From a6776ffe0a22034aa2f374ceea4f240741e80e1d Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 30 Oct 2026 18:53:01 +0800 Subject: [PATCH] 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 --- assets/css/admin/invite-codes.css | 147 ++++-- assets/js/admin-invite-codes.js | 158 +++++++ src/Admin/Native/UsersMenusInviteCode.php | 424 +++++------------- src/Admin/Native/UsersMenusInviteForms.php | 92 ++++ src/Payment/InviteCode.php | 40 +- .../invite-admin-contract.test.cjs | 198 ++++++++ tests/widget-system/invite-admin-harness.php | 183 ++++++++ .../invite-code-contract.test.cjs | 42 +- 8 files changed, 884 insertions(+), 400 deletions(-) create mode 100644 assets/js/admin-invite-codes.js create mode 100644 src/Admin/Native/UsersMenusInviteForms.php create mode 100644 tests/widget-system/invite-admin-contract.test.cjs create mode 100644 tests/widget-system/invite-admin-harness.php diff --git a/assets/css/admin/invite-codes.css b/assets/css/admin/invite-codes.css index 344153b5..c786c935 100644 --- a/assets/css/admin/invite-codes.css +++ b/assets/css/admin/invite-codes.css @@ -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; } +} diff --git a/assets/js/admin-invite-codes.js b/assets/js/admin-invite-codes.js new file mode 100644 index 00000000..811b4ba8 --- /dev/null +++ b/assets/js/admin-invite-codes.js @@ -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(); +})(); diff --git a/src/Admin/Native/UsersMenusInviteCode.php b/src/Admin/Native/UsersMenusInviteCode.php index 953a9f20..07a1a0a5 100644 --- a/src/Admin/Native/UsersMenusInviteCode.php +++ b/src/Admin/Native/UsersMenusInviteCode.php @@ -1,9 +1,6 @@ __('添加邀请码', '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 '

' - . esc_html__('注意:邀请码功能暂未启用。您可以在', 'aksrt') - . ' ' . esc_html__('登录注册设置', 'aksrt') . ' ' - . esc_html__('中启用此功能', 'aksrt') - . '

'; + $settings_url = admin_url('admin.php?page=aksrt-settings#/users/auth'); + echo '

' . esc_html__('邀请码注册尚未启用,已添加的邀请码暂不能用于注册。', 'aksrt') + . ' ' . esc_html__('前往登录注册设置', 'aksrt') . '

'; } - // 操作结果回跳提示:标题 + 说明,成功 / 错误两种形态。 - $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 '

' . esc_html($inv_title) . '

' - . ($inv_msg !== '' ? '

' . esc_html($inv_msg) . '

' : '') . '
'; - } - - 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 '

' . esc_html($title) . '' + . ($message !== '' ? ' ' . esc_html($message) : '') . '

'; } + $this->render_invite_codes_list($service, $this_url, $nonce); + $this->render_invite_codes_add($nonce); + $this->render_invite_codes_export($nonce); + echo '
'; NativeUi::page_close(); - ?> -
- - 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( - '', - 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 ''; - } else { - $links = [ - 'all' => __('全部', 'aksrt'), - 'unused' => __('未使用', 'aksrt'), - 'used' => __('已使用', 'aksrt'), - ]; - echo ''; + if ($remark !== '') { + echo '' . esc_html(sprintf(__('标识:%s', 'aksrt'), $remark)) . ''; + } + echo '' . esc_html__('清除筛选', 'aksrt') . ''; } - echo '
'; - echo ''; - echo '
'; NativeUi::card_close(); - // 可排序列:code / created_at / updated_at / used_count / remark。 + echo '
'; + echo ''; + $bulk = '
' + . '' + . '' + . '
'; + NativeUi::table_card_open(__('邀请码列表', 'aksrt'), $bulk); + echo ''; $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 ''; - echo ''; - echo ''; - - $bulk = '
- - -
'; - - NativeUi::table_card_open(__('邀请码列表', 'aksrt'), $bulk); // phpcs:ignore WordPress.Security.EscapeOutput -- actions pre-escaped. - echo '
'; + echo '
'; - echo ''; - 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 = '' - . ''; + foreach ($columns as $key => $label) { + $active = $key === $data['orderby']; + $sort = $data['desc'] === 'ASC' ? 'ascending' : 'descending'; + echo ''; // phpcs:ignore WordPress.Security.EscapeOutput -- 箭头为静态结构。 + echo ''; } echo ''; - 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 ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - if ($used === 0) { - echo ''; // 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 ''; - } - echo ''; + echo NativeUi::empty_row(7, __('暂无符合条件的邀请码', 'aksrt'), __('调整筛选条件,或点击“添加邀请码”生成、导入新的邀请码。', 'aksrt')); + } + foreach ($data['items'] as $item) { + $used = (int) $item['used_count']; + $max_uses = (int) $item['max_uses']; + echo ''; + echo ''; + echo ''; + echo ''; } echo '
'; + 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 '' + . esc_html($label) . ''; } - echo '' . esc_html($column['name']) . $arrows . '
' . esc_html($item['code']) . '' . wp_kses((string) $item['reward_text'], ['br' => []]) . '' . esc_html($item['created_at']) . '' . esc_html($item['updated_at']) . '' . NativeUi::badge(__('未使用', 'aksrt'), 'info') . '' . 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 ''; - } - echo ''; - if ($item['remark'] !== '') { - echo '' . esc_html($item['remark']) . ''; - } - echo '
' . wp_kses((string) $item['reward_text'], ['br' => []]) . '' . esc_html($item['created_at']) . '' . esc_html($item['updated_at']) . ''; + $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 ''; } + echo ''; + if ($item['remark'] !== '') { + echo '' . esc_html($item['remark']) . ''; + } else { + echo '—'; + } + echo '
'; NativeUi::table_wrap_close(); - - // 分页条:总数 + 页码链接。 - $total = (int) $data['total']; - $max_pages = (int) $data['max_pages']; - echo '
'; - echo '' . esc_html(sprintf(__('共 %s 项', 'aksrt'), number_format_i18n($total))) . ''; - if ($max_pages > 1) { + echo '
' . esc_html(sprintf(__('共 %s 项', 'aksrt'), number_format_i18n((int) $data['total']))) . ''; + 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 '
'; NativeUi::card_close(); echo ''; } - - /** 添加视图:系统自动生成 / 文本导入 二选一。 */ - private function render_invite_codes_add(string $this_url, string $nonce): void { - NativeUi::header( - __('添加邀请码', 'aksrt'), - '', - 'dashicons-tickets-alt' - ); - printf( - '', - esc_url($this_url), - esc_html__('返回列表', 'aksrt') - ); - - NativeUi::card_open(__('添加邀请码', 'aksrt')); - echo '
'; - echo ''; - echo ''; - - echo '

' . esc_html__('在此添加邀请码', 'aksrt') . '

    ' - . '
  • ' . esc_html__('如果您已经准备好了邀请码资料,请选择导入的方式添加', 'aksrt') . '
  • ' - . '
  • ' . esc_html__('您也可以采用系统生成的方式,自动批量添加邀请码', 'aksrt') . '
'; - - // 添加方式(按钮组二选一)。 - echo '
' - . '' - . '' - . '
'; - - // 自动生成区。 - echo '
'; - echo '
' - . '' . esc_html__('个', 'aksrt') . '' - . '' . esc_html__('需要生成多少个邀请码(单次生成数量太多可能会对服务器性能造成影响)', 'aksrt') . '
'; - echo '
' - . '' - . '' . esc_html__('对生成的邀请码做标记标识,方便后期查找管理', 'aksrt') . '
'; - $this->render_invite_reward_fields(); - echo '
' - . '
'; - echo '
'; - echo '
' - . '' . esc_html__('位数', 'aksrt') . '' - . '' . esc_html__('自定义自动生成的长度(不能太短,太短可能会出现重复)', 'aksrt') . '
'; - echo '
'; - - // 导入区。 - echo '
'; - echo '

' . esc_html__('导入邀请码', 'aksrt') . '

    ' - . '
  • ' . esc_html__('一行一个邀请码,单行格式为:', 'aksrt') . '' . esc_html__('邀请码 奖励 标识', 'aksrt') . '
  • ' - . '
  • ' . esc_html__('邀请码、奖励、标识默认使用空格分割,您可以在下方自定义分割符号,与您的数据对应即可', 'aksrt') . '
  • ' - . '
  • ' . esc_html__('奖励格式为:', 'aksrt') . 'level_integral=10&points=20&balance=30&vip=1&vip_time=5,' . esc_html__('无需奖励填', 'aksrt') . ' ' . esc_html__('无', 'aksrt') . '
  • ' - . '
  • ' . esc_html__('单次导入数量太多可能会对服务器性能造成影响', 'aksrt') . '
' - . '

' . esc_html__('数据示例', 'aksrt') . '
' - . '93FW0XAY ' . esc_html__('无', 'aksrt') . ' ' . esc_html__('没有奖励', 'aksrt') . '
' - . 'P86NPWKI level_integral=100 ' . esc_html__('奖励100经验值', 'aksrt') . '
' - . 'P86NPWKI points=10 ' . esc_html__('奖励10积分', 'aksrt') . '
' - . 'auF9D2B4 balance=30&vip=1&vip_time=5 ' . esc_html__('奖励余额30和5天1级会员', 'aksrt') . '

'; - echo '
' - . '
'; - echo '
' - . '' - . '' . esc_html__('邀请码和奖励之间分割符号(默认为空格分割)', 'aksrt') . '
'; - echo '
'; - - echo '
' - . '
'; - echo '
'; - NativeUi::card_close(); - } - /** 使用奖励字段集:经验值 / 积分 / 余额 / VIP 会员与赠送时长。 */ - private function render_invite_reward_fields(): void { - echo '
'; - echo '
' - . '' - . '' - . '' - . '
'; - echo '
'; - echo ''; - echo '
'; - } - - /** 导出视图:状态 / 格式 选择后提交下载。 */ - private function render_invite_codes_export(string $this_url, string $nonce): void { - NativeUi::header( - __('导出邀请码', 'aksrt'), - '', - 'dashicons-download' - ); - printf( - '', - esc_url($this_url), - esc_html__('返回列表', 'aksrt') - ); - - NativeUi::card_open(); - echo '
'; - echo ''; - echo ''; - - echo '
' - . '' - . '' - . '' - . '
'; - echo '
' - . '' - . '' - . '
'; - echo '
'; - echo '
' - . '' - . '' . esc_html__('邀请码和奖励之间分割符号(默认为空格分割)', 'aksrt') . '
'; - echo '
'; - echo '
' - . '
'; - echo '
'; - NativeUi::card_close(); - } } diff --git a/src/Admin/Native/UsersMenusInviteForms.php b/src/Admin/Native/UsersMenusInviteForms.php new file mode 100644 index 00000000..275888c4 --- /dev/null +++ b/src/Admin/Native/UsersMenusInviteForms.php @@ -0,0 +1,92 @@ +invite_dialog_open('aksrt-inv-add', __('添加邀请码', 'aksrt'), __('批量生成新邀请码,或导入已有的邀请码数据。', 'aksrt'), 'post', 'aksrt_invite_add', $nonce); + echo '
' . esc_html__('添加方式', 'aksrt') . ''; + foreach (['auto' => __('系统自动生成', 'aksrt'), 'import' => __('导入邀请码', 'aksrt')] as $value => $label) { + echo ''; + } + echo '
' . esc_html__('自动生成设置', 'aksrt') . ''; + echo '
'; + echo ''; + echo '
'; + $this->render_invite_reward_fields(); + echo ''; + echo '
'; + + echo ''; + $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 '
' . esc_html__('使用奖励', 'aksrt') . '

' . esc_html__('用户使用邀请码注册后获得以下奖励;数值为 0 表示不赠送。', 'aksrt') . '

'; + echo '
'; + foreach (['level_integral' => __('经验值', 'aksrt'), 'points' => __('积分', 'aksrt'), 'balance' => __('余额(元)', 'aksrt')] as $name => $label) { + echo ''; + } + echo '
' . esc_html__('VIP 会员', 'aksrt') . '
'; + foreach (['0' => __('不赠送', 'aksrt'), '1' => __('一级会员', 'aksrt'), '2' => __('二级会员', 'aksrt')] as $value => $label) { + echo ''; + } + echo '
'; + } + + 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 '
' . esc_html__('使用状态', 'aksrt') . '
'; + foreach (['all' => __('全部', 'aksrt'), 'unused' => __('未使用', 'aksrt'), 'used' => __('已使用', 'aksrt')] as $value => $label) { + echo ''; + } + echo '
' . esc_html__('导出格式', 'aksrt') . '
'; + foreach (['csv' => [__('Excel 表格(.csv)', 'aksrt'), __('包含奖励、时间、使用状态与标识,适合整理统计。', 'aksrt')], 'text' => [__('文本文档(.txt)', 'aksrt'), __('每行一个邀请码,包含奖励与标识,可再次导入。', 'aksrt')]] as $value => [$label, $hint]) { + echo ''; + } + echo '
'; + echo '

' . esc_html__('每次最多导出最新的 10,000 条记录。导出仅按上方使用状态筛选,不受列表关键词、标识或勾选项影响。', 'aksrt') . '

'; + $this->invite_dialog_close(__('导出并下载', 'aksrt')); + } + + private function invite_dialog_open(string $id, string $title, string $description, string $method, string $action, string $nonce): void { + echo ''; + echo '
'; + echo ''; + echo '

' . esc_html($title) . '

' . esc_html($description) . '

'; + echo '
'; + } + + private function invite_dialog_close(string $label, string $attributes = ''): void { + echo '
'; + echo '
'; + } +} diff --git a/src/Payment/InviteCode.php b/src/Payment/InviteCode.php index 836a3cc5..b06866ee 100644 --- a/src/Payment/InviteCode.php +++ b/src/Payment/InviteCode.php @@ -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}|\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>,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>,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 = ' '; diff --git a/tests/widget-system/invite-admin-contract.test.cjs b/tests/widget-system/invite-admin-contract.test.cjs new file mode 100644 index 00000000..8e5ef471 --- /dev/null +++ b/tests/widget-system/invite-admin-contract.test.cjs @@ -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(`]*\\bid="${id}"[^>]*>[\\s\\S]*?<\\/dialog>`)); + assert.ok(match, `missing ${id} dialog`); + return match[0]; +} +function input(html, name) { + const match = html.match(new RegExp(`]*\\bname="${name}"[^>]*>`)); + assert.ok(match, `missing input ${name}`); + return match[0]; +} +function fieldset(html, dependency) { + const tags = [...html.matchAll(/]*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('', listMarker); + const addStart = html.indexOf('= 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('', addIndex) < exportIndex); + assert.ok(addStart < 0 || addStart === addIndex, 'dialog extraction begins at its opening tag'); + assert.doesNotMatch(addDialog, /]*>[^]* { + const { html } = harness(); + const list = html.slice(html.lastIndexOf('', html.indexOf('data-aksrt-inv-list')) + 7); + const add = dialog(html, 'aksrt-inv-add'); + const exportForm = dialog(html, 'aksrt-inv-export'); + assert.match(list, /
{ + const { html } = harness(); + assert.match(html, /X&Y"<img src=x onerror=alert\(1\)>/); + assert.match(html, /<script>date<\/script>/); + assert.match(html, /Admin"><script>alert\(1\)<\/script>/); + assert.match(html, /owner"><img src=x onerror=alert\(1\)>/); + assert.match(html, /Completed/); + assert.doesNotMatch(html, /<(?:script|img)\b|javascript:/i); + assert.match(html, /
/, '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('', 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('', marker); + const form = html.slice(start, end + 7); + assert.match(form, //); + assert.match(form, //); + assert.match(form, //); + assert.match(form, /]*name="s" value="search alert\(2\) & phrase"/); + const statusMatch = html.match(/]*>全部<\/a>/); + assert.ok(statusMatch, 'status filter link is rendered'); + const statusUrl = new URL(statusMatch[1].replaceAll('&', '&')); + 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(/ { + 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', [], + ]); +}); diff --git a/tests/widget-system/invite-admin-harness.php b/tests/widget-system/invite-admin-harness.php new file mode 100644 index 00000000..1707b5c6 --- /dev/null +++ b/tests/widget-system/invite-admin-harness.php @@ -0,0 +1,183 @@ +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"']]; + } + if (str_contains($sql, 'SELECT * FROM wp_aksrt_invite_code')) { + return [ + [ + 'id' => 41, + 'code' => 'X&Y"', + 'batch_id' => 'B1', + 'remark' => 'owner">', + 'rewards' => '{"points":5,"vip_level":1,"vip_days":7}', + 'max_uses' => 3, + 'used_count' => 1, + 'status' => 'active', + 'expires_at' => null, + 'created_at' => '', + '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 & phrase', + 'remark' => 'campaign & east', + 'orderby' => 'code', + 'desc' => 'ASC', + 'aksrt_inv_title' => 'Completed', + 'aksrt_inv_msg' => '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); +} diff --git a/tests/widget-system/invite-code-contract.test.cjs b/tests/widget-system/invite-code-contract.test.cjs index 38726db9..833f23de 100644 --- a/tests/widget-system/invite-code-contract.test.cjs +++ b/tests/widget-system/invite-code-contract.test.cjs @@ -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\)/) })