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( - '
%s %s
', - 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\)/) })