feat(admin): upgrade invite code management with dialogs

Replace standalone add and export views with accessible dialogs, refine list interactions, and remove obsolete paths and unused counts. Fix filtered totals and reject oversized imports without truncation, with regression coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
kate522andClaude Fable 5 committed 2026-10-30 18:53:01 +08:00
1 parent 1cd19434d1
commit a6776ffe0a
8 files changed
+884 -400

No files matched your search

+101 -46
View File
@@ -1,49 +1,104 @@
/*
* 邀请码管理(users.php?page=aksrt_invite_codes)页面专属样式。
*
* 共享外壳/卡片/表格/徽标由 native-ui.css 承载;这里只留列表排序箭头、
* 添加/导出表单的行布局与按钮组切换、复制提示 toast 等本页独有交互样式。
* 仅在 users_page_aksrt_invite_codes 入队,规则限定在 .aksrt-inv-page 下。
*/
/* 邀请码原生运营页;外壳、表格与表单令牌沿用 native-ui.css。 */
.aksrt-inv-page [hidden] { display: none !important; }
.aksrt-inv-page .aksrt-native-header__actions .button { display: inline-flex; align-items: center; gap: 5px; }
.aksrt-inv-page .aksrt-native-header__actions .dashicons { margin: 0; font-size: 17px; line-height: 20px; }
.aksrt-inv-toolbar .subsubsub { margin: 0; padding: 0; box-shadow: none; }
.aksrt-inv-toolbar .aksrt-native-filters { margin: 0; }
.aksrt-inv-filter-summary { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--an-border-soft); overflow-wrap: anywhere; }
.aksrt-inv-selection { color: var(--an-text-2); font-size: 12px; font-variant-numeric: tabular-nums; }
/* 排序箭头:默认弱化,悬停加强。 */
.aksrt-inv-page .orderby-but { position: relative; display: inline-block; margin-left: 4px; }
.aksrt-inv-page .orderby-but > a { opacity: .4; position: absolute; transform: translateY(-3px); transition: .3s; }
.aksrt-inv-page .orderby-but > a + a { transform: translateY(6px); }
.aksrt-inv-page .orderby-but:hover a { opacity: .6; }
.aksrt-inv-page .orderby-but > a:hover { opacity: 1; }
.aksrt-inv-page .aksrt-inv-table { min-width: 1000px; table-layout: fixed; }
.aksrt-inv-table .column-code { width: 21%; }
.aksrt-inv-table .column-rewards { width: 14%; }
.aksrt-inv-table .column-created_at,
.aksrt-inv-table .column-updated_at { width: 16%; }
.aksrt-inv-table .column-used_count { width: 13%; }
.aksrt-inv-table .column-remark { width: 20%; }
.aksrt-inv-page .aksrt-inv-table td { vertical-align: middle; overflow-wrap: anywhere; }
.aksrt-inv-table .orderby-but { display: inline-flex; align-items: center; gap: 3px; color: var(--an-text-2); text-decoration: none; }
.aksrt-inv-table .orderby-but .dashicons { width: 14px; height: 14px; font-size: 14px; opacity: .35; }
.aksrt-inv-table .orderby-but:hover,
.aksrt-inv-table .orderby-but.is-active { color: var(--an-primary); }
.aksrt-inv-table .orderby-but.is-active .dashicons { opacity: 1; }
.aksrt-inv-code { display: inline-flex; align-items: center; gap: 5px; max-width: 100%; padding: 5px 7px; color: var(--an-text); background: var(--an-bg-soft); border: 1px solid transparent; border-radius: 5px; text-align: left; font-size: 13px; overflow-wrap: anywhere; cursor: pointer; }
.aksrt-inv-code .dashicons { flex: none; width: 14px; height: 14px; color: var(--an-muted); font-size: 14px; }
.aksrt-inv-code:hover { color: var(--an-primary); background: var(--an-primary-soft); border-color: var(--an-primary); }
.aksrt-inv-date { font-variant-numeric: tabular-nums; font-size: 12px; }
.aksrt-inv-user { margin-top: 4px; font-size: 12px; }
.aksrt-inv-table tr.is-selected > td,
.aksrt-inv-table tr.is-selected > th { background: var(--an-primary-soft); }
.aksrt-inv-page .aksrt-inv-code:focus-visible,
.aksrt-inv-page .aksrt-inv-dialog__close:focus-visible { outline: 2px solid var(--an-primary); outline-offset: 2px; }
/* 复制邀请码:指针反馈。 */
.aksrt-inv-page .aksrt-inv-code { cursor: pointer; }
html.aksrt-inv-modal-open { overflow: hidden; }
.aksrt-inv-dialog { width: min(680px, calc(100vw - 32px)); max-width: none; max-height: calc(100vh - 64px); max-height: calc(100dvh - 64px); margin: auto; padding: 0; overflow: hidden; color: var(--an-text-2); background: var(--an-card); border: 0; border-radius: var(--an-radius); box-shadow: 0 16px 64px rgba(15, 23, 42, .2); }
.aksrt-inv-dialog::backdrop { background: rgba(15, 23, 42, .45); }
#aksrt-inv-export { width: min(560px, calc(100vw - 32px)); }
.aksrt-inv-dialog__frame { display: flex; flex-direction: column; max-height: inherit; margin: 0; }
.aksrt-inv-dialog__head { display: flex; flex: none; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 20px 24px 18px; border-bottom: 1px solid var(--an-border-soft); }
.aksrt-inv-dialog .aksrt-inv-dialog__head h2 { margin: 0; padding: 0; color: var(--an-text); font-size: 18px; font-weight: 600; line-height: 1.5; }
.aksrt-inv-dialog__head p { margin: 5px 0 0; font-size: 12px; line-height: 1.7; }
.aksrt-inv-dialog__close { display: inline-flex; flex: none; align-items: center; justify-content: center; width: 30px; height: 30px; padding: 0; color: var(--an-text-2); background: transparent; border: 0; border-radius: 6px; cursor: pointer; }
.aksrt-inv-dialog__close:hover { color: var(--an-text); background: var(--an-bg-soft); }
.aksrt-inv-dialog__body { min-height: 0; padding: 20px 24px 24px; overflow-y: auto; overscroll-behavior: contain; }
.aksrt-inv-dialog__foot { display: flex; flex: none; justify-content: flex-end; gap: 8px; padding: 16px 24px; border-top: 1px solid var(--an-border-soft); background: var(--an-card); }
.aksrt-inv-dialog .aksrt-native-form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.aksrt-inv-dialog .aksrt-native-field { min-width: 0; }
.aksrt-inv-dialog .aksrt-native-field > input,
.aksrt-inv-dialog .aksrt-native-field > textarea { width: 100%; max-width: 100%; }
.aksrt-inv-dialog textarea { resize: vertical; }
.aksrt-inv-pane { min-width: 0; margin: 0; padding: 0; border: 0; }
.aksrt-inv-modes { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; margin: 0 0 22px; padding: 4px; background: var(--an-bg-soft); border: 0; border-radius: var(--an-radius-sm); }
.aksrt-inv-modes label { position: relative; cursor: pointer; }
.aksrt-inv-modes input { position: absolute; width: 1px; height: 1px; margin: -1px; opacity: 0; clip-path: inset(50%); }
.aksrt-inv-modes span { display: block; padding: 9px 12px; border: 1px solid transparent; border-radius: 6px; text-align: center; }
.aksrt-inv-modes input:checked + span { color: var(--an-primary); background: var(--an-card); border-color: var(--an-primary); font-weight: 600; }
.aksrt-inv-modes input:focus-visible + span { outline: 2px solid var(--an-primary); outline-offset: 2px; }
.aksrt-inv-rewards { min-width: 0; margin: 22px 0; padding: 18px; border: 1px solid var(--an-border); border-radius: var(--an-radius-sm); }
.aksrt-inv-rewards > legend,
.aksrt-inv-options > legend { padding: 0 5px; color: var(--an-text); font-weight: 600; }
.aksrt-inv-hint { margin: -2px 0 14px; font-size: 12px; line-height: 1.7; }
.aksrt-inv-dialog .aksrt-inv-reward-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.aksrt-inv-vip { margin: 16px 0 0; padding: 0; border: 0; }
.aksrt-inv-vip legend { margin-bottom: 8px; padding: 0; font-weight: 600; }
.aksrt-inv-radios { display: flex; flex-wrap: wrap; gap: 12px 18px; }
.aksrt-inv-radios label { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
.aksrt-inv-radios input { margin: 0; }
.aksrt-inv-vip-time { max-width: 200px; margin-top: 14px; }
.aksrt-inv-advanced { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
.aksrt-inv-advanced input { margin: 0; }
.aksrt-inv-length { margin-top: 12px; }
.aksrt-inv-dialog .aksrt-inv-length input { max-width: 160px; }
.aksrt-inv-separator { margin-top: 16px; }
.aksrt-inv-dialog .aksrt-inv-separator input { max-width: 100px; }
.aksrt-inv-help { margin-top: 20px; padding-top: 14px; border-top: 1px solid var(--an-border-soft); line-height: 1.8; }
.aksrt-inv-help summary { color: var(--an-primary); cursor: pointer; }
.aksrt-inv-help p { font-size: 12px; }
.aksrt-inv-help pre { padding: 12px; overflow: auto; font-size: 12px; background: var(--an-bg-soft); border-radius: 6px; }
.aksrt-inv-options { min-width: 0; margin: 0 0 22px; padding: 0; border: 0; }
.aksrt-inv-options > legend { margin-bottom: 12px; padding: 0; }
.aksrt-inv-formats { display: grid; gap: 10px; }
.aksrt-inv-formats label { display: flex; align-items: flex-start; gap: 10px; padding: 13px 14px; border: 1px solid var(--an-border); border-radius: var(--an-radius-sm); cursor: pointer; }
.aksrt-inv-formats label:has(input:checked) { background: var(--an-primary-soft); border-color: var(--an-primary); }
.aksrt-inv-formats input { flex: none; margin: 3px 0 0; }
.aksrt-inv-formats strong { display: block; color: var(--an-text); font-weight: 600; }
.aksrt-inv-formats small { display: block; margin-top: 5px; font-size: 12px; line-height: 1.7; }
.aksrt-inv-page .aksrt-inv-export-note { margin: 20px 0 0; padding: 12px 14px; font-size: 12px; line-height: 1.8; }
.aksrt-inv-toast { position: fixed; z-index: 100100; top: 48px; left: 50%; max-width: calc(100vw - 48px); padding: 10px 18px; transform: translateX(-50%); color: #fff; background: #1f2329; border-radius: var(--an-radius-sm); box-shadow: var(--an-shadow); opacity: 0; pointer-events: none; }
.aksrt-inv-toast.is-visible { opacity: 1; }
/* 三视图切换链接(列表 / 添加 / 导出):贴合页头下方。 */
.aksrt-inv-page .aksrt-inv-switch-views { margin: -8px 0 12px; display: flex; flex-wrap: wrap; gap: 6px; }
.aksrt-inv-page .aksrt-inv-switch-views .page-title-action { display: inline-flex; align-items: center; gap: 4px; }
/* 选项字段布局:左标题右控件,行间分隔线。 */
.aksrt-inv-page .aksrt-inv-field { display: flex; align-items: flex-start; padding: 14px 0; border-bottom: 1px solid var(--an-border-soft); }
.aksrt-inv-page .aksrt-inv-field:last-child { border-bottom: 0; }
.aksrt-inv-page .aksrt-inv-field > label { flex: 0 0 170px; color: var(--an-text); font-weight: 600; padding-top: 4px; }
.aksrt-inv-page .aksrt-inv-field > .aksrt-inv-control { flex: 1; min-width: 0; }
.aksrt-inv-page .aksrt-inv-field .description { display: block; margin-top: 4px; }
/* 添加方式按钮组(二选一)。 */
.aksrt-inv-page .aksrt-inv-btnset label { display: inline-block; margin-right: -1px; padding: 4px 14px; color: var(--an-text-2); background: #fff; border: 1px solid var(--an-border); cursor: pointer; }
.aksrt-inv-page .aksrt-inv-btnset label:first-child { border-radius: var(--an-radius-sm) 0 0 var(--an-radius-sm); }
.aksrt-inv-page .aksrt-inv-btnset label:last-child { border-radius: 0 var(--an-radius-sm) var(--an-radius-sm) 0; }
.aksrt-inv-page .aksrt-inv-btnset input { display: none; }
.aksrt-inv-page .aksrt-inv-btnset label.on { color: #fff; background: var(--an-primary); border-color: var(--an-primary); }
/* 开关式复选 / 奖励字段行 / 单位后缀。 */
.aksrt-inv-page .aksrt-inv-switch { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
.aksrt-inv-page .aksrt-inv-switch input { margin: 0; }
.aksrt-inv-page .aksrt-inv-rewards { display: flex; flex-wrap: wrap; gap: 12px; }
.aksrt-inv-page .aksrt-inv-radios { margin-top: 8px; }
.aksrt-inv-page .aksrt-inv-radios label + label { margin-left: 12px; font-weight: 400; }
.aksrt-inv-page .aksrt-inv-viptime { margin-top: 8px; }
.aksrt-inv-page .aksrt-inv-mini { width: 80px; }
.aksrt-inv-page .aksrt-inv-unit { margin-left: 4px; color: var(--an-muted); }
.aksrt-inv-page .aksrt-inv-textarea { resize: both; }
/* 复制成功悬浮提示。 */
.aksrt-inv-toast { position: fixed; top: 42px; left: 50%; transform: translateX(-50%); display: none; padding: 8px 22px; color: #fff; font-size: 13px; background: rgba(0, 0, 0, .75); border-radius: 4px; z-index: 100100; }
@media (max-width: 600px) {
.aksrt-inv-page .aksrt-native-header__actions { width: 100%; }
.aksrt-inv-page .aksrt-native-header__actions .button { flex: 1; justify-content: center; }
.aksrt-inv-toolbar .aksrt-native-filters { width: 100%; }
.aksrt-inv-toolbar input[type="search"] { flex: 1; width: 0; min-width: 0; }
.aksrt-inv-dialog { max-height: calc(100dvh - 24px); }
.aksrt-inv-dialog__head,
.aksrt-inv-dialog__body,
.aksrt-inv-dialog__foot { padding-left: 16px; padding-right: 16px; }
.aksrt-inv-dialog .aksrt-native-form-grid { grid-template-columns: 1fr; }
.aksrt-inv-rewards { padding: 12px; }
.aksrt-inv-dialog .aksrt-inv-reward-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
.aksrt-inv-radios { gap: 12px; }
.aksrt-inv-dialog__foot .button { flex: 1; }
}
+158
View File
@@ -0,0 +1,158 @@
/** 邀请码管理:弹窗、条件表单、复制与批量选择。仅本页入队。 */
(function () {
'use strict';
var root = document.querySelector('.aksrt-inv-page');
if (!root) return;
var toast = root.querySelector('[data-aksrt-inv-toast]');
var toastTimer;
var add = root.querySelector('#aksrt-inv-add');
var addForm = add.querySelector('form');
var addSubmit = add.querySelector('[data-aksrt-inv-add-submit]');
var importing = addForm.elements.import_data;
function notify(key) {
clearTimeout(toastTimer);
toast.textContent = toast.getAttribute('data-' + key);
toast.classList.add('is-visible');
toastTimer = setTimeout(function () { toast.classList.remove('is-visible'); }, 4000);
}
function syncForm(form) {
form.querySelectorAll('[data-aksrt-inv-dep]').forEach(function (pane) {
var parts = pane.getAttribute('data-aksrt-inv-dep').split('=');
var field = form.querySelector('[name="' + parts[0] + '"]:checked') || form.querySelector('[name="' + parts[0] + '"]');
var value = field && field.type === 'checkbox' ? (field.checked ? field.value : '') : field && field.value;
var active = !!field && !field.matches(':disabled') && parts[1].split('|').includes(value);
pane.hidden = !active;
pane.disabled = !active;
});
if (form === addForm && !form.dataset.submitting) {
addSubmit.textContent = addSubmit.getAttribute(form.elements.add_type.value === 'import' ? 'data-import-label' : 'data-auto-label');
}
}
root.querySelectorAll('.aksrt-inv-dialog').forEach(function (dialog) {
var opener;
var startedOutside = 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;
}
root.querySelectorAll('[data-aksrt-dialog-open="' + dialog.id + '"]').forEach(function (button) {
button.addEventListener('click', function () {
opener = button;
syncForm(dialog.querySelector('form'));
dialog.showModal();
document.documentElement.classList.add('aksrt-inv-modal-open');
});
});
dialog.querySelectorAll('[data-aksrt-dialog-close]').forEach(function (button) {
button.addEventListener('click', function () { dialog.close(); });
});
dialog.addEventListener('pointerdown', function (event) {
startedOutside = event.target === dialog && outside(event);
});
dialog.addEventListener('click', function (event) {
if (startedOutside && event.target === dialog && outside(event)) dialog.close();
startedOutside = false;
});
dialog.addEventListener('close', function () {
document.documentElement.classList.toggle('aksrt-inv-modal-open', !!root.querySelector('dialog[open]'));
if (opener && opener.isConnected) opener.focus();
});
var form = dialog.querySelector('form');
form.addEventListener('change', function () { syncForm(form); });
syncForm(form);
});
root.addEventListener('click', async function (event) {
var button = event.target.closest('[data-clipboard-text]');
if (!button) return;
var value = button.getAttribute('data-clipboard-text');
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(value);
} else {
var input = document.createElement('textarea');
input.value = value;
input.className = 'screen-reader-text';
root.appendChild(input);
input.select();
var copied = false;
try {
copied = document.execCommand('copy');
} finally {
input.remove();
button.focus();
}
if (!copied) throw new Error('copy');
}
notify('copied');
} catch (error) {
var range = document.createRange();
range.selectNodeContents(button);
var selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
notify('copy-failed');
}
});
addForm.addEventListener('submit', function (event) {
if (addForm.dataset.submitting) {
event.preventDefault();
return;
}
addForm.dataset.submitting = '1';
addSubmit.disabled = true;
addSubmit.textContent = addSubmit.getAttribute('data-busy-label');
});
importing.addEventListener('input', function () {
var count = importing.value.split(/\r\n|\r|\n/).filter(function (line) { return line.trim() !== ''; }).length;
importing.setCustomValidity(count > 1000 ? importing.getAttribute('data-limit-error') : '');
});
root.querySelector('#aksrt-inv-export form').addEventListener('submit', function () {
root.querySelector('#aksrt-inv-export').close();
notify('exported');
});
var list = root.querySelector('[data-aksrt-inv-list]');
var checks = Array.from(list.querySelectorAll('[name="action_id[]"]'));
var checkAll = list.querySelector('[data-aksrt-inv-check-all]');
var selection = list.querySelector('[data-aksrt-inv-selection]');
var bulk = list.querySelector('[data-aksrt-inv-bulk]');
function syncSelection() {
var count = checks.filter(function (check) { return check.checked; }).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 = checks.length === 0;
bulk.disabled = !!list.dataset.submitting || count === 0 || list.elements.bulk_action.value !== 'delete';
checks.forEach(function (check) { check.closest('tr').classList.toggle('is-selected', check.checked); });
return count;
}
checkAll.addEventListener('change', function () {
checks.forEach(function (check) { check.checked = checkAll.checked; });
syncSelection();
});
list.addEventListener('change', syncSelection);
list.addEventListener('submit', function (event) {
var count = syncSelection();
if (list.dataset.submitting || bulk.disabled || !window.confirm(list.getAttribute('data-confirm').replace('%d', String(count)))) {
event.preventDefault();
return;
}
list.dataset.submitting = '1';
bulk.disabled = true;
});
window.addEventListener('pageshow', function () {
delete addForm.dataset.submitting;
delete list.dataset.submitting;
addSubmit.disabled = false;
syncForm(addForm);
syncSelection();
document.documentElement.classList.toggle('aksrt-inv-modal-open', !!root.querySelector('dialog[open]'));
});
syncSelection();
})();
+109 -315
View File
@@ -1,9 +1,6 @@
<?php
/**
* Native invite-code operations page.
*
* 视图结构(列表 / 添加 / 导出)对齐参考主题的运营后台交互:
* 服务端渲染 + 表单提交 + 结果回跳提示,无 AJAX 依赖。
* Native invite-code list and page assets.
*
* @package AKSRT
*/
@@ -13,16 +10,16 @@ namespace AKSRT\Admin\Native;
defined('ABSPATH') || exit;
trait UsersMenusInviteCode {
use UsersMenusInviteForms;
/** 页面专属样式(排序箭头 / 按钮组 / 字段行 / 复制提示),只在本页入队。 */
public function enqueue_invite_assets(string $hook): void {
if ($hook !== 'users_page_aksrt_invite_codes') {
return;
}
$css = '/assets/css/admin/invite-codes.css';
if (is_file(AKSRT_DIR . $css)) {
wp_enqueue_style('aksrt-admin-invite-codes', AKSRT_URI . $css, [], (string) filemtime(AKSRT_DIR . $css));
}
$js = '/assets/js/admin-invite-codes.js';
wp_enqueue_style('aksrt-admin-invite-codes', AKSRT_URI . $css, [NativeUi::STYLE_HANDLE], (string) filemtime(AKSRT_DIR . $css));
wp_enqueue_script('aksrt-admin-invite-codes', AKSRT_URI . $js, [], (string) filemtime(AKSRT_DIR . $js), true);
}
public function render_invite_codes_page(): void {
@@ -32,353 +29,150 @@ trait UsersMenusInviteCode {
$service = \AKSRT\Payment\InviteCode::get_instance();
$this_url = admin_url('users.php?page=aksrt_invite_codes');
$tab = isset($_GET['tab']) ? sanitize_key((string) wp_unslash($_GET['tab'])) : '';
if (!in_array($tab, ['add', 'export'], true)) {
$tab = '';
}
$nonce = wp_create_nonce('aksrt_invite_admin');
NativeUi::page_open('aksrt-inv-page');
NativeUi::header(
__('邀请码管理', 'aksrt'),
__('管理注册邀请码与使用奖励,点击邀请码即可复制。', 'aksrt'),
'dashicons-tickets-alt',
[
['label' => __('添加邀请码', 'aksrt'), 'dialog' => 'aksrt-inv-add', 'primary' => true, 'icon' => 'dashicons-plus-alt2'],
['label' => __('导出邀请码', 'aksrt'), 'dialog' => 'aksrt-inv-export', 'icon' => 'dashicons-download'],
]
);
// 功能未启用提示(置顶橙色提醒)。
$settings = $service->registration_settings();
if (empty($settings['enabled'])) {
$settings_url = admin_url('admin.php?page=aksrt-settings');
echo '<div class="notice notice-warning"><p>'
. esc_html__('注意:邀请码功能暂未启用。您可以在', 'aksrt')
. ' <a href="' . esc_url($settings_url) . '">' . esc_html__('登录注册设置', 'aksrt') . '</a> '
. esc_html__('中启用此功能', 'aksrt')
. '</p></div>';
$settings_url = admin_url('admin.php?page=aksrt-settings#/users/auth');
echo '<div class="notice notice-warning"><p>' . esc_html__('邀请码注册尚未启用,已添加的邀请码暂不能用于注册。', 'aksrt')
. ' <a href="' . esc_url($settings_url) . '">' . esc_html__('前往登录注册设置', 'aksrt') . '</a></p></div>';
}
// 操作结果回跳提示:标题 + 说明,成功 / 错误两种形态。
$inv_title = isset($_GET['aksrt_inv_title']) ? sanitize_text_field(rawurldecode((string) wp_unslash($_GET['aksrt_inv_title']))) : '';
$inv_msg = isset($_GET['aksrt_inv_msg']) ? sanitize_text_field(rawurldecode((string) wp_unslash($_GET['aksrt_inv_msg']))) : '';
$inv_type = isset($_GET['aksrt_inv_type']) && $_GET['aksrt_inv_type'] === 'error' ? 'error' : 'success';
if ($inv_title !== '') {
echo '<div class="notice notice-' . esc_attr($inv_type) . ' is-dismissible"><h3>' . esc_html($inv_title) . '</h3>'
. ($inv_msg !== '' ? '<p>' . esc_html($inv_msg) . '</p>' : '') . '</div>';
}
if ($tab === '') {
$this->render_invite_codes_list($service, $this_url, $nonce);
} elseif ($tab === 'add') {
$this->render_invite_codes_add($this_url, $nonce);
} else {
$this->render_invite_codes_export($this_url, $nonce);
$title = isset($_GET['aksrt_inv_title']) ? sanitize_text_field(rawurldecode((string) wp_unslash($_GET['aksrt_inv_title']))) : '';
$message = isset($_GET['aksrt_inv_msg']) ? sanitize_text_field(rawurldecode((string) wp_unslash($_GET['aksrt_inv_msg']))) : '';
$type = ($_GET['aksrt_inv_type'] ?? '') === 'error' ? 'error' : 'success';
if ($title !== '') {
echo '<div class="notice notice-' . esc_attr($type) . ' is-dismissible"><p><strong>' . esc_html($title) . '</strong>'
. ($message !== '' ? ' ' . esc_html($message) : '') . '</p></div>';
}
$this->render_invite_codes_list($service, $this_url, $nonce);
$this->render_invite_codes_add($nonce);
$this->render_invite_codes_export($nonce);
echo '<div class="aksrt-inv-toast" role="status" aria-live="polite" data-aksrt-inv-toast'
. ' data-copied="' . esc_attr__('邀请码已复制', 'aksrt') . '"'
. ' data-copy-failed="' . esc_attr__('自动复制失败,请选中邀请码后手动复制。', 'aksrt') . '"'
. ' data-exported="' . esc_attr__('下载请求已提交,请查看浏览器下载列表。', 'aksrt') . '"></div>';
NativeUi::page_close();
?>
<div class="aksrt-inv-toast" id="aksrt-inv-toast"><?php esc_html_e('复制成功', 'aksrt'); ?></div>
<script>
(() => {
// 依赖切换:添加方式 / 高级选项 / VIP 时长。
const sync = () => {
document.querySelectorAll('[data-aksrt-inv-dep]').forEach((el) => {
const [name, value] = el.dataset.aksrtInvDep.split('=');
const input = document.querySelector(`[name="${name}"]:checked`) || document.querySelector(`[name="${name}"]`);
const show = input ? String(input.type === 'checkbox' ? (input.checked ? '1' : '') : input.value) === value : false;
el.style.display = show ? '' : 'none';
});
document.querySelectorAll('.aksrt-inv-btnset').forEach((group) => {
group.querySelectorAll('label').forEach((label) => label.classList.toggle('on', label.querySelector('input').checked));
});
};
document.addEventListener('change', sync);
// 点击复制邀请码,悬浮提示结果。
const toast = document.getElementById('aksrt-inv-toast');
document.addEventListener('click', async (event) => {
const target = event.target.closest('[data-clipboard-text]');
if (!target) return;
try {
await navigator.clipboard.writeText(target.dataset.clipboardText);
if (toast) { toast.style.display = 'block'; setTimeout(() => { toast.style.display = 'none'; }, 1200); }
} catch (error) {
const range = document.createRange();
range.selectNodeContents(target);
const selection = getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
});
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', sync, { once: true });
} else {
sync();
}
})();
</script>
<?php
}
/** 列表视图:状态筛选、搜索、批量删除、排序、分页。 */
private function render_invite_codes_list(\AKSRT\Payment\InviteCode $service, string $this_url, string $nonce): void {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- 只读列表查询。
$status = in_array(($_GET['status'] ?? 'all'), ['all', 'unused', 'used'], true) ? (string) $_GET['status'] : 'all';
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- 只读列表查询。
$s = isset($_GET['s']) ? sanitize_text_field((string) wp_unslash($_GET['s'])) : '';
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- 只读列表查询。
$remark = isset($_GET['remark']) ? sanitize_text_field((string) wp_unslash($_GET['remark'])) : '';
$data = $service->list_codes([
'page' => isset($_GET['paged']) ? max(1, (int) $_GET['paged']) : 1, // phpcs:ignore WordPress.Security.NonceVerification.Recommended
'page' => isset($_GET['paged']) ? max(1, (int) $_GET['paged']) : 1,
'status' => $status,
's' => $s,
'remark' => $remark,
'orderby' => isset($_GET['orderby']) ? sanitize_key((string) wp_unslash($_GET['orderby'])) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended
'desc' => isset($_GET['desc']) ? sanitize_key((string) wp_unslash($_GET['desc'])) : '', // phpcs:ignore WordPress.Security.NonceVerification.Recommended
'orderby' => isset($_GET['orderby']) ? sanitize_key((string) wp_unslash($_GET['orderby'])) : '',
'desc' => isset($_GET['desc']) ? sanitize_key((string) wp_unslash($_GET['desc'])) : '',
]);
$filters = ['status' => $status, 's' => $s, 'remark' => $remark, 'orderby' => $data['orderby'], 'desc' => $data['desc']];
$filter_url = add_query_arg($filters, $this_url);
// 页头 + 三视图切换(WP 原生 page-title-action:列表 / 添加 / 导出)。
NativeUi::header(
__('邀请码管理', 'aksrt'),
'',
'dashicons-tickets-alt'
);
printf(
'<div class="aksrt-inv-switch-views"><a href="%s" class="page-title-action">%s</a> <a href="%s" class="page-title-action">%s</a></div>',
esc_url(add_query_arg('tab', 'add', $this_url)),
esc_html__('添加邀请码', 'aksrt'),
esc_url(add_query_arg('tab', 'export', $this_url)),
esc_html__('导出邀请码', 'aksrt')
);
// 顶部筛选行:左侧状态链接(搜索时改为提示语),右侧搜索表单。
NativeUi::card_open('', '', false, 'aksrt-native-toolbar');
if ($s !== '') {
echo '<ul class="subsubsub"><li>' . esc_html(sprintf(__('搜索 “%s” 的内容', 'aksrt'), $s)) . '</li></ul>';
} else {
$links = [
'all' => __('全部', 'aksrt'),
'unused' => __('未使用', 'aksrt'),
'used' => __('已使用', 'aksrt'),
];
echo '<ul class="subsubsub">';
foreach ($links as $key => $label) {
$url = $key === 'all' ? $this_url : add_query_arg('status', $key, $this_url);
echo '<li><a href="' . esc_url($url) . '"' . ($key === $status ? ' class="current"' : '') . '>' . esc_html($label) . '</a></li>';
NativeUi::card_open('', '', false, 'aksrt-inv-toolbar');
echo '<div class="aksrt-native-toolbar"><ul class="subsubsub" aria-label="' . esc_attr__('使用状态筛选', 'aksrt') . '">';
foreach (['all' => __('全部', 'aksrt'), 'unused' => __('未使用', 'aksrt'), 'used' => __('已使用', 'aksrt')] as $key => $label) {
$url = add_query_arg('status', $key, $filter_url);
echo '<li><a href="' . esc_url($url) . '"' . ($key === $status ? ' class="current" aria-current="page"' : '') . '>' . esc_html($label) . '</a></li>';
}
echo '</ul><form method="get" action="' . esc_url(admin_url('users.php')) . '" class="aksrt-native-filters">';
echo '<input type="hidden" name="page" value="aksrt_invite_codes">';
foreach (array_diff_key($filters, ['s' => '']) as $key => $value) {
echo '<input type="hidden" name="' . esc_attr($key) . '" value="' . esc_attr($value) . '">';
}
echo '<label class="screen-reader-text" for="aksrt-inv-search">' . esc_html__('搜索邀请码或标识', 'aksrt') . '</label>';
echo '<input type="search" id="aksrt-inv-search" name="s" value="' . esc_attr($s) . '" placeholder="' . esc_attr__('搜索邀请码或标识', 'aksrt') . '">';
echo '<button type="submit" class="button">' . esc_html__('搜索', 'aksrt') . '</button></form></div>';
if ($s !== '' || $remark !== '' || $status !== 'all') {
echo '<div class="aksrt-inv-filter-summary">';
if ($s !== '') {
echo '<span>' . esc_html(sprintf(__('关键词:%s', 'aksrt'), $s)) . '</span>';
}
echo '</ul>';
if ($remark !== '') {
echo '<span>' . esc_html(sprintf(__('标识:%s', 'aksrt'), $remark)) . '</span>';
}
echo '<a href="' . esc_url($this_url) . '">' . esc_html__('清除筛选', 'aksrt') . '</a></div>';
}
echo '<form method="get" class="aksrt-native-filters"><input type="hidden" name="page" value="aksrt_invite_codes">';
echo '<input type="search" name="s" value="' . esc_attr($s) . '" placeholder="' . esc_attr__('搜索邀请码', 'aksrt') . '">';
echo '<button type="submit" class="button">' . esc_html__('提交', 'aksrt') . '</button></form>';
NativeUi::card_close();
// 可排序列:code / created_at / updated_at / used_count / remark。
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" data-aksrt-inv-list'
. ' data-confirm="' . esc_attr__('确定删除选中的 %d 个邀请码及其使用记录?此操作不可撤销。', 'aksrt') . '">';
echo '<input type="hidden" name="action" value="aksrt_invite_delete"><input type="hidden" name="aksrt_nonce" value="' . esc_attr($nonce) . '">';
$bulk = '<div class="aksrt-native-inline"><span class="aksrt-inv-selection" data-aksrt-inv-selection data-label="' . esc_attr__('已选 %d 项', 'aksrt') . '" aria-live="polite"></span>'
. '<label class="screen-reader-text" for="bulk-action-selector-top">' . esc_html__('批量操作', 'aksrt') . '</label>'
. '<select name="bulk_action" id="bulk-action-selector-top"><option value="-1">' . esc_html__('批量操作', 'aksrt') . '</option><option value="delete">' . esc_html__('删除', 'aksrt') . '</option></select>'
. '<button type="submit" class="button" data-aksrt-inv-bulk disabled>' . esc_html__('应用', 'aksrt') . '</button></div>';
NativeUi::table_card_open(__('邀请码列表', 'aksrt'), $bulk);
echo '<table class="widefat fixed striped aksrt-inv-table"><thead><tr><td class="manage-column column-cb check-column">';
echo '<input type="checkbox" data-aksrt-inv-check-all aria-label="' . esc_attr__('全选本页邀请码', 'aksrt') . '"></td>';
$columns = [
['width' => '6%', 'key' => 'code', 'name' => __('邀请码', 'aksrt')],
['width' => '10%', 'key' => '', 'name' => __('使用奖励', 'aksrt')],
['width' => '10%', 'key' => 'created_at', 'name' => __('创建时间', 'aksrt')],
['width' => '10%', 'key' => 'updated_at', 'name' => __('更新时间', 'aksrt')],
['width' => '6%', 'key' => 'used_count', 'name' => __('状态', 'aksrt')],
['width' => '12%', 'key' => 'remark', 'name' => __('标识', 'aksrt')],
'code' => __('邀请码', 'aksrt'), 'rewards' => __('使用奖励', 'aksrt'),
'created_at' => __('创建时间', 'aksrt'), 'updated_at' => __('更新时间', 'aksrt'),
'used_count' => __('使用状态', 'aksrt'), 'remark' => __('标识', 'aksrt'),
];
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
echo '<input type="hidden" name="action" value="aksrt_invite_delete">';
echo '<input type="hidden" name="aksrt_nonce" value="' . esc_attr($nonce) . '">';
$bulk = '<div class="bulkactions aksrt-native-inline">
<select name="bulk_action" id="bulk-action-selector-top">
<option value="-1">' . esc_html__('批量操作', 'aksrt') . '</option>
<option value="delete">' . esc_html__('删除', 'aksrt') . '</option>
</select>
<button type="submit" class="button action" onclick="return this.form.bulk_action.value===\'delete\'||confirm(' . esc_attr(wp_json_encode(__('请先选择“删除”操作', 'aksrt'))) . ');">' . esc_html__('应用', 'aksrt') . '</button>
</div>';
NativeUi::table_card_open(__('邀请码列表', 'aksrt'), $bulk); // phpcs:ignore WordPress.Security.EscapeOutput -- actions pre-escaped.
echo '<table class="widefat fixed striped"><thead><tr>';
echo '<td id="cb" class="manage-column column-cb check-column"><input id="cb-select-all-1" type="checkbox" onclick="document.querySelectorAll(\'tbody [name=&quot;action_id[]&quot;]\').forEach(c=>c.checked=this.checked)"></td>';
foreach ($columns as $column) {
$arrows = '';
if ($column['key'] !== '') {
$up = add_query_arg(['orderby' => $column['key'], 'desc' => 'ASC'], $this_url);
$down = add_query_arg(['orderby' => $column['key'], 'desc' => 'DESC'], $this_url);
$arrows = '<span class="orderby-but"><a title="' . esc_attr__('降序', 'aksrt') . '" href="' . esc_url($down) . '"><span class="dashicons dashicons-arrow-up"></span></a>'
. '<a title="' . esc_attr__('升序', 'aksrt') . '" href="' . esc_url($up) . '"><span class="dashicons dashicons-arrow-down"></span></a></span>';
foreach ($columns as $key => $label) {
$active = $key === $data['orderby'];
$sort = $data['desc'] === 'ASC' ? 'ascending' : 'descending';
echo '<th scope="col" class="column-' . esc_attr($key) . '"' . ($active ? ' aria-sort="' . esc_attr($sort) . '"' : '') . '>';
if ($key === 'rewards') {
echo esc_html($label);
} else {
$next = $active && $data['desc'] === 'ASC' ? 'DESC' : 'ASC';
$url = add_query_arg(['orderby' => $key, 'desc' => $next], $filter_url);
$icon = $active && $data['desc'] === 'ASC' ? 'dashicons-arrow-up-alt2' : 'dashicons-arrow-down-alt2';
echo '<a class="orderby-but' . ($active ? ' is-active' : '') . '" href="' . esc_url($url) . '" title="' . esc_attr($next === 'ASC' ? __('按此列升序排列', 'aksrt') : __('按此列降序排列', 'aksrt')) . '">'
. esc_html($label) . '<span class="dashicons ' . esc_attr($icon) . '" aria-hidden="true"></span></a>';
}
echo '<th>' . esc_html($column['name']) . $arrows . '</th>'; // phpcs:ignore WordPress.Security.EscapeOutput -- 箭头为静态结构。
echo '</th>';
}
echo '</tr></thead><tbody>';
if ($data['items'] === []) {
echo NativeUi::empty_row(7, __('未找到对应内容,或暂无内容', 'aksrt')); // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes.
} else {
foreach ($data['items'] as $item) {
$used = (int) $item['used_count'];
$max_uses = (int) $item['max_uses'];
echo '<tr>';
echo '<th scope="row" class="check-column"><input type="checkbox" name="action_id[]" value="' . esc_attr((string) $item['id']) . '"></th>';
echo '<td><span class="aksrt-inv-code aksrt-native-mono" data-clipboard-text="' . esc_attr($item['code']) . '" title="' . esc_attr__('点击复制', 'aksrt') . '">' . esc_html($item['code']) . '</span></td>';
echo '<td>' . wp_kses((string) $item['reward_text'], ['br' => []]) . '</td>';
echo '<td>' . esc_html($item['created_at']) . '</td>';
echo '<td>' . esc_html($item['updated_at']) . '</td>';
if ($used === 0) {
echo '<td>' . NativeUi::badge(__('未使用', 'aksrt'), 'info') . '</td>'; // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes.
} else {
$label = $max_uses > 0 && $used < $max_uses ? sprintf(__('已使用 %1$d/%2$d', 'aksrt'), $used, $max_uses) : __('已使用', 'aksrt');
echo '<td>' . NativeUi::badge($label, 'danger'); // phpcs:ignore WordPress.Security.EscapeOutput -- builder escapes.
foreach ((array) $item['recent_users'] as $user) {
$user_link = get_edit_user_link((int) $user['user_id']);
echo '<div class="aksrt-native-muted"><a target="_blank" href="' . esc_url($user_link) . '">' . esc_html($user['name']) . '</a></div>';
}
echo '</td>';
}
echo '<td>';
if ($item['remark'] !== '') {
echo '<a href="' . esc_url(add_query_arg('remark', $item['remark'], $this_url)) . '">' . esc_html($item['remark']) . '</a>';
}
echo '</td></tr>';
echo NativeUi::empty_row(7, __('暂无符合条件的邀请码', 'aksrt'), __('调整筛选条件,或点击“添加邀请码”生成、导入新的邀请码。', 'aksrt'));
}
foreach ($data['items'] as $item) {
$used = (int) $item['used_count'];
$max_uses = (int) $item['max_uses'];
echo '<tr><th scope="row" class="check-column"><input type="checkbox" name="action_id[]" value="' . esc_attr((string) $item['id']) . '" aria-label="' . esc_attr(sprintf(__('选择邀请码 %s', 'aksrt'), $item['code'])) . '"></th>';
echo '<td><button type="button" class="aksrt-inv-code aksrt-native-mono" data-clipboard-text="' . esc_attr($item['code']) . '" aria-label="' . esc_attr(sprintf(__('复制邀请码 %s', 'aksrt'), $item['code'])) . '">' . esc_html($item['code']) . '<span class="dashicons dashicons-admin-page" aria-hidden="true"></span></button></td>';
echo '<td>' . wp_kses((string) $item['reward_text'], ['br' => []]) . '</td>';
echo '<td class="aksrt-inv-date">' . esc_html($item['created_at']) . '</td><td class="aksrt-inv-date">' . esc_html($item['updated_at']) . '</td><td>';
$label = $used > 0 && $max_uses > 0 && $used < $max_uses ? sprintf(__('已使用 %1$d/%2$d', 'aksrt'), $used, $max_uses) : ($used > 0 ? __('已使用', 'aksrt') : __('未使用', 'aksrt'));
echo NativeUi::badge($label, $used > 0 ? 'neutral' : 'success');
foreach ((array) $item['recent_users'] as $user) {
echo '<div class="aksrt-inv-user"><a href="' . esc_url(get_edit_user_link((int) $user['user_id'])) . '">' . esc_html($user['name']) . '</a></div>';
}
echo '</td><td>';
if ($item['remark'] !== '') {
echo '<a href="' . esc_url(add_query_arg('remark', $item['remark'], $this_url)) . '">' . esc_html($item['remark']) . '</a>';
} else {
echo '<span class="aksrt-native-muted">—</span>';
}
echo '</td></tr>';
}
echo '</tbody></table>';
NativeUi::table_wrap_close();
// 分页条:总数 + 页码链接。
$total = (int) $data['total'];
$max_pages = (int) $data['max_pages'];
echo '<div class="tablenav bottom"><div class="tablenav-pages">';
echo '<span class="displaying-num">' . esc_html(sprintf(__('共 %s 项', 'aksrt'), number_format_i18n($total))) . '</span>';
if ($max_pages > 1) {
echo '<div class="tablenav bottom"><div class="tablenav-pages"><span class="displaying-num">' . esc_html(sprintf(__('共 %s 项', 'aksrt'), number_format_i18n((int) $data['total']))) . '</span>';
if ((int) $data['max_pages'] > 1) {
echo paginate_links([
'base' => add_query_arg('paged', '%#%'),
'format' => '',
'current' => (int) $data['page'],
'total' => $max_pages,
'type' => 'plain',
'prev_text' => '«',
'next_text' => '»',
]); // phpcs:ignore WordPress.Security.EscapeOutput -- paginate_links 自行转义。
'base' => add_query_arg('paged', '%#%', $filter_url), 'format' => '',
'current' => (int) $data['page'], 'total' => (int) $data['max_pages'],
'type' => 'plain', 'prev_text' => '«', 'next_text' => '»',
]);
}
echo '</div></div>';
NativeUi::card_close();
echo '</form>';
}
/** 添加视图:系统自动生成 / 文本导入 二选一。 */
private function render_invite_codes_add(string $this_url, string $nonce): void {
NativeUi::header(
__('添加邀请码', 'aksrt'),
'',
'dashicons-tickets-alt'
);
printf(
'<div class="aksrt-inv-switch-views"><a href="%s" class="page-title-action"><span class="dashicons dashicons-arrow-left-alt" aria-hidden="true"></span>%s</a></div>',
esc_url($this_url),
esc_html__('返回列表', 'aksrt')
);
NativeUi::card_open(__('添加邀请码', 'aksrt'));
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
echo '<input type="hidden" name="action" value="aksrt_invite_add">';
echo '<input type="hidden" name="aksrt_nonce" value="' . esc_attr($nonce) . '">';
echo '<div class="aksrt-native-callout"><p><b>' . esc_html__('在此添加邀请码', 'aksrt') . '</b></p><ul>'
. '<li>' . esc_html__('如果您已经准备好了邀请码资料,请选择导入的方式添加', 'aksrt') . '</li>'
. '<li>' . esc_html__('您也可以采用系统生成的方式,自动批量添加邀请码', 'aksrt') . '</li></ul></div>';
// 添加方式(按钮组二选一)。
echo '<div class="aksrt-inv-field"><label>' . esc_html__('添加方式', 'aksrt') . '</label><div class="aksrt-inv-control"><div class="aksrt-inv-btnset">'
. '<label><input type="radio" name="add_type" value="auto" checked>' . esc_html__('系统自动生成', 'aksrt') . '</label>'
. '<label><input type="radio" name="add_type" value="import">' . esc_html__('导入邀请码', 'aksrt') . '</label>'
. '</div></div></div>';
// 自动生成区。
echo '<div data-aksrt-inv-dep="add_type=auto">';
echo '<div class="aksrt-inv-field"><label for="aksrt-inv-num">' . esc_html__('生成数量', 'aksrt') . '</label><div class="aksrt-inv-control">'
. '<input type="number" id="aksrt-inv-num" name="auto_num" value="20" min="1" max="1000" step="10" class="small-text"><span class="aksrt-inv-unit">' . esc_html__('个', 'aksrt') . '</span>'
. '<span class="description">' . esc_html__('需要生成多少个邀请码(单次生成数量太多可能会对服务器性能造成影响)', 'aksrt') . '</span></div></div>';
echo '<div class="aksrt-inv-field"><label for="aksrt-inv-remarks">' . esc_html__('标识', 'aksrt') . '</label><div class="aksrt-inv-control">'
. '<input type="text" id="aksrt-inv-remarks" name="auto_remarks" value="' . esc_attr('invita_' . current_time('YmdHis')) . '" class="regular-text">'
. '<span class="description">' . esc_html__('对生成的邀请码做标记标识,方便后期查找管理', 'aksrt') . '</span></div></div>';
$this->render_invite_reward_fields();
echo '<div class="aksrt-inv-field"><label for="aksrt-inv-top">' . esc_html__('高级选项', 'aksrt') . '</label><div class="aksrt-inv-control">'
. '<label class="aksrt-inv-switch"><input type="checkbox" id="aksrt-inv-top" name="auto_top_s" value="1"><span>' . esc_html__('开启', 'aksrt') . '</span></label></div></div>';
echo '<div data-aksrt-inv-dep="auto_top_s=1" style="display:none;">';
echo '<div class="aksrt-inv-field"><label for="aksrt-inv-len">' . esc_html__('自定义位数', 'aksrt') . '</label><div class="aksrt-inv-control">'
. '<input type="number" id="aksrt-inv-len" name="auto_rand_password_limit" value="8" min="1" max="50" step="2" class="small-text"><span class="aksrt-inv-unit">' . esc_html__('位数', 'aksrt') . '</span>'
. '<span class="description">' . esc_html__('自定义自动生成的长度(不能太短,太短可能会出现重复)', 'aksrt') . '</span></div></div>';
echo '</div></div>';
// 导入区。
echo '<div data-aksrt-inv-dep="add_type=import" style="display:none;">';
echo '<div class="aksrt-native-callout"><p><b>' . esc_html__('导入邀请码', 'aksrt') . '</b></p><ul>'
. '<li>' . esc_html__('一行一个邀请码,单行格式为:', 'aksrt') . '<code>' . esc_html__('邀请码 奖励 标识', 'aksrt') . '</code></li>'
. '<li>' . esc_html__('邀请码、奖励、标识默认使用空格分割,您可以在下方自定义分割符号,与您的数据对应即可', 'aksrt') . '</li>'
. '<li>' . esc_html__('奖励格式为:', 'aksrt') . '<code>level_integral=10&points=20&balance=30&vip=1&vip_time=5</code>,' . esc_html__('无需奖励填', 'aksrt') . ' <code>' . esc_html__('无', 'aksrt') . '</code></li>'
. '<li>' . esc_html__('单次导入数量太多可能会对服务器性能造成影响', 'aksrt') . '</li></ul>'
. '<p><b>' . esc_html__('数据示例', 'aksrt') . '</b><br>'
. '<code>93FW0XAY ' . esc_html__('无', 'aksrt') . '</code> ' . esc_html__('没有奖励', 'aksrt') . '<br>'
. '<code>P86NPWKI level_integral=100</code> ' . esc_html__('奖励100经验值', 'aksrt') . '<br>'
. '<code>P86NPWKI points=10</code> ' . esc_html__('奖励10积分', 'aksrt') . '<br>'
. '<code>auF9D2B4 balance=30&vip=1&vip_time=5</code> ' . esc_html__('奖励余额30和5天1级会员', 'aksrt') . '</p></div>';
echo '<div class="aksrt-inv-field"><label for="aksrt-import-data">' . esc_html__('邀请码数据', 'aksrt') . '</label><div class="aksrt-inv-control">'
. '<textarea id="aksrt-import-data" name="import_data" rows="10" class="large-text code aksrt-inv-textarea"></textarea></div></div>';
echo '<div class="aksrt-inv-field"><label for="aksrt-import-division">' . esc_html__('自定义分隔符号', 'aksrt') . '</label><div class="aksrt-inv-control">'
. '<input type="text" id="aksrt-import-division" name="import_division" value=" " maxlength="4" class="aksrt-inv-mini">'
. '<span class="description">' . esc_html__('邀请码和奖励之间分割符号(默认为空格分割)', 'aksrt') . '</span></div></div>';
echo '</div>';
echo '<div class="aksrt-inv-field"><label></label><div class="aksrt-inv-control">'
. '<button type="submit" class="button button-primary">' . esc_html__('确认提交', 'aksrt') . '</button></div></div>';
echo '</form>';
NativeUi::card_close();
}
/** 使用奖励字段集:经验值 / 积分 / 余额 / VIP 会员与赠送时长。 */
private function render_invite_reward_fields(): void {
echo '<div class="aksrt-inv-field"><label>' . esc_html__('使用奖励', 'aksrt') . '</label><div class="aksrt-inv-control">';
echo '<div class="aksrt-inv-rewards">'
. '<span><label>' . esc_html__('经验值', 'aksrt') . ' <input type="number" name="level_integral" value="0" min="0" class="small-text"></label></span>'
. '<span><label>' . esc_html__('积分', 'aksrt') . ' <input type="number" name="points" value="0" min="0" class="small-text"></label></span>'
. '<span><label>' . esc_html__('余额', 'aksrt') . ' <input type="number" name="balance" value="0" min="0" step="0.01" class="small-text"> ' . esc_html__('元', 'aksrt') . '</label></span>'
. '</div>';
echo '<div class="aksrt-inv-vip"><label>' . esc_html__('VIP会员', 'aksrt') . ' '
. '<label class="aksrt-inv-radio"><input type="radio" name="vip" value="" checked>' . esc_html__('无', 'aksrt') . '</label>'
. '<label class="aksrt-inv-radio"><input type="radio" name="vip" value="1">' . esc_html__('一级', 'aksrt') . '</label>'
. '<label class="aksrt-inv-radio"><input type="radio" name="vip" value="2">' . esc_html__('二级', 'aksrt') . '</label>'
. '</label></div>';
echo '<div class="aksrt-inv-vip" data-aksrt-inv-dep="vip=1" style="display:none;"><label>' . esc_html__('会员赠送时长', 'aksrt')
. ' <input type="number" name="vip_time" value="0" min="0" class="small-text"> <span class="aksrt-inv-unit">' . esc_html__('天', 'aksrt') . '</span></label>'
. '<span class="description">' . esc_html__('仅在上方选择 VIP 会员等级后生效', 'aksrt') . '</span></div>';
echo '</div></div>';
}
/** 导出视图:状态 / 格式 选择后提交下载。 */
private function render_invite_codes_export(string $this_url, string $nonce): void {
NativeUi::header(
__('导出邀请码', 'aksrt'),
'',
'dashicons-download'
);
printf(
'<div class="aksrt-inv-switch-views"><a href="%s" class="page-title-action"><span class="dashicons dashicons-arrow-left-alt" aria-hidden="true"></span>%s</a></div>',
esc_url($this_url),
esc_html__('返回列表', 'aksrt')
);
NativeUi::card_open();
echo '<form method="get" action="' . esc_url(admin_url('admin-post.php')) . '">';
echo '<input type="hidden" name="action" value="aksrt_invite_export">';
echo '<input type="hidden" name="aksrt_nonce" value="' . esc_attr($nonce) . '">';
echo '<div class="aksrt-inv-field"><label>' . esc_html__('选择状态', 'aksrt') . '</label><div class="aksrt-inv-control">'
. '<label class="aksrt-inv-radio"><input type="radio" name="status" value="all" checked>' . esc_html__('全部', 'aksrt') . '</label>'
. '<label class="aksrt-inv-radio"><input type="radio" name="status" value="unused">' . esc_html__('未使用', 'aksrt') . '</label>'
. '<label class="aksrt-inv-radio"><input type="radio" name="status" value="used">' . esc_html__('已使用', 'aksrt') . '</label>'
. '</div></div>';
echo '<div class="aksrt-inv-field"><label>' . esc_html__('导出格式', 'aksrt') . '</label><div class="aksrt-inv-control">'
. '<label class="aksrt-inv-radio"><input type="radio" name="export_format" value="text">' . esc_html__('文本文档', 'aksrt') . '</label>'
. '<label class="aksrt-inv-radio"><input type="radio" name="export_format" value="xls" checked>' . esc_html__('Excel表格', 'aksrt') . '</label>'
. '</div></div>';
echo '<div data-aksrt-inv-dep="export_format=text" style="display:none;">';
echo '<div class="aksrt-inv-field"><label>' . esc_html__('分隔符号', 'aksrt') . '</label><div class="aksrt-inv-control">'
. '<input type="text" name="text_division" value=" " maxlength="4" class="aksrt-inv-mini">'
. '<span class="description">' . esc_html__('邀请码和奖励之间分割符号(默认为空格分割)', 'aksrt') . '</span></div></div>';
echo '</div>';
echo '<div class="aksrt-inv-field"><label></label><div class="aksrt-inv-control">'
. '<button type="submit" class="button button-primary">' . esc_html__('确认提交', 'aksrt') . '</button></div></div>';
echo '</form>';
NativeUi::card_close();
}
}
@@ -0,0 +1,92 @@
<?php
/**
* Invite-code generation, import and export dialogs.
*
* @package AKSRT
*/
namespace AKSRT\Admin\Native;
defined('ABSPATH') || exit;
trait UsersMenusInviteForms {
private function render_invite_codes_add(string $nonce): void {
$this->invite_dialog_open('aksrt-inv-add', __('添加邀请码', 'aksrt'), __('批量生成新邀请码,或导入已有的邀请码数据。', 'aksrt'), 'post', 'aksrt_invite_add', $nonce);
echo '<fieldset class="aksrt-inv-modes"><legend class="screen-reader-text">' . esc_html__('添加方式', 'aksrt') . '</legend>';
foreach (['auto' => __('系统自动生成', 'aksrt'), 'import' => __('导入邀请码', 'aksrt')] as $value => $label) {
echo '<label><input type="radio" name="add_type" value="' . esc_attr($value) . '"' . checked($value, 'auto', false) . '><span>' . esc_html($label) . '</span></label>';
}
echo '</fieldset><fieldset class="aksrt-inv-pane" data-aksrt-inv-dep="add_type=auto"><legend class="screen-reader-text">' . esc_html__('自动生成设置', 'aksrt') . '</legend>';
echo '<div class="aksrt-native-form-grid">';
echo '<label class="aksrt-native-field"><span class="aksrt-native-field__label">' . esc_html__('生成数量', 'aksrt') . '</span>'
. '<input type="number" name="auto_num" value="20" min="1" max="1000" step="1" required autofocus>'
. '<small class="aksrt-native-field__hint">' . esc_html__('单次可生成 1–1000 个。', 'aksrt') . '</small></label>';
echo '<label class="aksrt-native-field"><span class="aksrt-native-field__label">' . esc_html__('标识', 'aksrt') . '</span>'
. '<input type="text" name="auto_remarks" value="' . esc_attr('invita_' . current_time('YmdHis')) . '" maxlength="191">'
. '<small class="aksrt-native-field__hint">' . esc_html__('为本批邀请码命名,方便查找与管理。', 'aksrt') . '</small></label></div>';
$this->render_invite_reward_fields();
echo '<label class="aksrt-inv-advanced"><input type="checkbox" name="auto_top_s" value="1">' . esc_html__('自定义邀请码位数', 'aksrt') . '</label>';
echo '<fieldset class="aksrt-inv-pane aksrt-inv-length" data-aksrt-inv-dep="auto_top_s=1" hidden disabled><legend class="screen-reader-text">' . esc_html__('自定义位数', 'aksrt') . '</legend>';
echo '<label class="aksrt-native-field"><span class="aksrt-native-field__label">' . esc_html__('邀请码位数', 'aksrt') . '</span>'
. '<input type="number" name="auto_rand_password_limit" value="10" min="4" max="50" step="1" required>'
. '<small class="aksrt-native-field__hint">' . esc_html__('4–50 位;默认 10 位。位数过短容易重复。', 'aksrt') . '</small></label></fieldset></fieldset>';
echo '<fieldset class="aksrt-inv-pane" data-aksrt-inv-dep="add_type=import" hidden disabled><legend class="screen-reader-text">' . esc_html__('导入设置', 'aksrt') . '</legend>';
echo '<label class="aksrt-native-field"><span class="aksrt-native-field__label">' . esc_html__('邀请码数据', 'aksrt') . '</span>'
. '<textarea name="import_data" rows="7" class="code" required data-limit-error="' . esc_attr__('单次最多导入 1000 行,请拆分后重试。', 'aksrt') . '" placeholder="93FW0XAY 无 signup&#10;P86NPWKI points=10 signup" aria-describedby="aksrt-inv-import-hint"></textarea>'
. '<small class="aksrt-native-field__hint" id="aksrt-inv-import-hint">' . esc_html__('每行一个,格式为“邀请码 奖励 标识”,无奖励填“无”。单次最多 1000 条,空行会忽略,重复的邀请码不会导入。', 'aksrt') . '</small></label>';
echo '<label class="aksrt-native-field aksrt-inv-separator"><span class="aksrt-native-field__label">' . esc_html__('分隔符', 'aksrt') . '</span>'
. '<input type="text" name="import_division" value=" " maxlength="4">'
. '<small class="aksrt-native-field__hint">' . esc_html__('默认为空格,也可填写其他分隔符,例如 |。', 'aksrt') . '</small></label>';
echo '<details class="aksrt-inv-help"><summary>' . esc_html__('查看奖励格式与示例', 'aksrt') . '</summary><div>'
. '<p>' . esc_html__('奖励参数用 & 连接:level_integral 为经验值,points 为积分,balance 为余额(元),vip 为会员等级(1 或 2),vip_time 为会员天数。', 'aksrt') . '</p>'
. '<pre>93FW0XAY 无 signup&#10;P86NPWKI points=10 signup&#10;AUF9D2B4 balance=30&amp;vip=2&amp;vip_time=5 signup</pre>'
. '<p>' . esc_html__('第三行示例:奖励 30 元余额和 5 天二级会员。标识可省略;导出的文本文件也可按相同分隔符重新导入。', 'aksrt') . '</p></div></details></fieldset>';
$this->invite_dialog_close(__('生成邀请码', 'aksrt'), ' data-aksrt-inv-add-submit data-auto-label="' . esc_attr__('生成邀请码', 'aksrt') . '" data-import-label="' . esc_attr__('导入邀请码', 'aksrt') . '" data-busy-label="' . esc_attr__('正在添加…', 'aksrt') . '"');
}
private function render_invite_reward_fields(): void {
echo '<fieldset class="aksrt-inv-rewards"><legend>' . esc_html__('使用奖励', 'aksrt') . '</legend><p class="aksrt-inv-hint">' . esc_html__('用户使用邀请码注册后获得以下奖励;数值为 0 表示不赠送。', 'aksrt') . '</p>';
echo '<div class="aksrt-native-form-grid aksrt-inv-reward-grid">';
foreach (['level_integral' => __('经验值', 'aksrt'), 'points' => __('积分', 'aksrt'), 'balance' => __('余额(元)', 'aksrt')] as $name => $label) {
echo '<label class="aksrt-native-field"><span class="aksrt-native-field__label">' . esc_html($label) . '</span><input type="number" name="' . esc_attr($name) . '" value="0" min="0" step="' . ($name === 'balance' ? '0.01' : '1') . '"></label>';
}
echo '</div><fieldset class="aksrt-inv-vip"><legend>' . esc_html__('VIP 会员', 'aksrt') . '</legend><div class="aksrt-inv-radios">';
foreach (['0' => __('不赠送', 'aksrt'), '1' => __('一级会员', 'aksrt'), '2' => __('二级会员', 'aksrt')] as $value => $label) {
echo '<label><input type="radio" name="vip" value="' . esc_attr($value) . '"' . checked((string) $value, '0', false) . '>' . esc_html($label) . '</label>';
}
echo '</div></fieldset><fieldset class="aksrt-inv-pane aksrt-inv-vip-time" data-aksrt-inv-dep="vip=1|2" hidden disabled><legend class="screen-reader-text">' . esc_html__('会员时长', 'aksrt') . '</legend>';
echo '<label class="aksrt-native-field"><span class="aksrt-native-field__label">' . esc_html__('会员赠送时长(天)', 'aksrt') . '</span><input type="number" name="vip_time" value="30" min="1" step="1" required></label></fieldset></fieldset>';
}
private function render_invite_codes_export(string $nonce): void {
$status = in_array(($_GET['status'] ?? 'all'), ['all', 'unused', 'used'], true) ? (string) $_GET['status'] : 'all';
$this->invite_dialog_open('aksrt-inv-export', __('导出邀请码', 'aksrt'), __('选择使用状态与文件格式,导出后可在本地保存或分发。', 'aksrt'), 'get', 'aksrt_invite_export', $nonce);
echo '<fieldset class="aksrt-inv-options"><legend>' . esc_html__('使用状态', 'aksrt') . '</legend><div class="aksrt-inv-radios">';
foreach (['all' => __('全部', 'aksrt'), 'unused' => __('未使用', 'aksrt'), 'used' => __('已使用', 'aksrt')] as $value => $label) {
echo '<label><input type="radio" name="status" value="' . esc_attr($value) . '"' . checked($value, $status, false) . '>' . esc_html($label) . '</label>';
}
echo '</div></fieldset><fieldset class="aksrt-inv-options"><legend>' . esc_html__('导出格式', 'aksrt') . '</legend><div class="aksrt-inv-formats">';
foreach (['csv' => [__('Excel 表格(.csv)', 'aksrt'), __('包含奖励、时间、使用状态与标识,适合整理统计。', 'aksrt')], 'text' => [__('文本文档(.txt)', 'aksrt'), __('每行一个邀请码,包含奖励与标识,可再次导入。', 'aksrt')]] as $value => [$label, $hint]) {
echo '<label><input type="radio" name="export_format" value="' . esc_attr($value) . '"' . checked($value, 'csv', false) . '><span><strong>' . esc_html($label) . '</strong><small>' . esc_html($hint) . '</small></span></label>';
}
echo '</div></fieldset><fieldset class="aksrt-inv-pane" data-aksrt-inv-dep="export_format=text" hidden disabled><legend class="screen-reader-text">' . esc_html__('文本设置', 'aksrt') . '</legend>';
echo '<label class="aksrt-native-field aksrt-inv-separator"><span class="aksrt-native-field__label">' . esc_html__('分隔符', 'aksrt') . '</span><input type="text" name="text_division" value=" " maxlength="4">'
. '<small class="aksrt-native-field__hint">' . esc_html__('默认为空格;重新导入时请使用相同分隔符。', 'aksrt') . '</small></label></fieldset>';
echo '<p class="aksrt-native-callout aksrt-inv-export-note">' . esc_html__('每次最多导出最新的 10,000 条记录。导出仅按上方使用状态筛选,不受列表关键词、标识或勾选项影响。', 'aksrt') . '</p>';
$this->invite_dialog_close(__('导出并下载', 'aksrt'));
}
private function invite_dialog_open(string $id, string $title, string $description, string $method, string $action, string $nonce): void {
echo '<dialog class="aksrt-inv-dialog" id="' . esc_attr($id) . '" aria-labelledby="' . esc_attr($id) . '-title" aria-describedby="' . esc_attr($id) . '-description">';
echo '<form method="' . esc_attr($method) . '" action="' . esc_url(admin_url('admin-post.php')) . '" class="aksrt-inv-dialog__frame">';
echo '<input type="hidden" name="action" value="' . esc_attr($action) . '"><input type="hidden" name="aksrt_nonce" value="' . esc_attr($nonce) . '">';
echo '<header class="aksrt-inv-dialog__head"><div><h2 id="' . esc_attr($id) . '-title">' . esc_html($title) . '</h2><p id="' . esc_attr($id) . '-description">' . esc_html($description) . '</p></div>';
echo '<button type="button" class="aksrt-inv-dialog__close" data-aksrt-dialog-close aria-label="' . esc_attr__('关闭弹窗', 'aksrt') . '"><span class="dashicons dashicons-no-alt" aria-hidden="true"></span></button></header><div class="aksrt-inv-dialog__body">';
}
private function invite_dialog_close(string $label, string $attributes = ''): void {
echo '</div><footer class="aksrt-inv-dialog__foot"><button type="button" class="button" data-aksrt-dialog-close>' . esc_html__('取消', 'aksrt') . '</button>';
echo '<button type="submit" class="button button-primary"' . $attributes . '>' . esc_html($label) . '</button></footer></form></dialog>';
}
}
+17 -23
View File
@@ -293,7 +293,7 @@ final class InviteCode {
}
/**
* 文本导入邀请码(对齐 Zibll 格式):一行一码,`邀请码<分隔符>奖励<分隔符>标识`。
* 文本导入邀请码:一行一码,`邀请码<分隔符>奖励<分隔符>标识`。
* 奖励参数串:points=20&balance=30&vip=1&vip_time=5&level_integral=100(balance 单位为元),"无" 或留空表示无奖励。
*
* @return array{imported:int,failed:int,failed_codes:array<string>}|\WP_Error
@@ -305,6 +305,11 @@ final class InviteCode {
return new \WP_Error('aksrt_invite_db', __('数据表不可用', 'aksrt'), ['status' => 500]);
}
$raw = (string) ($args['data'] ?? '');
$lines = preg_split('/\r\n|\r|\n/', $raw) ?: [];
$lines = array_filter(array_map('trim', $lines), static fn($line) => $line !== '');
if (count($lines) > 1000) {
return new \WP_Error('aksrt_invite_import_limit', __('单次最多导入 1000 行,请拆分后重试。', 'aksrt'), ['status' => 400]);
}
$division = trim((string) ($args['division'] ?? ' '));
if ($division === '') {
$division = ' ';
@@ -316,15 +321,10 @@ final class InviteCode {
$created_by = get_current_user_id();
$created_at = current_time('mysql');
$lines = array_slice(preg_split('/\r\n|\r|\n/', $raw) ?: [], 0, 1000);
$imported = 0;
$failed = 0;
$failed_codes = [];
foreach ($lines as $line) {
$line = trim((string) $line);
if ($line === '') {
continue;
}
$parts = $division === ' ' ? preg_split('/\s+/', $line, 3) : explode($division, (string) $line, 3);
$code = strtoupper(preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($parts[0] ?? '')));
if ($code === '') {
@@ -365,7 +365,7 @@ final class InviteCode {
return ['imported' => $imported, 'failed' => $failed, 'failed_codes' => array_slice($failed_codes, 0, 20)];
}
/** Zibll 兼容奖励参数串 → 归一化奖励 JSON("无"/空串 → 全零)。 */
/** 导入/导出的奖励参数串 → 归一化奖励 JSON("无"/空串 → 全零)。 */
private function parse_reward_string(string $reward): string {
$reward = trim($reward);
if ($reward === '' || in_array($reward, ['无', 'none', '0'], true)) {
@@ -435,7 +435,7 @@ final class InviteCode {
/**
* 服务端列表查询:状态筛选 / 关键字搜索 / 标识定位 / 可排序 / 分页。
*
* @return array{items:array<int,array<string,mixed>>,total:int,counts:array{all:int,unused:int,used:int},page:int,per_page:int,max_pages:int,orderby:string,desc:string}
* @return array{items:array<int,array<string,mixed>>,total:int,page:int,per_page:int,max_pages:int,orderby:string,desc:string}
*/
public function list_codes(array $args): array {
global $wpdb;
@@ -449,7 +449,7 @@ final class InviteCode {
$orderby = $orderby_map[sanitize_key((string) ($args['orderby'] ?? ''))] ?? 'updated_at';
$desc = strtoupper((string) ($args['desc'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC';
if ($table === '') {
return ['items' => [], 'total' => 0, 'counts' => ['all' => 0, 'unused' => 0, 'used' => 0], 'page' => $page, 'per_page' => $per_page, 'max_pages' => 1, 'orderby' => $orderby, 'desc' => $desc];
return ['items' => [], 'total' => 0, 'page' => $page, 'per_page' => $per_page, 'max_pages' => 1, 'orderby' => $orderby, 'desc' => $desc];
}
[$status_sql, $status_params] = $this->status_where($status);
@@ -466,9 +466,8 @@ final class InviteCode {
}
$where_sql = implode(' AND ', $where);
$count_row = $params
? $wpdb->get_row($wpdb->prepare("SELECT COUNT(*) AS all_count, COALESCE(SUM(used_count = 0), 0) AS unused_count, COALESCE(SUM(used_count > 0), 0) AS used_count FROM {$table} WHERE {$where_sql}", $params), ARRAY_A)
: $wpdb->get_row("SELECT COUNT(*) AS all_count, COALESCE(SUM(used_count = 0), 0) AS unused_count, COALESCE(SUM(used_count > 0), 0) AS used_count FROM {$table}", ARRAY_A);
$count_sql = "SELECT COUNT(*) AS all_count FROM {$table} WHERE {$where_sql}";
$count_row = $wpdb->get_row($params ? $wpdb->prepare($count_sql, $params) : $count_sql, ARRAY_A);
$total = (int) ($count_row['all_count'] ?? 0);
$rows = $wpdb->get_results($wpdb->prepare(
@@ -501,11 +500,6 @@ final class InviteCode {
return [
'items' => $items,
'total' => $total,
'counts' => [
'all' => (int) ($count_row['all_count'] ?? 0),
'unused' => (int) ($count_row['unused_count'] ?? 0),
'used' => (int) ($count_row['used_count'] ?? 0),
],
'page' => $page,
'per_page' => $per_page,
'max_pages' => max(1, (int) ceil($total / $per_page)),
@@ -591,12 +585,12 @@ final class InviteCode {
}
/** 管理操作入口守卫:权限 + nonce,通过后回跳带结果的列表页。 */
private function guard_and_redirect(string $tab = ''): string {
private function guard_and_redirect(): string {
if (!current_user_can('edit_users')) {
wp_die(esc_html__('您没有权限执行此操作。', 'aksrt'), '', ['response' => 403]);
}
check_admin_referer('aksrt_invite_admin', 'aksrt_nonce');
return admin_url('users.php?page=aksrt_invite_codes' . ($tab !== '' ? '&tab=' . $tab : ''));
return admin_url('users.php?page=aksrt_invite_codes');
}
private function redirect_with_notice(string $url, string $title, string $message, bool $is_error = false): never {
@@ -608,9 +602,9 @@ final class InviteCode {
exit;
}
/** 添加视图提交:系统自动生成 或 文本导入。 */
/** 添加弹窗提交:系统自动生成或文本导入,结果返回列表。 */
public function handle_add(): void {
$back = $this->guard_and_redirect('add');
$back = $this->guard_and_redirect();
$add_type = ($_POST['add_type'] ?? 'auto') === 'import' ? 'import' : 'auto';
if ($add_type === 'import') {
@@ -664,14 +658,14 @@ final class InviteCode {
$this->redirect_with_notice($back, __('删除完成', 'aksrt'), sprintf(__('已删除%d个邀请码', 'aksrt'), $deleted));
}
/** 导出视图提交:文本(可回流导入)或 Excel/CSV 表格。 */
/** 导出弹窗提交:文本(可回流导入)或 Excel/CSV 表格。 */
public function handle_export(): void {
if (!current_user_can('edit_users')) {
wp_die(esc_html__('您没有权限执行此操作。', 'aksrt'), '', ['response' => 403]);
}
check_admin_referer('aksrt_invite_admin', 'aksrt_nonce');
$status = in_array(($_GET['status'] ?? 'all'), ['all', 'unused', 'used'], true) ? (string) $_GET['status'] : 'all';
$format = ($_GET['export_format'] ?? 'xls') === 'text' ? 'text' : 'xls';
$format = ($_GET['export_format'] ?? 'csv') === 'text' ? 'text' : 'csv';
$division = trim((string) wp_unslash($_GET['text_division'] ?? ' '));
if ($division === '') {
$division = ' ';
@@ -0,0 +1,198 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const test = require('node:test');
const root = path.resolve(__dirname, '../..');
const read = (...segments) => fs.readFileSync(path.join(root, ...segments), 'utf8');
let rendered;
function harness() {
if (!rendered) {
const result = spawnSync('php', [path.join(__dirname, 'invite-admin-harness.php')], { encoding: 'utf8' });
assert.equal(result.status, 0, `${result.stderr}${result.stdout}`);
rendered = JSON.parse(result.stdout);
}
return rendered;
}
function dialog(html, id) {
const match = html.match(new RegExp(`<dialog\\b[^>]*\\bid="${id}"[^>]*>[\\s\\S]*?<\\/dialog>`));
assert.ok(match, `missing ${id} dialog`);
return match[0];
}
function input(html, name) {
const match = html.match(new RegExp(`<input\\b[^>]*\\bname="${name}"[^>]*>`));
assert.ok(match, `missing input ${name}`);
return match[0];
}
function fieldset(html, dependency) {
const tags = [...html.matchAll(/<fieldset\b[^>]*data-aksrt-inv-dep="([^"]+)"[^>]*>/g)];
const found = tags.find(([, value]) => value === dependency);
assert.ok(found, `missing conditional fieldset ${dependency}`);
return found[0];
}
function attr(tag, name, value) {
assert.match(tag, new RegExp(`\\b${name}="${value.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}"`));
}
function hasBooleanAttr(tag, name) {
assert.match(tag, new RegExp(`(?:\\s|^)${name}(?:\\s|>|$)`));
}
test('page renders add and export dialogs beside, not inside, the list form', () => {
const { html } = harness();
const listMarker = html.indexOf('data-aksrt-inv-list');
assert.ok(listMarker > 0, 'list form is rendered');
const listStart = html.lastIndexOf('<form', listMarker);
const listEnd = html.indexOf('</form>', listMarker);
const addStart = html.indexOf('<dialog', html.indexOf('id="aksrt-inv-add"') - 100);
const addDialog = dialog(html, 'aksrt-inv-add');
const exportDialog = dialog(html, 'aksrt-inv-export');
const addIndex = html.indexOf(addDialog);
const exportIndex = html.indexOf(exportDialog);
assert.ok(listStart >= 0 && listEnd > listStart);
assert.ok(listEnd < addIndex, 'the list form closes before the add dialog');
assert.ok(addIndex < exportIndex, 'add and export dialogs are rendered as adjacent page siblings');
assert.ok(html.indexOf('</dialog>', addIndex) < exportIndex);
assert.ok(addStart < 0 || addStart === addIndex, 'dialog extraction begins at its opening tag');
assert.doesNotMatch(addDialog, /<form[^>]*>[^]*<dialog/);
});
test('all admin forms carry the expected method, action, and escaped nonce', () => {
const { html } = harness();
const list = html.slice(html.lastIndexOf('<form', html.indexOf('data-aksrt-inv-list')), html.indexOf('</form>', html.indexOf('data-aksrt-inv-list')) + 7);
const add = dialog(html, 'aksrt-inv-add');
const exportForm = dialog(html, 'aksrt-inv-export');
assert.match(list, /<form method="post" action="https:\/\/example\.test\/wp-admin\/admin-post\.php"/);
assert.match(list, /name="action" value="aksrt_invite_delete"/);
for (const form of [list, add, exportForm]) {
assert.match(form, /name="aksrt_nonce" value="nonce&quot;&lt;script&gt;&amp;"/);
}
assert.match(add, /<form method="post" action="https:\/\/example\.test\/wp-admin\/admin-post\.php"/);
assert.match(add, /name="action" value="aksrt_invite_add"/);
assert.match(exportForm, /<form method="get" action="https:\/\/example\.test\/wp-admin\/admin-post\.php"/);
assert.match(exportForm, /name="action" value="aksrt_invite_export"/);
});
test('rendered code, notice, user, date, and row values are escaped', () => {
const { html } = harness();
assert.match(html, /X&amp;Y&quot;&lt;img src=x onerror=alert\(1\)&gt;/);
assert.match(html, /&lt;script&gt;date&lt;\/script&gt;/);
assert.match(html, /Admin&quot;&gt;&lt;script&gt;alert\(1\)&lt;\/script&gt;/);
assert.match(html, /owner&quot;&gt;&lt;img src=x onerror=alert\(1\)&gt;/);
assert.match(html, /Completed/);
assert.doesNotMatch(html, /<(?:script|img)\b|javascript:/i);
assert.match(html, /<br>/, 'the reward cell preserves the permitted line break');
});
test('conditional form sections start hidden and disabled where inactive', () => {
const { html } = harness();
const auto = fieldset(html, 'add_type=auto');
const importing = fieldset(html, 'add_type=import');
const codeLength = fieldset(html, 'auto_top_s=1');
const vipTime = fieldset(html, 'vip=1|2');
const textOptions = fieldset(html, 'export_format=text');
assert.doesNotMatch(auto, /\bhidden\b|\bdisabled\b/);
for (const pane of [importing, codeLength, vipTime, textOptions]) {
hasBooleanAttr(pane, 'hidden');
hasBooleanAttr(pane, 'disabled');
}
});
test('numeric fields expose browser constraints and VIP days apply to both levels', () => {
const { html } = harness();
const quantity = input(html, 'auto_num');
attr(quantity, 'min', '1'); attr(quantity, 'max', '1000'); attr(quantity, 'step', '1');
hasBooleanAttr(quantity, 'required');
const length = input(html, 'auto_rand_password_limit');
attr(length, 'min', '4'); attr(length, 'max', '50'); attr(length, 'step', '1');
const balance = input(html, 'balance');
attr(balance, 'min', '0'); attr(balance, 'step', '0\.01');
const points = input(html, 'points');
attr(points, 'min', '0'); attr(points, 'step', '1');
const days = input(html, 'vip_time');
attr(days, 'min', '1'); attr(days, 'step', '1');
hasBooleanAttr(days, 'required');
assert.match(html, /name="vip" value="1"/);
assert.match(html, /name="vip" value="2"/);
const vipPane = fieldset(html, 'vip=1|2');
const vipPaneStart = html.indexOf(vipPane);
assert.match(html.slice(vipPaneStart, html.indexOf('</fieldset>', vipPaneStart)), /name="vip_time"/);
});
test('export UI and service consistently name the spreadsheet format CSV', () => {
const { html } = harness();
const exportForm = dialog(html, 'aksrt-inv-export');
assert.match(exportForm, /name="export_format" value="csv" checked/);
assert.match(exportForm, /Excel 表格(\.csv)/);
assert.match(exportForm, /文本文档(\.txt)/);
const service = read('src', 'Payment', 'InviteCode.php');
assert.match(service, /\$_GET\['export_format'\] \?\? 'csv'\) === 'text' \? 'text' : 'csv'/);
assert.match(service, /\[__\('邀请码', 'aksrt'\), __\('使用奖励', 'aksrt'\), __\('创建时间', 'aksrt'\), __\('更新时间', 'aksrt'\), __\('状态', 'aksrt'\), __\('标识', 'aksrt'\)\]/);
assert.match(service, /aksrt-invite-codes\.csv/);
assert.doesNotMatch(service, /\bxls\b|export_format[^\n]*xls/);
});
test('search form and filter links preserve sanitized keyword and remark context', () => {
const { html } = harness();
const marker = html.indexOf('class="aksrt-native-filters"');
const start = html.lastIndexOf('<form', marker);
const end = html.indexOf('</form>', marker);
const form = html.slice(start, end + 7);
assert.match(form, /<input type="hidden" name="status" value="used">/);
assert.match(form, /<input type="hidden" name="orderby" value="code">/);
assert.match(form, /<input type="hidden" name="desc" value="ASC">/);
assert.match(form, /<input type="search"[^>]*name="s" value="search alert\(2\) &amp; phrase"/);
const statusMatch = html.match(/<a href="([^"]+)"[^>]*>全部<\/a>/);
assert.ok(statusMatch, 'status filter link is rendered');
const statusUrl = new URL(statusMatch[1].replaceAll('&amp;', '&'));
assert.equal(statusUrl.pathname, '/wp-admin/users.php');
assert.equal(statusUrl.searchParams.get('status'), 'all');
assert.equal(statusUrl.searchParams.get('s'), 'search alert(2) & phrase');
assert.equal(statusUrl.searchParams.get('remark'), 'campaign & east');
const sortMatch = html.match(/<a class="orderby-but is-active" href="([^"]+)"/);
assert.ok(sortMatch, 'active sort link is rendered');
const sortUrl = new URL(sortMatch[1].replaceAll('&amp;', '&'));
assert.equal(sortUrl.searchParams.get('s'), 'search alert(2) & phrase');
assert.equal(sortUrl.searchParams.get('remark'), 'campaign & east');
assert.equal(sortUrl.searchParams.get('orderby'), 'code');
});
test('import rejects more than 1000 nonempty rows without truncation', () => {
const { over_limit_is_error, leading_blank_imported, import_insert_count } = harness();
assert.equal(over_limit_is_error, true);
assert.equal(leading_blank_imported, 1000);
assert.equal(import_insert_count, 1000, 'the over-limit request inserts nothing; leading blank rows do not count');
});
test('page uses no list/add/export tab route and service returns to the canonical list', () => {
const { html, return_url } = harness();
const page = read('src', 'Admin', 'Native', 'UsersMenusInviteCode.php');
const forms = read('src', 'Admin', 'Native', 'UsersMenusInviteForms.php');
assert.doesNotMatch(page + forms + html, /page-title-action|[?&](?:tab|view)=(?:list|add|export)|tab=(?:list|add|export)/);
assert.equal(return_url, 'https://example.test/wp-admin/users.php?page=aksrt_invite_codes');
assert.doesNotMatch(return_url, /(?:tab|view|add)=/);
});
test('status-only list totals keep their filter and no longer return unused count buckets', () => {
const { list_result, row_queries } = harness();
assert.equal(Object.hasOwn(list_result, 'counts'), false);
const totalQuery = row_queries.find((sql) => /SELECT COUNT\(\*\) AS all_count/.test(sql));
assert.ok(totalQuery, 'the service issues a single total-count query');
assert.match(totalQuery, /WHERE used_count > 0/);
assert.doesNotMatch(totalQuery, /COALESCE|unused_count|used_count AS/);
assert.doesNotMatch(read('src', 'Payment', 'InviteCode.php'), /'counts'\s*=>/);
});
test('invite CSS and JS are enqueued only for the invite submenu hook', () => {
const { wrong_hook_assets, page_assets } = harness();
assert.deepEqual(wrong_hook_assets, []);
assert.equal(page_assets.length, 2);
assert.deepEqual(page_assets[0].slice(0, 4), [
'style', 'aksrt-admin-invite-codes', 'https://example.test/theme/assets/css/admin/invite-codes.css', ['aksrt-admin-native-ui'],
]);
assert.deepEqual(page_assets[1].slice(0, 4), [
'script', 'aksrt-admin-invite-codes', 'https://example.test/theme/assets/js/admin-invite-codes.js', [],
]);
});
@@ -0,0 +1,183 @@
<?php
/** WordPress-free render harness for the native invite admin page. */
namespace AKSRT\Core {
final class DBMigrator {
public static function table($key) { return 'wp_aksrt_' . $key; }
}
}
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 $code;
public $message;
public function __construct($code, $message) { $this->code = $code; $this->message = $message; }
public function get_error_message() { return $this->message; }
}
function is_wp_error($value) { return $value instanceof WP_Error; }
function wp_json_encode($value, $options = 0) { return json_encode($value, $options); }
function get_current_user_id() { return 7; }
function wp_generate_password($length, $special_chars = true, $extra_special_chars = false) { return str_repeat('A', (int) $length); }
function __($text, $domain = '') { return $text; }
function esc_html($text) { return htmlspecialchars((string) $text, ENT_QUOTES | ENT_SUBSTITUTE, '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('/^\s*javascript:/i', (string) $url) ? '' : esc_attr($url); }
function admin_url($path = '') { return 'https://example.test/wp-admin/' . ltrim((string) $path, '/'); }
function get_edit_user_link($id) { return admin_url('user-edit.php?user_id=' . (int) $id); }
function current_user_can($capability) { return $capability === 'edit_users'; }
function wp_die($message = '') { throw new RuntimeException((string) $message); }
function wp_create_nonce($action) { return 'nonce"<script>&'; }
function check_admin_referer($action, $query_arg = '_wpnonce') { $GLOBALS['checked_nonces'][] = [$action, $query_arg]; }
function sanitize_key($text) { return preg_replace('/[^a-z0-9_\-]/', '', strtolower((string) $text)); }
function sanitize_text_field($text) { return trim(strip_tags((string) $text)); }
function wp_unslash($text) { return $text; }
function current_time($format) { return $format === 'mysql' ? '2026-10-30 12:00:00' : '20261030120000'; }
function number_format_i18n($number) { return number_format((float) $number); }
function checked($actual, $expected, $echo = true) { return (string) $actual === (string) $expected ? ' checked="checked"' : ''; }
function add_query_arg($key, $value = '', $url = '') {
if (is_array($key)) {
$args = $key;
$url = (string) $value;
} else {
$args = [$key => $value];
if ($url === '') {
$url = $_SERVER['REQUEST_URI'] ?? '';
}
}
$parts = parse_url((string) $url) ?: [];
parse_str($parts['query'] ?? '', $query);
foreach ($args as $name => $item) {
$query[$name] = $item;
}
$base = '';
if (isset($parts['scheme'])) $base .= $parts['scheme'] . '://';
if (isset($parts['host'])) $base .= $parts['host'];
if (isset($parts['port'])) $base .= ':' . $parts['port'];
$base .= $parts['path'] ?? '';
return $base . ($query ? '?' . http_build_query($query) : '') . (isset($parts['fragment']) ? '#' . $parts['fragment'] : '');
}
function wp_kses($html, $allowed_html) {
return preg_replace_callback('/<\/?([a-z][a-z0-9]*)\b[^>]*>/i', static function ($match) {
return strtolower($match[1]) === 'br' ? '<br>' : '';
}, (string) $html);
}
function paginate_links($args) { return '<span class="pagination-probe">' . esc_html((string) $args['current']) . '</span>'; }
function wp_enqueue_style(...$args) { $GLOBALS['assets'][] = ['style', ...$args]; }
function wp_enqueue_script(...$args) { $GLOBALS['assets'][] = ['script', ...$args]; }
final class FakeWpdb {
public $users = 'wp_users';
public $prepared = [];
public $row_queries = [];
public $inserts = 0;
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 null; }
public function insert($table, $data, $formats = null) { $this->inserts++; return 1; }
public function get_row($sql, $format = null) {
$this->row_queries[] = $sql;
return ['all_count' => 2, 'unused_count' => 1, 'used_count' => 1];
}
public function get_results($sql, $format = null) {
if (str_contains($sql, 'FROM wp_aksrt_invite_code_usage u')) {
return [['code_id' => 41, 'user_id' => 7, 'used_at' => '2026-10-30 11:00:00', 'display_name' => 'Admin"><script>alert(1)</script>']];
}
if (str_contains($sql, 'SELECT * FROM wp_aksrt_invite_code')) {
return [
[
'id' => 41,
'code' => 'X&Y"<img src=x onerror=alert(1)>',
'batch_id' => 'B1',
'remark' => 'owner"><img src=x onerror=alert(1)>',
'rewards' => '{"points":5,"vip_level":1,"vip_days":7}',
'max_uses' => 3,
'used_count' => 1,
'status' => 'active',
'expires_at' => null,
'created_at' => '<script>date</script>',
'updated_at' => '2026-10-30 11:00:00',
],
[
'id' => 42,
'code' => 'CLEAN-CODE',
'batch_id' => 'B2',
'remark' => '',
'rewards' => '{"vip_level":2,"vip_days":5}',
'max_uses' => 1,
'used_count' => 0,
'status' => 'active',
'expires_at' => null,
'created_at' => '2026-10-29 10:00:00',
'updated_at' => '2026-10-29 10:00:00',
],
];
}
return [];
}
public function esc_like($text) { return addcslashes((string) $text, '_%\\'); }
}
$wpdb = new FakeWpdb();
require $root . '/src/Admin/Native/NativeUi.php';
require $root . '/src/Payment/InviteCode.php';
require $root . '/src/Admin/Native/UsersMenusInviteForms.php';
require $root . '/src/Admin/Native/UsersMenusInviteCode.php';
final class InviteAdminProbe {
use \AKSRT\Admin\Native\UsersMenusInviteCode;
}
$_GET = [
'page' => 'aksrt_invite_codes',
'status' => 'used',
's' => 'search <script>alert(2)</script> & phrase',
'remark' => 'campaign & east',
'orderby' => 'code',
'desc' => 'ASC',
'aksrt_inv_title' => '<script>alert(1)</script>Completed',
'aksrt_inv_msg' => '<img src=x onerror=alert(1)>notice',
];
$probe = new InviteAdminProbe();
ob_start();
$probe->render_invite_codes_page();
$page = ob_get_clean();
$GLOBALS['assets'] = [];
$probe->enqueue_invite_assets('users_page_aksrt_messages');
$wrong_hook_assets = $GLOBALS['assets'];
$probe->enqueue_invite_assets('users_page_aksrt_invite_codes');
$page_assets = $GLOBALS['assets'];
$service = \AKSRT\Payment\InviteCode::get_instance();
$guard = new \ReflectionMethod($service, 'guard_and_redirect');
$list_result = $service->list_codes(['status' => 'used']);
$import_lines = [];
for ($i = 1; $i <= 1000; $i++) {
$import_lines[] = sprintf('CODE%05d 无', $i);
}
$over_limit_result = $service->import_codes(['data' => implode("\n", array_merge($import_lines, ['OVERLIMIT 无']))]);
$blank_plus_limit_result = $service->import_codes(['data' => "\n" . implode("\n", $import_lines)]);
$return_url = $guard->invoke($service);
echo json_encode([
'html' => $page,
'wrong_hook_assets' => $wrong_hook_assets,
'page_assets' => $page_assets,
'return_url' => $return_url,
'list_result' => $list_result,
'row_queries' => $wpdb->row_queries,
'over_limit_is_error' => is_wp_error($over_limit_result),
'leading_blank_imported' => is_wp_error($blank_plus_limit_result) ? null : $blank_plus_limit_result['imported'],
'import_insert_count' => $wpdb->inserts,
'checked_nonces' => $GLOBALS['checked_nonces'],
], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
}
@@ -59,20 +59,25 @@ test('invite-code surfaces: form field, admin page, menus and both schemas', ()
assert.match(template, /name="invite_code"/)
assert.match(template, /get_invite_code_registration_settings/)
// 管理页为服务端渲染三视图(列表/添加/导出),通过 page-title-action 切换。
const page = read('src', 'Admin', 'Native', 'UsersMenusInviteCode.php')
const forms = read('src', 'Admin', 'Native', 'UsersMenusInviteForms.php')
assert.match(page, /render_invite_codes_page/)
assert.match(page, /page-title-action/)
assert.match(page, /use UsersMenusInviteForms;/)
assert.match(page, /render_invite_codes_list\(\$service, \$this_url, \$nonce\)/)
assert.match(page, /render_invite_codes_add\(\$nonce\)/)
assert.match(page, /render_invite_codes_export\(\$nonce\)/)
assert.match(page, /subsubsub/)
assert.match(page, /data-clipboard-text/)
assert.match(page, /orderby-but/)
assert.match(page, /bulk_action/)
assert.match(page, /aksrt_invite_delete/)
assert.match(page, /aksrt_invite_add/)
assert.match(page, /aksrt_invite_export/)
assert.match(page, /paginate_links/)
assert.match(page, /aksrt_invite_delete/)
assert.match(forms, /aksrt_invite_add/)
assert.match(forms, /aksrt_invite_export/)
// 列表内不再有列表/添加/导出三视图路由;状态筛选仍是独立的列表筛选器。
assert.doesNotMatch(page + forms, /page-title-action|[?&](?:tab|view)=(?:add|export)|tab=(?:list|add|export)/)
// 管理页不再依赖 REST 与 wp_rest nonce。
assert.doesNotMatch(page, /wp_rest|\$\{endpoint\}/)
assert.doesNotMatch(page + forms, /wp_rest|\$\{endpoint\}/)
const menus = read('src', 'Admin', 'Native', 'UsersMenus.php')
assert.match(menus, /use UsersMenusInviteCode/)
@@ -121,15 +126,19 @@ test('import accepts Zibll-compatible lines with per-code rewards', () => {
assert.match(service, /level_integral/)
assert.match(service, /vip_time/)
assert.match(service, /in_array\(\$reward, \['无', 'none', '0'\]/)
// 单次导入上限与重复码不计入成功。
assert.match(service, /array_slice\(preg_split\('\/\\r\\n\|\\r\|\\n\/', \$raw\) \?: \[\], 0, 1000\)/)
// 空行先剔除,超过 1000 个非空行时拒绝,而不是静默截断。
assert.match(service, /array_filter\(array_map\('trim', \$lines\), static fn\(\$line\) => \$line !== ''\)/)
assert.match(service, /count\(\$lines\) > 1000/)
assert.doesNotMatch(service, /array_slice\(preg_split\('\/\\r\\n\|\\r\|\\n\/', \$raw\) \?: \[\], 0, 1000\)/)
const adminScript = read('assets', 'js', 'admin-invite-codes.js')
assert.match(adminScript, /split\(\/\\r\\n\|\\r\|\\n\/\)\.filter\(function \(line\) \{ return line\.trim\(\) !== ''; \}\)/)
assert.match(service, /\$exists !== null/)
assert.match(service, /\$failed_codes\[\] = \$code/)
// 导入走添加视图表单提交(add_type=import),textarea + 分隔符。
const page = read('src', 'Admin', 'Native', 'UsersMenusInviteCode.php')
assert.match(page, /name="import_data"/)
assert.match(page, /name="import_division"/)
assert.match(page, /name="add_type"/)
// 导入表单由独立 Forms trait 渲染,并走添加操作的 add_type=import。
const forms = read('src', 'Admin', 'Native', 'UsersMenusInviteForms.php')
assert.match(forms, /name="import_data"/)
assert.match(forms, /name="import_division"/)
assert.match(forms, /name="add_type"/)
})
test('per-code remark persists through generate/import and both exports round-trip', () => {
@@ -151,10 +160,11 @@ test('per-code remark persists through generate/import and both exports round-tr
assert.match(service, /__\('邀请码', 'aksrt'\), __\('使用奖励', 'aksrt'\)/)
assert.match(service, /\$used > 0 \? __\('已使用', 'aksrt'\) : __\('未使用', 'aksrt'\)/)
const forms = read('src', 'Admin', 'Native', 'UsersMenusInviteForms.php')
// 自动生成标识字段默认 invita_YmdHis;列表标识列可点击定位。
assert.match(forms, /name="auto_remarks"/)
assert.match(forms, /invita_' \. current_time\('YmdHis'\)/)
const page = read('src', 'Admin', 'Native', 'UsersMenusInviteCode.php')
// 添加表单标识字段(默认 invita_YmdHis);列表标识列可点击定位。
assert.match(page, /name="auto_remarks"/)
assert.match(page, /invita_' \. current_time\('YmdHis'\)/)
assert.match(page, /add_query_arg\('remark', \$item\['remark'\], \$this_url\)/)
})