feat(invite-code): admin page rebuilt as server-rendered three-view ops UI
对齐参考主题的邀请码运营页 UI/UX(布局与交互模式为自研实现): - 三视图切换:列表 / 添加邀请码 / 导出邀请码(page-title-action 按钮) - 列表:subsubsub 状态筛选(全部/未使用/已使用)、关键字搜索、批量删除、 可排序表头(邀请码/创建时间/更新时间/状态/标识,orderby 箭头)、 点击复制邀请码 + 复制成功浮层、标识点击定位、分页条 - 状态列:未使用(蓝)/已使用(红)+使用者链接,复用使用记录表 - 添加视图:添加方式按钮组(系统自动生成/导入邀请码)、生成数量(默认20/上限1000)、 标识(默认 invita_YmdHis)、使用奖励字段集(经验值/积分/余额/VIP+赠送时长依赖显示)、 高级选项折叠(自定义位数)、导入区格式说明与数据示例 - 导出视图:状态选择 + 文本文档/Excel表格 双格式 + 文本分隔符 - 传输层从 fetch+JSON 改为 admin_post 表单提交 + 回跳提示; 管理端 REST 全部移除(保留公开 verify),死代码清理 - 建表新增 updated_at(消费/生成/导入维护),列表'更新时间'列与默认排序依据 - 契约测试 357→358 项固化新架构
This commit is contained in:
@@ -170,7 +170,7 @@ WordPress 后台左侧 **Admin Bar** → ⚡ **AKSRT 主题设置**
|
||||
### 4. 推广、邀请与佣金(用户中心 → 收益)
|
||||
|
||||
- **推广链接**:用户中心「收益」页展示带 `?ref=` 的推广链接、二维码(可下载 PNG)与推广海报(按「推广返佣」设置中的海报文案生成)。归因模式(首触/末触)在「支付&付费 → 推广返佣 → 推广归因模式」配置。
|
||||
- **一次性邀请码**:后台「用户 → 邀请码管理」批量生成(每码次数/有效期/备注/四类奖励:积分、余额、VIP、等级积分),支持文本导入(一行一码,格式 `邀请码 奖励 标识`,第三段标识存为备注;奖励参数串与 Zibll 兼容:`points=20&balance=30&vip=1&vip_time=5&level_integral=100`,分隔符可自定义)与两种导出(CSV 全字段 / 文本格式,文本导出可直接回流导入)。注册门控模式在「用户&互动 → 登录注册 → 邀请码注册」。消费为原子操作,并发注册不会超发;开启注册验证时奖励延迟到邮箱验证后发放。
|
||||
- **一次性邀请码**:后台「用户 → 邀请码管理」三视图运营页——列表(状态筛选/搜索/批量删除/排序/分页/点击复制)、添加(系统自动生成:数量/标识/四类奖励[经验值、积分、余额、VIP]/高级选项自定义位数;或 Zibll 兼容文本导入:一行一码 `邀请码 奖励 标识`,奖励参数串 `points=20&balance=30&vip=1&vip_time=5&level_integral=100`,分隔符可自定义)、导出(状态筛选 + 文本文档[可回流导入]/Excel表格 双格式)。注册门控模式在「用户&互动 → 登录注册 → 邀请码注册」。消费为原子操作,并发注册不会超发;开启注册验证时奖励延迟到邮箱验证后发放。
|
||||
- **佣金**:订单支付成功后按推广者等级分档比例记录到佣金账本,经「结算观察期」后自动结算入钱包,支持提现与退款联动回滚。「推广码禁用后停止返佣」开关默认关闭。
|
||||
|
||||
### 5. 部署注意事项
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<?php
|
||||
/**
|
||||
* Native invite-code operations.
|
||||
* Native invite-code operations page.
|
||||
*
|
||||
* 视图结构(列表 / 添加 / 导出)对齐参考主题的运营后台交互:
|
||||
* 服务端渲染 + 表单提交 + 结果回跳提示,无 AJAX 依赖。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
@@ -15,279 +18,352 @@ trait UsersMenusInviteCode {
|
||||
if (!current_user_can('edit_users')) {
|
||||
wp_die(esc_html__('您没有权限访问此页面。', 'aksrt'));
|
||||
}
|
||||
$endpoint = rest_url('aksrt/v1/invite-codes');
|
||||
$nonce = wp_create_nonce('wp_rest');
|
||||
|
||||
$service = \AKSRT\Payment\InviteCode::get_instance();
|
||||
$this_url = admin_url('users.php?page=aksrt_invite_codes');
|
||||
$tab = isset($_GET['tab']) ? sanitize_key((string) wp_unslash($_GET['tab'])) : '';
|
||||
if (!in_array($tab, ['add', 'export'], true)) {
|
||||
$tab = '';
|
||||
}
|
||||
$nonce = wp_create_nonce('aksrt_invite_admin');
|
||||
|
||||
// 功能未启用提示(置顶橙色提醒)。
|
||||
$settings = $service->registration_settings();
|
||||
if (empty($settings['enabled'])) {
|
||||
$settings_url = admin_url('admin.php?page=aksrt-settings');
|
||||
echo '<div class="notice notice-warning"><p style="color:#fb590a;">'
|
||||
. esc_html__('注意:邀请码功能暂未启用。您可以在', 'aksrt')
|
||||
. ' <a href="' . esc_url($settings_url) . '">' . esc_html__('登录注册设置', 'aksrt') . '</a> '
|
||||
. esc_html__('中启用此功能', 'aksrt')
|
||||
. '</p></div>';
|
||||
}
|
||||
|
||||
// 操作结果回跳提示:标题 + 说明,成功 / 错误两种形态。
|
||||
$inv_title = isset($_GET['aksrt_inv_title']) ? sanitize_text_field(rawurldecode((string) wp_unslash($_GET['aksrt_inv_title']))) : '';
|
||||
$inv_msg = isset($_GET['aksrt_inv_msg']) ? sanitize_text_field(rawurldecode((string) wp_unslash($_GET['aksrt_inv_msg']))) : '';
|
||||
$inv_type = isset($_GET['aksrt_inv_type']) && $_GET['aksrt_inv_type'] === 'error' ? 'error' : 'success';
|
||||
if ($inv_title !== '') {
|
||||
echo '<div class="notice notice-' . esc_attr($inv_type) . ' is-dismissible"><h3>' . esc_html($inv_title) . '</h3>'
|
||||
. ($inv_msg !== '' ? '<p>' . esc_html($inv_msg) . '</p>' : '') . '</div>';
|
||||
}
|
||||
?>
|
||||
<div class="wrap" id="aksrt-invite-ops">
|
||||
<h1><?php esc_html_e('邀请码管理', 'aksrt'); ?></h1>
|
||||
<p><?php esc_html_e('一次性邀请码用于控制注册并发放注册奖励。每个码可限制使用次数与有效期,奖励在注册(或完成邮箱验证)后自动发放。', 'aksrt'); ?></p>
|
||||
|
||||
<h2><?php esc_html_e('批量生成', 'aksrt'); ?></h2>
|
||||
<div class="tablenav top">
|
||||
<div class="alignleft actions">
|
||||
<label for="aksrt-inv-count"><?php esc_html_e('数量', 'aksrt'); ?></label>
|
||||
<input type="number" id="aksrt-inv-count" min="1" max="500" value="10" class="small-text">
|
||||
<label for="aksrt-inv-max-uses"><?php esc_html_e('每码可用次数', 'aksrt'); ?></label>
|
||||
<input type="number" id="aksrt-inv-max-uses" min="0" max="9999" value="1" class="small-text">
|
||||
<label for="aksrt-inv-exp-days"><?php esc_html_e('有效天数', 'aksrt'); ?></label>
|
||||
<input type="number" id="aksrt-inv-exp-days" min="0" max="3650" value="30" class="small-text">
|
||||
<label for="aksrt-inv-points"><?php esc_html_e('积分奖励', 'aksrt'); ?></label>
|
||||
<input type="number" id="aksrt-inv-points" min="0" value="0" class="small-text">
|
||||
<label for="aksrt-inv-balance"><?php esc_html_e('余额奖励(元)', 'aksrt'); ?></label>
|
||||
<input type="number" id="aksrt-inv-balance" min="0" step="0.01" value="0" class="small-text">
|
||||
<label for="aksrt-inv-vip-level"><?php esc_html_e('赠VIP', 'aksrt'); ?></label>
|
||||
<select id="aksrt-inv-vip-level">
|
||||
<option value="0"><?php esc_html_e('不赠送', 'aksrt'); ?></option>
|
||||
<option value="1"><?php esc_html_e('一级', 'aksrt'); ?></option>
|
||||
<option value="2"><?php esc_html_e('二级', 'aksrt'); ?></option>
|
||||
</select>
|
||||
<label for="aksrt-inv-vip-days"><?php esc_html_e('VIP天数', 'aksrt'); ?></label>
|
||||
<input type="number" id="aksrt-inv-vip-days" min="0" value="0" class="small-text">
|
||||
<label for="aksrt-inv-level-points"><?php esc_html_e('等级积分', 'aksrt'); ?></label>
|
||||
<input type="number" id="aksrt-inv-level-points" min="0" value="0" class="small-text">
|
||||
<label for="aksrt-inv-remark"><?php esc_html_e('备注', 'aksrt'); ?></label>
|
||||
<input type="text" id="aksrt-inv-remark" maxlength="191" class="regular-text">
|
||||
<button type="button" class="button button-primary" id="aksrt-inv-generate"><?php esc_html_e('生成', 'aksrt'); ?></button>
|
||||
<button type="button" class="button" id="aksrt-inv-import-toggle"><?php esc_html_e('导入', 'aksrt'); ?></button>
|
||||
<a class="button" id="aksrt-inv-export" href="<?php echo esc_url(add_query_arg('_wpnonce', $nonce, rest_url('aksrt/v1/invite-codes/export'))); ?>"><?php esc_html_e('导出 CSV', 'aksrt'); ?></a>
|
||||
<a class="button" id="aksrt-inv-export-text" href="<?php echo esc_url(add_query_arg('_wpnonce', $nonce, rest_url('aksrt/v1/invite-codes/export-text'))); ?>"><?php esc_html_e('导出文本', 'aksrt'); ?></a>
|
||||
</div>
|
||||
</div>
|
||||
<div id="aksrt-inv-import-panel" style="display:none;">
|
||||
<p>
|
||||
<?php esc_html_e('一行一个邀请码,单行格式:', 'aksrt'); ?>
|
||||
<code><?php esc_html_e('邀请码 分隔符 奖励 分隔符 标识', 'aksrt'); ?></code>
|
||||
(<?php esc_html_e('第三段标识将作为备注保存', 'aksrt'); ?>)
|
||||
</p>
|
||||
<p>
|
||||
<?php esc_html_e('奖励参数串(与 Zibll 兼容):', 'aksrt'); ?>
|
||||
<code>points=20&balance=30&vip=1&vip_time=5&level_integral=100</code>
|
||||
<?php esc_html_e(',无需奖励填', 'aksrt'); ?> <code><?php esc_html_e('无', 'aksrt'); ?></code><?php esc_html_e('。示例:', 'aksrt'); ?><code>93FW0XAY 无 没有奖励</code>
|
||||
</p>
|
||||
<p>
|
||||
<label for="aksrt-inv-import-division"><?php esc_html_e('分隔符', 'aksrt'); ?></label>
|
||||
<input type="text" id="aksrt-inv-import-division" value=" " maxlength="4" class="small-text">
|
||||
<label for="aksrt-inv-import-max-uses"><?php esc_html_e('每码可用次数', 'aksrt'); ?></label>
|
||||
<input type="number" id="aksrt-inv-import-max-uses" min="0" max="9999" value="1" class="small-text">
|
||||
<label for="aksrt-inv-import-exp-days"><?php esc_html_e('有效天数', 'aksrt'); ?></label>
|
||||
<input type="number" id="aksrt-inv-import-exp-days" min="0" max="3650" value="0" class="small-text">
|
||||
</p>
|
||||
<p><textarea id="aksrt-inv-import-data" rows="8" class="large-text code" style="resize:both;" placeholder="93FW0XAY 无 没有奖励 P86NPWKI points=10 AUF9D2B4 balance=30&vip=1&vip_time=5"></textarea></p>
|
||||
<p><button type="button" class="button button-primary" id="aksrt-inv-import-submit"><?php esc_html_e('开始导入', 'aksrt'); ?></button></p>
|
||||
</div>
|
||||
<div id="aksrt-inv-generated" style="display:none;">
|
||||
<p><strong><?php esc_html_e('本批邀请码(点击复制):', 'aksrt'); ?></strong></p>
|
||||
<p id="aksrt-inv-codes" style="user-select:all;word-break:break-all;"></p>
|
||||
</div>
|
||||
|
||||
<h2><?php esc_html_e('邀请码列表', 'aksrt'); ?></h2>
|
||||
<div class="tablenav top">
|
||||
<div class="alignleft actions">
|
||||
<select id="aksrt-inv-status">
|
||||
<option value=""><?php esc_html_e('全部状态', 'aksrt'); ?></option>
|
||||
<option value="active"><?php esc_html_e('启用', 'aksrt'); ?></option>
|
||||
<option value="disabled"><?php esc_html_e('禁用', 'aksrt'); ?></option>
|
||||
</select>
|
||||
<input type="search" id="aksrt-inv-search" placeholder="<?php esc_attr_e('搜索邀请码或批次', 'aksrt'); ?>">
|
||||
<button type="button" class="button" id="aksrt-inv-filter"><?php esc_html_e('筛选', 'aksrt'); ?></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="aksrt-inv-notice" role="status" aria-live="polite"></div>
|
||||
<table class="wp-list-table widefat fixed striped">
|
||||
<thead><tr>
|
||||
<th><?php esc_html_e('邀请码', 'aksrt'); ?></th>
|
||||
<th><?php esc_html_e('批次', 'aksrt'); ?></th>
|
||||
<th><?php esc_html_e('备注', 'aksrt'); ?></th>
|
||||
<th><?php esc_html_e('奖励', 'aksrt'); ?></th>
|
||||
<th style="text-align:right;"><?php esc_html_e('已用/上限', 'aksrt'); ?></th>
|
||||
<th><?php esc_html_e('使用者', 'aksrt'); ?></th>
|
||||
<th><?php esc_html_e('状态', 'aksrt'); ?></th>
|
||||
<th><?php esc_html_e('过期时间', 'aksrt'); ?></th>
|
||||
<th><?php esc_html_e('创建时间', 'aksrt'); ?></th>
|
||||
<th><?php esc_html_e('操作', 'aksrt'); ?></th>
|
||||
</tr></thead>
|
||||
<tbody id="aksrt-inv-rows"><tr><td colspan="10"><?php esc_html_e('正在加载…', 'aksrt'); ?></td></tr></tbody>
|
||||
</table>
|
||||
<div class="tablenav bottom"><div class="tablenav-pages" id="aksrt-inv-pages"></div></div>
|
||||
<div class="wrap aksrt-inv-page">
|
||||
<style>
|
||||
/* 排序箭头:默认弱化,悬停加强。 */
|
||||
.aksrt-inv-page .orderby-but { position: relative; display: inline-block; }
|
||||
.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-field { display: flex; align-items: flex-start; padding: 14px 0; border-bottom: 1px solid #f0f0f1; }
|
||||
.aksrt-inv-field > label { flex: 0 0 170px; font-weight: 600; padding-top: 4px; }
|
||||
.aksrt-inv-field > .aksrt-inv-control { flex: 1; }
|
||||
.aksrt-inv-field .description { display: block; margin-top: 4px; color: #646970; }
|
||||
.aksrt-inv-submessage { background: #fff8e5; border-left: 4px solid #ffb900; box-shadow: 0 1px 1px rgba(0,0,0,.08); padding: 10px 14px; margin: 12px 0; }
|
||||
.aksrt-inv-submessage li { list-style: disc; margin-left: 18px; }
|
||||
.aksrt-inv-btnset label { display: inline-block; margin-right: -1px; border: 1px solid #2271b1; background: #fff; padding: 4px 14px; cursor: pointer; }
|
||||
.aksrt-inv-btnset label:first-child { border-radius: 4px 0 0 4px; }
|
||||
.aksrt-inv-btnset label:last-child { border-radius: 0 4px 4px 0; }
|
||||
.aksrt-inv-btnset input { display: none; }
|
||||
.aksrt-inv-btnset label.on { background: #2271b1; color: #fff; }
|
||||
.aksrt-inv-switch { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
|
||||
.aksrt-inv-switch input { margin: 0; }
|
||||
.aksrt-inv-mini { width: 80px; }
|
||||
.aksrt-inv-submit { background: linear-gradient(135deg, #2f9bff, #2271b1) !important; border-color: #2271b1 !important; color: #fff !important; padding: 4px 24px !important; border-radius: 4px !important; }
|
||||
.aksrt-inv-code { cursor: pointer; }
|
||||
.aksrt-inv-toast { position: fixed; top: 42px; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,.75); color: #fff; padding: 8px 22px; border-radius: 4px; font-size: 13px; display: none; z-index: 100100; }
|
||||
.aksrt-inv-unit { color: #646970; margin-left: 4px; }
|
||||
</style>
|
||||
<?php if ($tab === '') : ?>
|
||||
<?php $this->render_invite_codes_list($service, $this_url, $nonce); ?>
|
||||
<?php elseif ($tab === 'add') : ?>
|
||||
<?php $this->render_invite_codes_add($this_url, $nonce); ?>
|
||||
<?php else : ?>
|
||||
<?php $this->render_invite_codes_export($this_url, $nonce); ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="aksrt-inv-toast" id="aksrt-inv-toast"><?php esc_html_e('复制成功', 'aksrt'); ?></div>
|
||||
<script>
|
||||
(() => {
|
||||
const init = () => {
|
||||
const root = document.getElementById('aksrt-invite-ops');
|
||||
if (!root || root.dataset.aksrtInviteReady === '1') return;
|
||||
root.dataset.aksrtInviteReady = '1';
|
||||
|
||||
const endpoint = <?php echo wp_json_encode($endpoint); ?>;
|
||||
const nonce = <?php echo wp_json_encode($nonce); ?>;
|
||||
const rows = root.querySelector('#aksrt-inv-rows');
|
||||
const pages = root.querySelector('#aksrt-inv-pages');
|
||||
const notice = root.querySelector('#aksrt-inv-notice');
|
||||
const search = root.querySelector('#aksrt-inv-search');
|
||||
const status = root.querySelector('#aksrt-inv-status');
|
||||
const filter = root.querySelector('#aksrt-inv-filter');
|
||||
const generated = root.querySelector('#aksrt-inv-generated');
|
||||
const generatedCodes = root.querySelector('#aksrt-inv-codes');
|
||||
let currentPage = 1;
|
||||
|
||||
const escapeHtml = (value) => String(value ?? '').replace(/[&<>'"]/g, (char) => ({
|
||||
'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'
|
||||
})[char]);
|
||||
const setNotice = (message, isError = false) => {
|
||||
notice.className = message ? `notice notice-${isError ? 'error' : 'success'} inline` : '';
|
||||
notice.innerHTML = message ? `<p>${escapeHtml(message)}</p>` : '';
|
||||
};
|
||||
const request = async (url, options = {}) => {
|
||||
const response = await fetch(url, {
|
||||
credentials: 'same-origin',
|
||||
...options,
|
||||
headers: { 'X-WP-Nonce': nonce, 'Content-Type': 'application/json', ...(options.headers || {}) },
|
||||
// 依赖切换:添加方式 / 高级选项 / VIP 时长。
|
||||
const sync = () => {
|
||||
document.querySelectorAll('[data-aksrt-inv-dep]').forEach((el) => {
|
||||
const [name, value] = el.dataset.aksrtInvDep.split('=');
|
||||
const input = document.querySelector(`[name="${name}"]:checked`) || document.querySelector(`[name="${name}"]`);
|
||||
const show = input ? String(input.type === 'checkbox' ? (input.checked ? '1' : '') : input.value) === value : false;
|
||||
el.style.display = show ? '' : 'none';
|
||||
});
|
||||
document.querySelectorAll('.aksrt-inv-btnset').forEach((group) => {
|
||||
group.querySelectorAll('label').forEach((label) => label.classList.toggle('on', label.querySelector('input').checked));
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.message || '<?php echo esc_js(__('请求失败', 'aksrt')); ?>');
|
||||
return payload;
|
||||
};
|
||||
const rewardText = (rewards) => {
|
||||
const parts = [];
|
||||
if (Number(rewards.points) > 0) parts.push(`<?php echo esc_js(__('积分', 'aksrt')); ?>+${rewards.points}`);
|
||||
if (Number(rewards.balance_minor) > 0) parts.push(`<?php echo esc_js(__('余额', 'aksrt')); ?>+${(Number(rewards.balance_minor) / 100).toFixed(2)}`);
|
||||
if (Number(rewards.vip_level) > 0 && Number(rewards.vip_days) > 0) parts.push(`VIP${rewards.vip_level}×${rewards.vip_days}${'<?php echo esc_js(__('天', 'aksrt')); ?>'}`);
|
||||
if (Number(rewards.level_points) > 0) parts.push(`<?php echo esc_js(__('等级积分', 'aksrt')); ?>+${rewards.level_points}`);
|
||||
return parts.join(' / ') || '—';
|
||||
};
|
||||
const statusText = (item) => item.expired
|
||||
? '<?php echo esc_js(__('已过期', 'aksrt')); ?>'
|
||||
: ({active: '<?php echo esc_js(__('启用', 'aksrt')); ?>', disabled: '<?php echo esc_js(__('禁用', 'aksrt')); ?>'}[item.status] || item.status);
|
||||
const load = async (page = 1) => {
|
||||
currentPage = page;
|
||||
setNotice('');
|
||||
rows.innerHTML = '<tr><td colspan="10"><?php echo esc_js(__('正在加载…', 'aksrt')); ?></td></tr>';
|
||||
const url = new URL(endpoint);
|
||||
url.searchParams.set('page', String(page));
|
||||
url.searchParams.set('per_page', '20');
|
||||
if (status.value) url.searchParams.set('status', status.value);
|
||||
if (search.value.trim()) url.searchParams.set('search', search.value.trim());
|
||||
document.addEventListener('change', sync);
|
||||
// 点击复制邀请码,悬浮提示结果。
|
||||
const toast = document.getElementById('aksrt-inv-toast');
|
||||
document.addEventListener('click', async (event) => {
|
||||
const target = event.target.closest('[data-clipboard-text]');
|
||||
if (!target) return;
|
||||
try {
|
||||
const data = await request(url.toString());
|
||||
rows.innerHTML = data.items.length ? data.items.map((item) => `
|
||||
<tr>
|
||||
<td><code style="user-select:all;">${escapeHtml(item.code)}</code></td>
|
||||
<td><small>${escapeHtml(item.batch_id || '—')}</small></td>
|
||||
<td>${escapeHtml(item.remark || '—')}</td>
|
||||
<td>${escapeHtml(rewardText(item.rewards))}</td>
|
||||
<td style="text-align:right;">${Number(item.used_count)}/${item.max_uses > 0 ? Number(item.max_uses) : '∞'}</td>
|
||||
<td>${(item.recent_users || []).map((u) => escapeHtml(u.name)).join('、') || '—'}</td>
|
||||
<td>${escapeHtml(statusText(item))}</td>
|
||||
<td>${item.expires_at ? escapeHtml(item.expires_at) : '—'}</td>
|
||||
<td>${escapeHtml(item.created_at)}</td>
|
||||
<td>${item.status === 'active' && !item.expired
|
||||
? `<button class="button" data-inv-action="disable" data-inv-id="${Number(item.id)}"><?php echo esc_js(__('禁用', 'aksrt')); ?></button>`
|
||||
: `<button class="button" data-inv-action="enable" data-inv-id="${Number(item.id)}"><?php echo esc_js(__('启用', 'aksrt')); ?></button>`}</td>
|
||||
</tr>`).join('') : '<tr><td colspan="10"><?php echo esc_js(__('暂无邀请码', 'aksrt')); ?></td></tr>';
|
||||
pages.innerHTML = Array.from({length: Number(data.max_pages || 1)}, (_, index) => {
|
||||
const number = index + 1;
|
||||
return `<button type="button" class="button ${number === Number(data.page) ? 'button-primary' : ''}" data-inv-page="${number}">${number}</button>`;
|
||||
}).join(' ');
|
||||
await navigator.clipboard.writeText(target.dataset.clipboardText);
|
||||
if (toast) { toast.style.display = 'block'; setTimeout(() => { toast.style.display = 'none'; }, 1200); }
|
||||
} catch (error) {
|
||||
rows.innerHTML = '<tr><td colspan="10"><?php echo esc_js(__('邀请码数据加载失败', 'aksrt')); ?></td></tr>';
|
||||
setNotice(error.message, true);
|
||||
}
|
||||
};
|
||||
|
||||
root.querySelector('#aksrt-inv-generate').addEventListener('click', async (event) => {
|
||||
const button = event.currentTarget;
|
||||
button.disabled = true;
|
||||
try {
|
||||
const data = await request(`${endpoint}/generate`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
count: Number(root.querySelector('#aksrt-inv-count').value) || 1,
|
||||
max_uses: Number(root.querySelector('#aksrt-inv-max-uses').value) || 0,
|
||||
expires_days: Number(root.querySelector('#aksrt-inv-exp-days').value) || 0,
|
||||
remark: root.querySelector('#aksrt-inv-remark').value.trim(),
|
||||
rewards: {
|
||||
points: Number(root.querySelector('#aksrt-inv-points').value) || 0,
|
||||
balance: Number(root.querySelector('#aksrt-inv-balance').value) || 0,
|
||||
vip_level: Number(root.querySelector('#aksrt-inv-vip-level').value) || 0,
|
||||
vip_days: Number(root.querySelector('#aksrt-inv-vip-days').value) || 0,
|
||||
level_points: Number(root.querySelector('#aksrt-inv-level-points').value) || 0,
|
||||
},
|
||||
}),
|
||||
});
|
||||
generated.style.display = '';
|
||||
generatedCodes.textContent = data.codes.join(', ');
|
||||
setNotice(`<?php echo esc_js(__('已生成', 'aksrt')); ?> ${data.count} <?php echo esc_js(__('个邀请码', 'aksrt')); ?>`);
|
||||
await load(1);
|
||||
} catch (error) {
|
||||
setNotice(error.message, true);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(target);
|
||||
const selection = getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
});
|
||||
|
||||
root.querySelector('#aksrt-inv-import-toggle').addEventListener('click', (event) => {
|
||||
const panel = root.querySelector('#aksrt-inv-import-panel');
|
||||
panel.style.display = panel.style.display === 'none' ? '' : 'none';
|
||||
event.currentTarget.setAttribute('aria-expanded', panel.style.display === 'none' ? 'false' : 'true');
|
||||
});
|
||||
|
||||
root.querySelector('#aksrt-inv-import-submit').addEventListener('click', async (event) => {
|
||||
const button = event.currentTarget;
|
||||
const data = root.querySelector('#aksrt-inv-import-data').value;
|
||||
if (!data.trim()) { setNotice('<?php echo esc_js(__('请粘贴需要导入的数据', 'aksrt')); ?>', true); return; }
|
||||
button.disabled = true;
|
||||
try {
|
||||
const result = await request(`${endpoint}/import`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
data,
|
||||
division: root.querySelector('#aksrt-inv-import-division').value || ' ',
|
||||
max_uses: Number(root.querySelector('#aksrt-inv-import-max-uses').value) || 0,
|
||||
expires_days: Number(root.querySelector('#aksrt-inv-import-exp-days').value) || 0,
|
||||
}),
|
||||
});
|
||||
const failedDetail = (result.failed_codes || []).length
|
||||
? `,<?php echo esc_js(__('失败:', 'aksrt')); ?>${result.failed_codes.join('、')}`
|
||||
: '';
|
||||
setNotice(`<?php echo esc_js(__('导入完成:成功', 'aksrt')); ?> ${result.imported} <?php echo esc_js(__('个', 'aksrt')); ?>` +
|
||||
(result.failed ? `,<?php echo esc_js(__('失败', 'aksrt')); ?> ${result.failed} <?php echo esc_js(__('个', 'aksrt')); ?>` : '') + failedDetail,
|
||||
result.failed > 0);
|
||||
root.querySelector('#aksrt-inv-import-data').value = '';
|
||||
await load(1);
|
||||
} catch (error) {
|
||||
setNotice(error.message, true);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
filter.addEventListener('click', () => load(1));
|
||||
search.addEventListener('keydown', (event) => { if (event.key === 'Enter') load(1); });
|
||||
root.addEventListener('click', async (event) => {
|
||||
const pageButton = event.target.closest('[data-inv-page]');
|
||||
if (pageButton) return load(Number(pageButton.dataset.invPage));
|
||||
const button = event.target.closest('[data-inv-action]');
|
||||
if (!button) return;
|
||||
button.disabled = true;
|
||||
try {
|
||||
await request(`${endpoint}/${Number(button.dataset.invId)}/${button.dataset.invAction}`, { method: 'POST', body: '{}' });
|
||||
setNotice('<?php echo esc_js(__('邀请码状态已更新', 'aksrt')); ?>');
|
||||
await load(currentPage);
|
||||
} catch (error) {
|
||||
setNotice(error.message, true);
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
load();
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init, { once: true });
|
||||
document.addEventListener('DOMContentLoaded', sync, { once: true });
|
||||
} else {
|
||||
init();
|
||||
sync();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<?php
|
||||
}
|
||||
|
||||
/** 列表视图:状态筛选、搜索、批量删除、排序、分页。 */
|
||||
private function render_invite_codes_list(\AKSRT\Payment\InviteCode $service, string $this_url, string $nonce): void {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- 只读列表查询。
|
||||
$status = in_array(($_GET['status'] ?? 'all'), ['all', 'unused', 'used'], true) ? (string) $_GET['status'] : 'all';
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- 只读列表查询。
|
||||
$s = isset($_GET['s']) ? sanitize_text_field((string) wp_unslash($_GET['s'])) : '';
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- 只读列表查询。
|
||||
$remark = isset($_GET['remark']) ? sanitize_text_field((string) wp_unslash($_GET['remark'])) : '';
|
||||
$data = $service->list_codes([
|
||||
'page' => isset($_GET['paged']) ? max(1, (int) $_GET['paged']) : 1, // phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
'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
|
||||
]);
|
||||
|
||||
echo '<h1 class="wp-heading-inline">' . esc_html__('邀请码管理', 'aksrt') . '</h1>';
|
||||
echo '<a href="' . esc_url(add_query_arg('tab', 'add', $this_url)) . '" class="page-title-action">' . esc_html__('添加邀请码', 'aksrt') . '</a>';
|
||||
echo '<a href="' . esc_url(add_query_arg('tab', 'export', $this_url)) . '" class="page-title-action" style="margin-left:6px;">' . esc_html__('导出邀请码', 'aksrt') . '</a>';
|
||||
echo '<hr class="wp-header-end">';
|
||||
|
||||
// 顶部筛选行:左侧状态链接(搜索时改为提示语),右侧搜索表单。
|
||||
$search_html = '<div style="float:right;">
|
||||
<form method="get"><input type="hidden" name="page" value="aksrt_invite_codes">
|
||||
<input type="text" name="s" value="' . esc_attr($s) . '" placeholder="' . esc_attr__('搜索邀请码', 'aksrt') . '">
|
||||
<button type="submit" class="button">' . esc_html__('提交', 'aksrt') . '</button></form></div>';
|
||||
echo '<div style="margin:12px 0;min-height:32px;">';
|
||||
echo '<ul class="subsubsub" style="float:left;margin:0 0 8px;">';
|
||||
if ($s !== '') {
|
||||
echo '<li style="display:inline-block;background:#fff8e5;border:1px solid #ffb900;padding:2px 10px;">'
|
||||
. esc_html(sprintf(__('搜索 “%s” 的内容', 'aksrt'), $s)) . '</li>';
|
||||
} else {
|
||||
$links = [
|
||||
'all' => __('全部', 'aksrt'),
|
||||
'unused' => __('未使用', 'aksrt'),
|
||||
'used' => __('已使用', 'aksrt'),
|
||||
];
|
||||
$parts = [];
|
||||
foreach ($links as $key => $label) {
|
||||
$url = $key === 'all' ? $this_url : add_query_arg('status', $key, $this_url);
|
||||
$parts[] = '<li><a href="' . esc_url($url) . '"' . ($key === $status ? ' style="font-weight:600;"' : '') . '>' . esc_html($label) . '</a></li>';
|
||||
}
|
||||
echo implode(' | ', $parts); // phpcs:ignore WordPress.Security.EscapeOutput -- 各项均已转义。
|
||||
}
|
||||
echo '</ul>' . $search_html . '</div><div class="clear"></div>'; // phpcs:ignore WordPress.Security.EscapeOutput -- 静态结构。
|
||||
|
||||
// 可排序列:code / created_at / updated_at / used_count / remark。
|
||||
$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')],
|
||||
];
|
||||
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_invite_delete">';
|
||||
echo '<input type="hidden" name="aksrt_nonce" value="' . esc_attr($nonce) . '">';
|
||||
echo '<div class="bulkactions" style="margin:10px 0;">
|
||||
<select name="bulk_action" id="bulk-action-selector-top">
|
||||
<option value="-1">' . esc_html__('批量操作', 'aksrt') . '</option>
|
||||
<option value="delete">' . esc_html__('删除', 'aksrt') . '</option>
|
||||
</select>
|
||||
<button type="submit" class="button action" onclick="return this.form.bulk_action.value===\'delete\'||confirm(' . wp_json_encode(__('请先选择“删除”操作', 'aksrt')) . ');">' . esc_html__('应用', 'aksrt') . '</button>
|
||||
</div>';
|
||||
echo '<div style="overflow-y:auto;width:100%;">';
|
||||
echo '<table class="widefat fixed striped posts table table-bordered" style="min-width:1000px;"><thead><tr>';
|
||||
echo '<td id="cb" class="manage-column column-cb check-column" style="width:2%;"><input id="cb-select-all-1" type="checkbox" onclick="document.querySelectorAll(\'tbody [name="action_id[]"]\').forEach(c=>c.checked=this.checked)"></td>';
|
||||
foreach ($columns as $column) {
|
||||
$arrows = '';
|
||||
if ($column['key'] !== '') {
|
||||
$up = add_query_arg(['orderby' => $column['key'], 'desc' => 'ASC'], $this_url);
|
||||
$down = add_query_arg(['orderby' => $column['key'], 'desc' => 'DESC'], $this_url);
|
||||
$arrows = '<span class="orderby-but"><a title="' . esc_attr__('降序', 'aksrt') . '" href="' . esc_url($down) . '"><span class="dashicons dashicons-arrow-up"></span></a>'
|
||||
. '<a title="' . esc_attr__('升序', 'aksrt') . '" href="' . esc_url($up) . '"><span class="dashicons dashicons-arrow-down"></span></a></span>';
|
||||
}
|
||||
echo '<th style="width:' . esc_attr($column['width']) . ';">' . esc_html($column['name']) . $arrows . '</th>'; // phpcs:ignore WordPress.Security.EscapeOutput -- 箭头为静态结构。
|
||||
}
|
||||
echo '</tr></thead><tbody>';
|
||||
|
||||
if ($data['items'] === []) {
|
||||
echo '<tr><td colspan="7" style="color:#ff4a4a;text-align:center;">' . esc_html__('未找到对应内容,或暂无内容', 'aksrt') . '</td></tr>';
|
||||
} else {
|
||||
foreach ($data['items'] as $item) {
|
||||
$used = (int) $item['used_count'];
|
||||
$max_uses = (int) $item['max_uses'];
|
||||
echo '<tr>';
|
||||
echo '<th scope="row" class="check-column"><input type="checkbox" name="action_id[]" value="' . esc_attr((string) $item['id']) . '"></th>';
|
||||
echo '<td><span class="aksrt-inv-code" data-clipboard-text="' . esc_attr($item['code']) . '" title="' . esc_attr__('点击复制', 'aksrt') . '">' . esc_html($item['code']) . '</span></td>';
|
||||
echo '<td><div style="font-size:12px;">' . wp_kses((string) $item['reward_text'], ['br' => []]) . '</div></td>';
|
||||
echo '<td>' . esc_html($item['created_at']) . '</td>';
|
||||
echo '<td>' . esc_html($item['updated_at']) . '</td>';
|
||||
if ($used === 0) {
|
||||
echo '<td><span style="color:#3d7ffd;">' . esc_html__('未使用', 'aksrt') . '</span></td>';
|
||||
} else {
|
||||
$label = $max_uses > 0 && $used < $max_uses ? sprintf(__('已使用 %1$d/%2$d', 'aksrt'), $used, $max_uses) : __('已使用', 'aksrt');
|
||||
echo '<td><span style="color:#f93b3b;">' . esc_html($label) . '</span>';
|
||||
foreach ((array) $item['recent_users'] as $user) {
|
||||
$user_link = get_edit_user_link((int) $user['user_id']);
|
||||
echo '<br><a target="_blank" href="' . esc_url($user_link) . '">' . esc_html($user['name']) . '</a>';
|
||||
}
|
||||
echo '</td>';
|
||||
}
|
||||
echo '<td>';
|
||||
if ($item['remark'] !== '') {
|
||||
echo '<a href="' . esc_url(add_query_arg('remark', $item['remark'], $this_url)) . '">' . esc_html($item['remark']) . '</a>';
|
||||
}
|
||||
echo '</td></tr>';
|
||||
}
|
||||
}
|
||||
echo '</tbody></table></div></form><div class="clear"></div>';
|
||||
|
||||
// 分页条:总数 + 页码链接。
|
||||
$total = (int) $data['total'];
|
||||
$max_pages = (int) $data['max_pages'];
|
||||
echo '<div class="tablenav bottom"><div class="tablenav-pages" style="display:flex;align-items:center;gap:6px;">';
|
||||
echo '<span class="displaying-num">' . esc_html(sprintf(__('共 %s 项', 'aksrt'), number_format_i18n($total))) . '</span>';
|
||||
if ($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 自行转义。
|
||||
}
|
||||
echo '</div></div>';
|
||||
}
|
||||
|
||||
/** 添加视图:系统自动生成 / 文本导入 二选一。 */
|
||||
private function render_invite_codes_add(string $this_url, string $nonce): void {
|
||||
echo '<h1 class="wp-heading-inline">' . esc_html__('添加邀请码', 'aksrt') . '</h1>';
|
||||
echo '<a href="' . esc_url($this_url) . '" class="page-title-action">' . esc_html__('返回列表', 'aksrt') . '</a>';
|
||||
echo '<hr class="wp-header-end">';
|
||||
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="max-width:860px;">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_invite_add">';
|
||||
echo '<input type="hidden" name="aksrt_nonce" value="' . esc_attr($nonce) . '">';
|
||||
|
||||
echo '<div class="aksrt-inv-submessage"><p><b>' . esc_html__('在此添加邀请码', 'aksrt') . '</b></p><ul>'
|
||||
. '<li>' . esc_html__('如果您已经准备好了邀请码资料,请选择导入的方式添加', 'aksrt') . '</li>'
|
||||
. '<li>' . esc_html__('您也可以采用系统生成的方式,自动批量添加邀请码', 'aksrt') . '</li></ul></div>';
|
||||
|
||||
// 添加方式(按钮组二选一)。
|
||||
echo '<div class="aksrt-inv-field"><label>' . esc_html__('添加方式', 'aksrt') . '</label><div class="aksrt-inv-control"><div class="aksrt-inv-btnset">'
|
||||
. '<label><input type="radio" name="add_type" value="auto" checked>' . esc_html__('系统自动生成', 'aksrt') . '</label>'
|
||||
. '<label><input type="radio" name="add_type" value="import">' . esc_html__('导入邀请码', 'aksrt') . '</label>'
|
||||
. '</div></div></div>';
|
||||
|
||||
// 自动生成区。
|
||||
echo '<div data-aksrt-inv-dep="add_type=auto">';
|
||||
echo '<div class="aksrt-inv-field"><label for="aksrt-inv-num">' . esc_html__('生成数量', 'aksrt') . '</label><div class="aksrt-inv-control">'
|
||||
. '<input type="number" id="aksrt-inv-num" name="auto_num" value="20" min="1" max="1000" step="10" class="small-text"><span class="aksrt-inv-unit">' . esc_html__('个', 'aksrt') . '</span>'
|
||||
. '<span class="description">' . esc_html__('需要生成多少个邀请码(单次生成数量太多可能会对服务器性能造成影响)', 'aksrt') . '</span></div></div>';
|
||||
echo '<div class="aksrt-inv-field"><label for="aksrt-inv-remarks">' . esc_html__('标识', 'aksrt') . '</label><div class="aksrt-inv-control">'
|
||||
. '<input type="text" id="aksrt-inv-remarks" name="auto_remarks" value="' . esc_attr('invita_' . current_time('YmdHis')) . '" class="regular-text">'
|
||||
. '<span class="description">' . esc_html__('对生成的邀请码做标记标识,方便后期查找管理', 'aksrt') . '</span></div></div>';
|
||||
$this->render_invite_reward_fields();
|
||||
echo '<div class="aksrt-inv-field"><label for="aksrt-inv-top">' . esc_html__('高级选项', 'aksrt') . '</label><div class="aksrt-inv-control">'
|
||||
. '<label class="aksrt-inv-switch"><input type="checkbox" id="aksrt-inv-top" name="auto_top_s" value="1"><span>' . esc_html__('开启', 'aksrt') . '</span></label></div></div>';
|
||||
echo '<div data-aksrt-inv-dep="auto_top_s=1" style="display:none;">';
|
||||
echo '<div class="aksrt-inv-field"><label for="aksrt-inv-len">' . esc_html__('自定义位数', 'aksrt') . '</label><div class="aksrt-inv-control">'
|
||||
. '<input type="number" id="aksrt-inv-len" name="auto_rand_password_limit" value="8" min="1" max="50" step="2" class="small-text"><span class="aksrt-inv-unit">' . esc_html__('位数', 'aksrt') . '</span>'
|
||||
. '<span class="description">' . esc_html__('自定义自动生成的长度(不能太短,太短可能会出现重复)', 'aksrt') . '</span></div></div>';
|
||||
echo '</div></div>';
|
||||
|
||||
// 导入区。
|
||||
echo '<div data-aksrt-inv-dep="add_type=import" style="display:none;">';
|
||||
echo '<div class="aksrt-inv-submessage"><p><b>' . esc_html__('导入邀请码', 'aksrt') . '</b></p><ul>'
|
||||
. '<li>' . esc_html__('一行一个邀请码,单行格式为:', 'aksrt') . '<code>' . esc_html__('邀请码 奖励 标识', 'aksrt') . '</code></li>'
|
||||
. '<li>' . esc_html__('邀请码、奖励、标识默认使用空格分割,您可以在下方自定义分割符号,与您的数据对应即可', 'aksrt') . '</li>'
|
||||
. '<li>' . esc_html__('奖励格式为:', 'aksrt') . '<code>level_integral=10&points=20&balance=30&vip=1&vip_time=5</code>,' . esc_html__('无需奖励填', 'aksrt') . ' <code>' . esc_html__('无', 'aksrt') . '</code></li>'
|
||||
. '<li>' . esc_html__('单次导入数量太多可能会对服务器性能造成影响', 'aksrt') . '</li></ul>'
|
||||
. '<p><b>' . esc_html__('数据示例', 'aksrt') . '</b><br>'
|
||||
. '<code>93FW0XAY ' . esc_html__('无', 'aksrt') . '</code> ' . esc_html__('没有奖励', 'aksrt') . '<br>'
|
||||
. '<code>P86NPWKI level_integral=100</code> ' . esc_html__('奖励100经验值', 'aksrt') . '<br>'
|
||||
. '<code>P86NPWKI points=10</code> ' . esc_html__('奖励10积分', 'aksrt') . '<br>'
|
||||
. '<code>auF9D2B4 balance=30&vip=1&vip_time=5</code> ' . esc_html__('奖励余额30和5天1级会员', 'aksrt') . '</p></div>';
|
||||
echo '<div class="aksrt-inv-field"><label for="aksrt-import-data">' . esc_html__('邀请码数据', 'aksrt') . '</label><div class="aksrt-inv-control">'
|
||||
. '<textarea id="aksrt-import-data" name="import_data" rows="10" class="large-text code" style="resize:both;"></textarea></div></div>';
|
||||
echo '<div class="aksrt-inv-field"><label for="aksrt-import-division">' . esc_html__('自定义分隔符号', 'aksrt') . '</label><div class="aksrt-inv-control">'
|
||||
. '<input type="text" id="aksrt-import-division" name="import_division" value=" " maxlength="4" class="aksrt-inv-mini">'
|
||||
. '<span class="description">' . esc_html__('邀请码和奖励之间分割符号(默认为空格分割)', 'aksrt') . '</span></div></div>';
|
||||
echo '</div>';
|
||||
|
||||
echo '<div class="aksrt-inv-field"><label></label><div class="aksrt-inv-control">'
|
||||
. '<button type="submit" class="button button-primary aksrt-inv-submit">' . esc_html__('确认提交', 'aksrt') . '</button></div></div>';
|
||||
echo '</form>';
|
||||
}
|
||||
|
||||
/** 使用奖励字段集:经验值 / 积分 / 余额 / VIP 会员与赠送时长。 */
|
||||
private function render_invite_reward_fields(): void {
|
||||
echo '<div class="aksrt-inv-field"><label>' . esc_html__('使用奖励', 'aksrt') . '</label><div class="aksrt-inv-control">';
|
||||
echo '<div style="display:flex;flex-wrap:wrap;gap:12px;">'
|
||||
. '<span><label>' . esc_html__('经验值', 'aksrt') . ' <input type="number" name="level_integral" value="0" min="0" class="small-text"></label></span>'
|
||||
. '<span><label>' . esc_html__('积分', 'aksrt') . ' <input type="number" name="points" value="0" min="0" class="small-text"></label></span>'
|
||||
. '<span><label>' . esc_html__('余额', 'aksrt') . ' <input type="number" name="balance" value="0" min="0" step="0.01" class="small-text"> ' . esc_html__('元', 'aksrt') . '</label></span>'
|
||||
. '</div>';
|
||||
echo '<div style="margin-top:8px;"><label>' . esc_html__('VIP会员', 'aksrt') . ' '
|
||||
. '<label style="font-weight:400;margin:0 8px;"><input type="radio" name="vip" value="" checked>' . esc_html__('无', 'aksrt') . '</label>'
|
||||
. '<label style="font-weight:400;margin:0 8px;"><input type="radio" name="vip" value="1">' . esc_html__('一级', 'aksrt') . '</label>'
|
||||
. '<label style="font-weight:400;margin:0 8px;"><input type="radio" name="vip" value="2">' . esc_html__('二级', 'aksrt') . '</label>'
|
||||
. '</label></div>';
|
||||
echo '<div data-aksrt-inv-dep="vip=1" style="margin-top:8px;display:none;"><label>' . esc_html__('会员赠送时长', 'aksrt')
|
||||
. ' <input type="number" name="vip_time" value="0" min="0" class="small-text"> <span class="aksrt-inv-unit">' . esc_html__('天', 'aksrt') . '</span></label>'
|
||||
. '<span class="description">' . esc_html__('仅在上方选择 VIP 会员等级后生效', 'aksrt') . '</span></div>';
|
||||
echo '</div></div>';
|
||||
}
|
||||
|
||||
/** 导出视图:状态 / 格式 选择后提交下载。 */
|
||||
private function render_invite_codes_export(string $this_url, string $nonce): void {
|
||||
echo '<h1 class="wp-heading-inline">' . esc_html__('导出邀请码', 'aksrt') . '</h1>';
|
||||
echo '<a href="' . esc_url($this_url) . '" class="page-title-action">' . esc_html__('返回列表', 'aksrt') . '</a>';
|
||||
echo '<hr class="wp-header-end">';
|
||||
|
||||
echo '<form method="get" action="' . esc_url(admin_url('admin-post.php')) . '" style="max-width:860px;">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_invite_export">';
|
||||
echo '<input type="hidden" name="aksrt_nonce" value="' . esc_attr($nonce) . '">';
|
||||
|
||||
echo '<div class="aksrt-inv-field"><label>' . esc_html__('选择状态', 'aksrt') . '</label><div class="aksrt-inv-control">'
|
||||
. '<label style="font-weight:400;margin-right:16px;"><input type="radio" name="status" value="all" checked>' . esc_html__('全部', 'aksrt') . '</label>'
|
||||
. '<label style="font-weight:400;margin-right:16px;"><input type="radio" name="status" value="unused">' . esc_html__('未使用', 'aksrt') . '</label>'
|
||||
. '<label style="font-weight:400;margin-right:16px;"><input type="radio" name="status" value="used">' . esc_html__('已使用', 'aksrt') . '</label>'
|
||||
. '</div></div>';
|
||||
echo '<div class="aksrt-inv-field"><label>' . esc_html__('导出格式', 'aksrt') . '</label><div class="aksrt-inv-control">'
|
||||
. '<label style="font-weight:400;margin-right:16px;"><input type="radio" name="export_format" value="text">' . esc_html__('文本文档', 'aksrt') . '</label>'
|
||||
. '<label style="font-weight:400;margin-right:16px;"><input type="radio" name="export_format" value="xls" checked>' . esc_html__('Excel表格', 'aksrt') . '</label>'
|
||||
. '</div></div>';
|
||||
echo '<div data-aksrt-inv-dep="export_format=text" style="display:none;">';
|
||||
echo '<div class="aksrt-inv-field"><label>' . esc_html__('分隔符号', 'aksrt') . '</label><div class="aksrt-inv-control">'
|
||||
. '<input type="text" name="text_division" value=" " maxlength="4" class="aksrt-inv-mini">'
|
||||
. '<span class="description">' . esc_html__('邀请码和奖励之间分割符号(默认为空格分割)', 'aksrt') . '</span></div></div>';
|
||||
echo '</div>';
|
||||
echo '<div class="aksrt-inv-field"><label></label><div class="aksrt-inv-control">'
|
||||
. '<button type="submit" class="button button-primary aksrt-inv-submit">' . esc_html__('确认提交', 'aksrt') . '</button></div></div>';
|
||||
echo '</form>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,6 +389,7 @@ trait DBMigratorPart2 {
|
||||
expires_at DATETIME DEFAULT NULL,
|
||||
created_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uniq_invite_code (code),
|
||||
KEY idx_batch (batch_id),
|
||||
|
||||
+217
-155
@@ -31,6 +31,9 @@ final class InviteCode {
|
||||
add_action('user_register', [$this, 'handle_registration'], 30, 1);
|
||||
add_action('aksrt_email_verified', [$this, 'handle_email_verified'], 10, 1);
|
||||
add_action('rest_api_init', [$this, 'register_routes']);
|
||||
add_action('admin_post_aksrt_invite_add', [$this, 'handle_add']);
|
||||
add_action('admin_post_aksrt_invite_delete', [$this, 'handle_delete']);
|
||||
add_action('admin_post_aksrt_invite_export', [$this, 'handle_export']);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
@@ -133,10 +136,12 @@ final class InviteCode {
|
||||
if ($code === '' || $table === '') {
|
||||
return null;
|
||||
}
|
||||
$now = current_time('mysql');
|
||||
$updated = $wpdb->query($wpdb->prepare(
|
||||
"UPDATE {$table} SET used_count = used_count + 1 WHERE code = %s AND status = 'active' AND (max_uses = 0 OR used_count < max_uses) AND (expires_at IS NULL OR expires_at > %s)",
|
||||
"UPDATE {$table} SET used_count = used_count + 1, updated_at = %s WHERE code = %s AND status = 'active' AND (max_uses = 0 OR used_count < max_uses) AND (expires_at IS NULL OR expires_at > %s)",
|
||||
$now,
|
||||
$code,
|
||||
current_time('mysql')
|
||||
$now
|
||||
));
|
||||
if ($updated !== 1) {
|
||||
return null;
|
||||
@@ -232,9 +237,10 @@ final class InviteCode {
|
||||
if ($table === '') {
|
||||
return new \WP_Error('aksrt_invite_db', __('数据表不可用', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$count = min(500, max(1, (int) ($args['count'] ?? 1)));
|
||||
$count = min(1000, max(1, (int) ($args['count'] ?? 1)));
|
||||
$max_uses = max(0, (int) ($args['max_uses'] ?? 1));
|
||||
$expires_days = max(0, (int) ($args['expires_days'] ?? 0));
|
||||
$code_length = min(50, max(4, (int) ($args['code_length'] ?? self::CODE_LENGTH)));
|
||||
$rewards = $this->normalize_rewards((string) wp_json_encode($args['rewards'] ?? []));
|
||||
if (max($rewards['points'], $rewards['balance_minor'], $rewards['vip_level'] * $rewards['vip_days'], $rewards['level_points']) === 0) {
|
||||
$rewards = ['points' => 0, 'balance_minor' => 0, 'vip_level' => 0, 'vip_days' => 0, 'level_points' => 0];
|
||||
@@ -251,7 +257,7 @@ final class InviteCode {
|
||||
$code = '';
|
||||
for ($attempt = 0; $attempt < 20; $attempt++) {
|
||||
$candidate = '';
|
||||
for ($j = 0; $j < self::CODE_LENGTH; $j++) {
|
||||
for ($j = 0; $j < $code_length; $j++) {
|
||||
$candidate .= self::ALPHABET[random_int(0, strlen(self::ALPHABET) - 1)];
|
||||
}
|
||||
$exists = $wpdb->get_var($wpdb->prepare("SELECT id FROM {$table} WHERE code = %s", $candidate));
|
||||
@@ -273,7 +279,8 @@ final class InviteCode {
|
||||
'expires_at' => $expires_at,
|
||||
'created_by' => $created_by,
|
||||
'created_at' => $created_at,
|
||||
], ['%s', '%s', '%s', '%s', '%d', '%s', '%s', '%d', '%s']);
|
||||
'updated_at' => $created_at,
|
||||
], ['%s', '%s', '%s', '%s', '%d', '%s', '%s', '%d', '%s', '%s']);
|
||||
if ($inserted === 1) {
|
||||
$codes[] = $code;
|
||||
}
|
||||
@@ -342,7 +349,8 @@ final class InviteCode {
|
||||
'expires_at' => $expires_at,
|
||||
'created_by' => $created_by,
|
||||
'created_at' => $created_at,
|
||||
], ['%s', '%s', '%s', '%s', '%d', '%s', '%s', '%d', '%s']);
|
||||
'updated_at' => $created_at,
|
||||
], ['%s', '%s', '%s', '%s', '%d', '%s', '%s', '%d', '%s', '%s']);
|
||||
if ($inserted === 1) {
|
||||
$imported++;
|
||||
} else {
|
||||
@@ -394,45 +402,77 @@ final class InviteCode {
|
||||
return $parts === [] ? '无' : implode('&', $parts);
|
||||
}
|
||||
|
||||
public function set_status(int $id, string $status): bool {
|
||||
global $wpdb;
|
||||
$table = $this->table();
|
||||
if ($id <= 0 || $table === '' || !in_array($status, ['active', 'disabled'], true)) {
|
||||
return false;
|
||||
}
|
||||
return $wpdb->update($table, ['status' => $status], ['id' => $id], ['%s'], ['%d']) === 1;
|
||||
/** 状态过滤片段(列表与导出共用):all=全部,unused=未使用,used=已使用。 */
|
||||
private function status_where(string $status): array {
|
||||
return match ($status) {
|
||||
'unused' => ['used_count = 0', []],
|
||||
'used' => ['used_count > 0', []],
|
||||
default => ['1=1', []],
|
||||
};
|
||||
}
|
||||
|
||||
/** @return array{items:array<int,array<string,mixed>>,total:int,page:int,per_page:int,max_pages:int} */
|
||||
/**
|
||||
* 删除邀请码(连同其使用记录)。
|
||||
*
|
||||
* @param array<int,int> $ids
|
||||
*/
|
||||
public function delete_codes(array $ids): int {
|
||||
global $wpdb;
|
||||
$table = $this->table();
|
||||
$usage_table = \AKSRT\Core\DBMigrator::table('invite_code_usage');
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), static fn($id) => $id > 0)));
|
||||
if ($ids === [] || $table === '') {
|
||||
return 0;
|
||||
}
|
||||
$placeholders = implode(',', array_fill(0, count($ids), '%d'));
|
||||
if ($usage_table !== '') {
|
||||
$wpdb->query($wpdb->prepare("DELETE FROM {$usage_table} WHERE code_id IN ({$placeholders})", $ids));
|
||||
}
|
||||
$wpdb->query($wpdb->prepare("DELETE FROM {$table} WHERE id IN ({$placeholders})", $ids));
|
||||
return (int) $wpdb->rows_affected;
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务端列表查询:状态筛选 / 关键字搜索 / 标识定位 / 可排序 / 分页。
|
||||
*
|
||||
* @return array{items:array<int,array<string,mixed>>,total:int,counts:array{all:int,unused:int,used:int},page:int,per_page:int,max_pages:int,orderby:string,desc:string}
|
||||
*/
|
||||
public function list_codes(array $args): array {
|
||||
global $wpdb;
|
||||
$table = $this->table();
|
||||
$page = max(1, (int) ($args['page'] ?? 1));
|
||||
$per_page = max(1, min(100, (int) ($args['per_page'] ?? 20)));
|
||||
$status = sanitize_key((string) ($args['status'] ?? ''));
|
||||
$search = trim((string) ($args['search'] ?? ''));
|
||||
$per_page = max(1, min(100, (int) ($args['per_page'] ?? 30)));
|
||||
$status = in_array(($args['status'] ?? 'all'), ['all', 'unused', 'used'], true) ? (string) $args['status'] : 'all';
|
||||
$search = trim((string) ($args['s'] ?? ''));
|
||||
$remark = trim((string) ($args['remark'] ?? ''));
|
||||
$orderby_map = ['code' => 'code', 'created_at' => 'created_at', 'updated_at' => 'updated_at', 'used_count' => 'used_count', 'remark' => 'remark'];
|
||||
$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, 'page' => $page, 'per_page' => $per_page, 'max_pages' => 1];
|
||||
return ['items' => [], 'total' => 0, 'counts' => ['all' => 0, 'unused' => 0, 'used' => 0], 'page' => $page, 'per_page' => $per_page, 'max_pages' => 1, 'orderby' => $orderby, 'desc' => $desc];
|
||||
}
|
||||
|
||||
$where = ['1=1'];
|
||||
$params = [];
|
||||
if (in_array($status, ['active', 'disabled'], true)) {
|
||||
$where[] = 'status = %s';
|
||||
$params[] = $status;
|
||||
}
|
||||
[$status_sql, $status_params] = $this->status_where($status);
|
||||
$where = [$status_sql];
|
||||
$params = $status_params;
|
||||
if ($search !== '') {
|
||||
$where[] = '(code LIKE %s OR batch_id LIKE %s)';
|
||||
$like = '%' . $wpdb->esc_like($search) . '%';
|
||||
array_push($params, $like, $like);
|
||||
$where[] = '(code LIKE %s OR remark LIKE %s OR batch_id LIKE %s)';
|
||||
array_push($params, $like, $like, $like);
|
||||
}
|
||||
if ($remark !== '') {
|
||||
$where[] = 'remark = %s';
|
||||
$params[] = $remark;
|
||||
}
|
||||
$where_sql = implode(' AND ', $where);
|
||||
|
||||
$total = (int) ($params
|
||||
? $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE {$where_sql}", $params))
|
||||
: $wpdb->get_var("SELECT COUNT(*) FROM {$table}"));
|
||||
$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);
|
||||
$total = (int) ($count_row['all_count'] ?? 0);
|
||||
|
||||
$rows = $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT * FROM {$table} WHERE {$where_sql} ORDER BY id DESC LIMIT %d OFFSET %d",
|
||||
"SELECT * FROM {$table} WHERE {$where_sql} ORDER BY {$orderby} {$desc}, id DESC LIMIT %d OFFSET %d",
|
||||
array_merge($params, [$per_page, ($page - 1) * $per_page])
|
||||
), ARRAY_A);
|
||||
|
||||
@@ -440,13 +480,13 @@ final class InviteCode {
|
||||
$code_ids = array_map(static fn($row) => (int) $row['id'], (array) $rows);
|
||||
$recent_users = $this->recent_users_map($code_ids);
|
||||
foreach ((array) $rows as $row) {
|
||||
$rewards = $this->normalize_rewards((string) ($row['rewards'] ?? ''));
|
||||
$items[] = [
|
||||
'id' => (int) $row['id'],
|
||||
'code' => (string) $row['code'],
|
||||
'batch_id' => (string) $row['batch_id'],
|
||||
'remark' => (string) ($row['remark'] ?? ''),
|
||||
'rewards' => $rewards,
|
||||
'rewards' => (string) ($row['rewards'] ?? ''),
|
||||
'reward_text' => $this->reward_text((string) ($row['rewards'] ?? '')),
|
||||
'max_uses' => (int) $row['max_uses'],
|
||||
'used_count' => (int) $row['used_count'],
|
||||
'recent_users' => $recent_users[(int) $row['id']] ?? [],
|
||||
@@ -454,18 +494,45 @@ final class InviteCode {
|
||||
'expired' => !empty($row['expires_at']) && strtotime((string) $row['expires_at']) <= time(),
|
||||
'expires_at' => (string) ($row['expires_at'] ?? ''),
|
||||
'created_at' => (string) ($row['created_at'] ?? ''),
|
||||
'updated_at' => (string) ($row['updated_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
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)),
|
||||
'orderby' => $orderby,
|
||||
'desc' => $desc,
|
||||
];
|
||||
}
|
||||
|
||||
/** 奖励 JSON → 多行展示文本(对应导入参数串的可读形态)。 */
|
||||
public function reward_text(string $rewards_json): string {
|
||||
$rewards = $this->normalize_rewards($rewards_json);
|
||||
$lines = [];
|
||||
if ($rewards['level_points'] > 0) {
|
||||
$lines[] = sprintf(__('经验值+%d', 'aksrt'), $rewards['level_points']);
|
||||
}
|
||||
if ($rewards['points'] > 0) {
|
||||
$lines[] = sprintf(__('积分+%d', 'aksrt'), $rewards['points']);
|
||||
}
|
||||
if ($rewards['balance_minor'] > 0) {
|
||||
$lines[] = sprintf(__('余额+%s', 'aksrt'), number_format($rewards['balance_minor'] / 100, 2));
|
||||
}
|
||||
if ($rewards['vip_level'] > 0 && $rewards['vip_days'] > 0) {
|
||||
$lines[] = sprintf(__('VIP%d×%d天', 'aksrt'), $rewards['vip_level'], $rewards['vip_days']);
|
||||
}
|
||||
return $lines === [] ? __('无', 'aksrt') : implode('<br>', $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量取每个码最近的使用者(最多 3 个),用于运营列表展示。
|
||||
*
|
||||
@@ -506,7 +573,7 @@ final class InviteCode {
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* REST
|
||||
* REST(仅公开校验)与管理表单处理器
|
||||
* ----------------------------------------------------------------*/
|
||||
|
||||
public function register_routes(): void {
|
||||
@@ -521,143 +588,138 @@ final class InviteCode {
|
||||
'code' => ['type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_key'],
|
||||
],
|
||||
]);
|
||||
|
||||
$admin_guard = fn() => current_user_can('edit_users')
|
||||
? true
|
||||
: new \WP_Error('aksrt_invite_forbidden', __('无权执行此操作', 'aksrt'), ['status' => 403]);
|
||||
$write_guard = function () use ($admin_guard) {
|
||||
$allowed = $admin_guard();
|
||||
if (is_wp_error($allowed)) {
|
||||
return $allowed;
|
||||
}
|
||||
$nonce = (string) isset($_SERVER['HTTP_X_WP_NONCE']) ? sanitize_text_field((string) wp_unslash($_SERVER['HTTP_X_WP_NONCE'])) : '';
|
||||
return $nonce !== '' && wp_verify_nonce($nonce, 'wp_rest')
|
||||
? true
|
||||
: new \WP_Error('aksrt_rest_nonce', __('安全验证失败,请刷新页面后重试', 'aksrt'), ['status' => 403]);
|
||||
};
|
||||
|
||||
register_rest_route('aksrt/v1', '/invite-codes', [
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => fn(\WP_REST_Request $request) => rest_ensure_response($this->list_codes($request->get_params())),
|
||||
'permission_callback' => $admin_guard,
|
||||
'args' => [
|
||||
'page' => ['type' => 'integer', 'default' => 1, 'minimum' => 1],
|
||||
'per_page' => ['type' => 'integer', 'default' => 20, 'minimum' => 1, 'maximum' => 100],
|
||||
'search' => ['type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'status' => ['type' => 'string', 'default' => '', 'enum' => ['', 'active', 'disabled']],
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route('aksrt/v1', '/invite-codes/generate', [
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => fn(\WP_REST_Request $request) => $this->rest_generate($request),
|
||||
'permission_callback' => $write_guard,
|
||||
]);
|
||||
|
||||
register_rest_route('aksrt/v1', '/invite-codes/import', [
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => function (\WP_REST_Request $request) {
|
||||
$result = $this->import_codes([
|
||||
'data' => (string) $request->get_param('data'),
|
||||
'division' => (string) $request->get_param('division'),
|
||||
'max_uses' => (int) $request->get_param('max_uses'),
|
||||
'expires_days' => (int) $request->get_param('expires_days'),
|
||||
]);
|
||||
return is_wp_error($result) ? $result : new \WP_REST_Response(['success' => true] + $result, 201);
|
||||
},
|
||||
'permission_callback' => $write_guard,
|
||||
'args' => [
|
||||
'data' => ['type' => 'string', 'required' => true],
|
||||
'division' => ['type' => 'string', 'default' => ' ', 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'max_uses' => ['type' => 'integer', 'default' => 1, 'minimum' => 0, 'maximum' => 9999],
|
||||
'expires_days' => ['type' => 'integer', 'default' => 0, 'minimum' => 0, 'maximum' => 3650],
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route('aksrt/v1', '/invite-codes/export', [
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'rest_export'],
|
||||
'permission_callback' => $admin_guard,
|
||||
]);
|
||||
|
||||
register_rest_route('aksrt/v1', '/invite-codes/export-text', [
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'rest_export_text'],
|
||||
'permission_callback' => $admin_guard,
|
||||
'args' => [
|
||||
'division' => ['type' => 'string', 'default' => ' ', 'sanitize_callback' => 'sanitize_text_field'],
|
||||
],
|
||||
]);
|
||||
|
||||
foreach (['disable' => 'disabled', 'enable' => 'active'] as $action => $status) {
|
||||
register_rest_route('aksrt/v1', '/invite-codes/(?P<id>\d+)/' . $action, [
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => function (\WP_REST_Request $request) use ($status) {
|
||||
$updated = $this->set_status((int) $request['id'], $status);
|
||||
return $updated
|
||||
? rest_ensure_response(['success' => true, 'id' => (int) $request['id'], 'status' => $status])
|
||||
: new \WP_Error('aksrt_invite_status', __('邀请码状态更新失败', 'aksrt'), ['status' => 409]);
|
||||
},
|
||||
'permission_callback' => $write_guard,
|
||||
'args' => ['id' => ['type' => 'integer', 'minimum' => 1, 'required' => true]],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function rest_generate(\WP_REST_Request $request) {
|
||||
$rewards = $request->get_param('rewards');
|
||||
/** 管理操作入口守卫:权限 + nonce,通过后回跳带结果的列表页。 */
|
||||
private function guard_and_redirect(string $tab = ''): 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 : ''));
|
||||
}
|
||||
|
||||
private function redirect_with_notice(string $url, string $title, string $message, bool $is_error = false): never {
|
||||
wp_safe_redirect(add_query_arg([
|
||||
'aksrt_inv_title' => rawurlencode($title),
|
||||
'aksrt_inv_msg' => rawurlencode($message),
|
||||
'aksrt_inv_type' => $is_error ? 'error' : 'success',
|
||||
], $url));
|
||||
exit;
|
||||
}
|
||||
|
||||
/** 添加视图提交:系统自动生成 或 文本导入。 */
|
||||
public function handle_add(): void {
|
||||
$back = $this->guard_and_redirect('add');
|
||||
$add_type = ($_POST['add_type'] ?? 'auto') === 'import' ? 'import' : 'auto';
|
||||
|
||||
if ($add_type === 'import') {
|
||||
$data = (string) wp_unslash($_POST['import_data'] ?? '');
|
||||
if (trim($data) === '') {
|
||||
$this->redirect_with_notice($back, __('错误!', 'aksrt'), __('请粘贴您需要导入的数据', 'aksrt'), true);
|
||||
}
|
||||
$division = trim((string) wp_unslash($_POST['import_division'] ?? ' '));
|
||||
$result = $this->import_codes(['data' => $data, 'division' => $division]);
|
||||
if (is_wp_error($result)) {
|
||||
$this->redirect_with_notice($back, __('错误!', 'aksrt'), $result->get_error_message(), true);
|
||||
}
|
||||
$message = sprintf(__('成功导入%d个邀请码', 'aksrt'), $result['imported'])
|
||||
. ($result['failed'] ? sprintf(__(',%d个导入失败', 'aksrt'), $result['failed']) : '');
|
||||
$this->redirect_with_notice($back, __('导入完成', 'aksrt'), $message, $result['failed'] > 0);
|
||||
}
|
||||
|
||||
$num = (int) ($_POST['auto_num'] ?? 0);
|
||||
if ($num < 1) {
|
||||
$this->redirect_with_notice($back, __('错误!', 'aksrt'), __('请输入需要生成的数量', 'aksrt'), true);
|
||||
}
|
||||
$code_length = !empty($_POST['auto_top_s'])
|
||||
? max(4, (int) ($_POST['auto_rand_password_limit'] ?? 8))
|
||||
: self::CODE_LENGTH;
|
||||
$codes = $this->generate_batch([
|
||||
'count' => (int) $request->get_param('count'),
|
||||
'max_uses' => (int) $request->get_param('max_uses'),
|
||||
'expires_days' => (int) $request->get_param('expires_days'),
|
||||
'remark' => (string) $request->get_param('remark'),
|
||||
'rewards' => is_array($rewards) ? $rewards : [],
|
||||
'count' => $num,
|
||||
'code_length' => $code_length,
|
||||
'remark' => sanitize_text_field(wp_unslash((string) ($_POST['auto_remarks'] ?? ''))),
|
||||
'rewards' => [
|
||||
'level_points' => (int) ($_POST['level_integral'] ?? 0),
|
||||
'points' => (int) ($_POST['points'] ?? 0),
|
||||
'balance' => (float) ($_POST['balance'] ?? 0),
|
||||
'vip_level' => (int) ($_POST['vip'] ?? 0),
|
||||
'vip_days' => (int) ($_POST['vip_time'] ?? 0),
|
||||
],
|
||||
]);
|
||||
if (is_wp_error($codes)) {
|
||||
return $codes;
|
||||
$this->redirect_with_notice($back, __('错误!', 'aksrt'), $codes->get_error_message(), true);
|
||||
}
|
||||
return new \WP_REST_Response(['success' => true, 'codes' => $codes, 'count' => count($codes)], 201);
|
||||
$this->redirect_with_notice($back, __('完成!', 'aksrt'), sprintf(__('已自动生成%d个邀请码', 'aksrt'), count($codes)));
|
||||
}
|
||||
|
||||
public function rest_export(): \WP_REST_Response {
|
||||
global $wpdb;
|
||||
$table = $this->table();
|
||||
$rows = $wpdb->get_results("SELECT code, batch_id, remark, rewards, max_uses, used_count, status, expires_at, created_at FROM {$table} ORDER BY id DESC LIMIT 10000", ARRAY_A);
|
||||
$lines = ['code,batch_id,remark,rewards,max_uses,used_count,status,expires_at,created_at'];
|
||||
foreach ((array) $rows as $row) {
|
||||
$row['rewards'] = $this->reward_param_string((string) ($row['rewards'] ?? ''));
|
||||
$lines[] = implode(',', array_map(static fn($value) => '"' . str_replace('"', '""', (string) $value) . '"', array_values($row)));
|
||||
/** 列表批量删除。 */
|
||||
public function handle_delete(): void {
|
||||
$back = $this->guard_and_redirect();
|
||||
$ids = array_map('intval', (array) ($_POST['action_id'] ?? []));
|
||||
if ($ids === []) {
|
||||
$this->redirect_with_notice($back, __('错误!', 'aksrt'), __('未选择需要删除的内容', 'aksrt'), true);
|
||||
}
|
||||
$response = new \WP_REST_Response(implode("\n", $lines), 200);
|
||||
$response->set_headers([
|
||||
'Content-Type' => 'text/csv; charset=utf-8',
|
||||
'Content-Disposition' => 'attachment; filename="aksrt-invite-codes.csv"',
|
||||
]);
|
||||
return $response;
|
||||
$deleted = $this->delete_codes($ids);
|
||||
$this->redirect_with_notice($back, __('删除完成', 'aksrt'), sprintf(__('已删除%d个邀请码', 'aksrt'), $deleted));
|
||||
}
|
||||
|
||||
/** 文本导出:与导入格式互通(`码 分隔符 奖励 标识`),导出即可回流导入。 */
|
||||
public function rest_export_text(\WP_REST_Request $request): \WP_REST_Response {
|
||||
global $wpdb;
|
||||
$table = $this->table();
|
||||
$division = trim((string) ($request['division'] ?? ' '));
|
||||
/** 导出视图提交:文本(可回流导入)或 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';
|
||||
$division = trim((string) wp_unslash($_GET['text_division'] ?? ' '));
|
||||
if ($division === '') {
|
||||
$division = ' ';
|
||||
}
|
||||
$rows = $wpdb->get_results("SELECT code, remark, rewards FROM {$table} ORDER BY id DESC LIMIT 10000", ARRAY_A);
|
||||
$lines = [];
|
||||
foreach ((array) $rows as $row) {
|
||||
$line = $row['code'] . $division . $this->reward_param_string((string) ($row['rewards'] ?? ''));
|
||||
if ((string) ($row['remark'] ?? '') !== '') {
|
||||
$line .= $division . $row['remark'];
|
||||
[$status_sql, $status_params] = $this->status_where($status);
|
||||
|
||||
global $wpdb;
|
||||
$table = $this->table();
|
||||
$rows = $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT code, remark, rewards, max_uses, used_count, created_at, updated_at FROM {$table} WHERE {$status_sql} ORDER BY id DESC LIMIT 10000",
|
||||
$status_params
|
||||
), ARRAY_A);
|
||||
|
||||
if ($format === 'text') {
|
||||
$lines = [];
|
||||
foreach ((array) $rows as $row) {
|
||||
$line = $row['code'] . $division . $this->reward_param_string((string) ($row['rewards'] ?? ''));
|
||||
if ((string) ($row['remark'] ?? '') !== '') {
|
||||
$line .= $division . $row['remark'];
|
||||
}
|
||||
$lines[] = $line;
|
||||
}
|
||||
$lines[] = $line;
|
||||
$this->stream_download('aksrt-invite-codes.txt', 'text/plain; charset=utf-8', implode("\n", $lines));
|
||||
}
|
||||
$response = new \WP_REST_Response(implode("\n", $lines), 200);
|
||||
$response->set_headers([
|
||||
'Content-Type' => 'text/plain; charset=utf-8',
|
||||
'Content-Disposition' => 'attachment; filename="aksrt-invite-codes.txt"',
|
||||
]);
|
||||
return $response;
|
||||
|
||||
$lines = [implode(',', array_map(static fn($h) => '"' . $h . '"', [__('邀请码', 'aksrt'), __('使用奖励', 'aksrt'), __('创建时间', 'aksrt'), __('更新时间', 'aksrt'), __('状态', 'aksrt'), __('标识', 'aksrt')]))];
|
||||
foreach ((array) $rows as $row) {
|
||||
$used = (int) $row['used_count'];
|
||||
$cells = [
|
||||
(string) $row['code'],
|
||||
$this->reward_param_string((string) ($row['rewards'] ?? '')),
|
||||
(string) $row['created_at'],
|
||||
(string) $row['updated_at'],
|
||||
$used > 0 ? __('已使用', 'aksrt') : __('未使用', 'aksrt'),
|
||||
(string) ($row['remark'] ?? ''),
|
||||
];
|
||||
$lines[] = implode(',', array_map(static fn($value) => '"' . str_replace('"', '""', $value) . '"', $cells));
|
||||
}
|
||||
// UTF-8 BOM:保证 Excel 直接打开不乱码。
|
||||
$this->stream_download('aksrt-invite-codes.csv', 'text/csv; charset=utf-8', "\xEF\xBB\xBF" . implode("\n", $lines));
|
||||
}
|
||||
|
||||
private function stream_download(string $filename, string $content_type, string $body): never {
|
||||
nocache_headers();
|
||||
header('Content-Type: ' . $content_type);
|
||||
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||
header('Content-Length: ' . strlen($body));
|
||||
echo $body;
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,9 @@ test('invite code table has a unique code key and a usage log table', () => {
|
||||
|
||||
test('consumption is a single conditional UPDATE, not select-then-update', () => {
|
||||
const service = read('src', 'Payment', 'InviteCode.php')
|
||||
// TOCTOU 防线:消费必须是一条带条件的 UPDATE,且以 affected_rows === 1 判定。
|
||||
assert.match(service, /SET used_count = used_count \+ 1 WHERE code = %s AND status = 'active' AND \(max_uses = 0 OR used_count < max_uses\)/)
|
||||
// TOCTOU 防线:消费必须是一条带条件的 UPDATE,且以 affected_rows === 1 判定;
|
||||
// 同语句维护 updated_at(列表"更新时间"列与排序依据)。
|
||||
assert.match(service, /SET used_count = used_count \+ 1, updated_at = %s WHERE code = %s AND status = 'active' AND \(max_uses = 0 OR used_count < max_uses\)/)
|
||||
assert.match(service, /\$updated !== 1/)
|
||||
// 消费成功后写入使用记录(使用者视图)。
|
||||
assert.match(service, /invite_code_usage/)
|
||||
@@ -58,10 +59,20 @@ 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')
|
||||
assert.match(page, /render_invite_codes_page/)
|
||||
assert.match(page, /\$\{endpoint\}\/generate/)
|
||||
assert.match(page, /_wpnonce/)
|
||||
assert.match(page, /page-title-action/)
|
||||
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/)
|
||||
// 管理页不再依赖 REST 与 wp_rest nonce。
|
||||
assert.doesNotMatch(page, /wp_rest|\$\{endpoint\}/)
|
||||
|
||||
const menus = read('src', 'Admin', 'Native', 'UsersMenus.php')
|
||||
assert.match(menus, /use UsersMenusInviteCode/)
|
||||
@@ -79,6 +90,30 @@ test('invite-code surfaces: form field, admin page, menus and both schemas', ()
|
||||
}
|
||||
})
|
||||
|
||||
test('admin operations run through admin_post handlers with capability and nonce guards', () => {
|
||||
const service = read('src', 'Payment', 'InviteCode.php')
|
||||
assert.match(service, /admin_post_aksrt_invite_add/)
|
||||
assert.match(service, /admin_post_aksrt_invite_delete/)
|
||||
assert.match(service, /admin_post_aksrt_invite_export/)
|
||||
assert.match(service, /check_admin_referer\('aksrt_invite_admin', 'aksrt_nonce'\)/)
|
||||
assert.match(service, /current_user_can\('edit_users'\)/)
|
||||
// 管理端 REST 已移除:除公开 verify 外不再注册邀请码路由(导出文件名除外)。
|
||||
assert.doesNotMatch(service, /register_rest_route\('aksrt\/v1', '\/invite-codes/)
|
||||
assert.match(service, /register_rest_route\('aksrt\/v1', '\/invite-code\/verify'/)
|
||||
// 列表/导出共用状态过滤;批量删除连使用记录一起清。
|
||||
assert.match(service, /private function status_where\(string \$status\): array/)
|
||||
assert.match(service, /'unused' => \['used_count = 0', \[\]\]/)
|
||||
assert.match(service, /public function delete_codes\(array \$ids\): int/)
|
||||
assert.match(service, /DELETE FROM \{\$usage_table\} WHERE code_id IN/)
|
||||
// 列表支持状态/搜索/标识定位/排序/分页,默认按更新时间倒序。
|
||||
assert.match(service, /\$orderby_map = \['code' => 'code', 'created_at' => 'created_at', 'updated_at' => 'updated_at', 'used_count' => 'used_count', 'remark' => 'remark'\]/)
|
||||
assert.match(service, /\?\? 'updated_at'/)
|
||||
assert.match(service, /'remark' => 'remark'/)
|
||||
// 生成支持自定义位数(高级选项),单次上限 1000。
|
||||
assert.match(service, /min\(1000, max\(1, \(int\) \(\$args\['count'\] \?\? 1\)\)\)/)
|
||||
assert.match(service, /min\(50, max\(4, \(int\) \(\$args\['code_length'\] \?\? self::CODE_LENGTH\)\)\)/)
|
||||
})
|
||||
|
||||
test('import accepts Zibll-compatible lines with per-code rewards', () => {
|
||||
const service = read('src', 'Payment', 'InviteCode.php')
|
||||
assert.match(service, /public function import_codes\(array \$args\)/)
|
||||
@@ -90,17 +125,17 @@ test('import accepts Zibll-compatible lines with per-code rewards', () => {
|
||||
assert.match(service, /array_slice\(preg_split\('\/\\r\\n\|\\r\|\\n\/', \$raw\) \?: \[\], 0, 1000\)/)
|
||||
assert.match(service, /\$exists !== null/)
|
||||
assert.match(service, /\$failed_codes\[\] = \$code/)
|
||||
// REST 写入口 + 后台导入面板。
|
||||
assert.match(service, /invite-codes\/import/)
|
||||
// 导入走添加视图表单提交(add_type=import),textarea + 分隔符。
|
||||
const page = read('src', 'Admin', 'Native', 'UsersMenusInviteCode.php')
|
||||
assert.match(page, /aksrt-inv-import-data/)
|
||||
assert.match(page, /\$\{endpoint\}\/import/)
|
||||
assert.match(page, /aksrt-inv-import-division/)
|
||||
assert.match(page, /name="import_data"/)
|
||||
assert.match(page, /name="import_division"/)
|
||||
assert.match(page, /name="add_type"/)
|
||||
})
|
||||
|
||||
test('per-code remark persists through generate/import and both exports round-trip', () => {
|
||||
const schema = read('src', 'Core', 'DBMigratorPart2.php')
|
||||
assert.match(schema, /remark\s+VARCHAR\(191\) NOT NULL DEFAULT ''/)
|
||||
assert.match(schema, /updated_at\s+DATETIME NOT NULL DEFAULT/)
|
||||
|
||||
const service = read('src', 'Payment', 'InviteCode.php')
|
||||
// 生成与导入都落 remark;导入第三段"标识"→备注。
|
||||
@@ -111,16 +146,16 @@ test('per-code remark persists through generate/import and both exports round-tr
|
||||
assert.match(service, /private function reward_param_string\(string \$rewards_json\): string/)
|
||||
assert.match(service, /'balance=' \. /)
|
||||
assert.match(service, /\$parts === \[\] \? '无'/)
|
||||
// CSV 含 remark+奖励列;文本导出与导入格式互通。
|
||||
assert.match(service, /code,batch_id,remark,rewards,max_uses/)
|
||||
assert.match(service, /invite-codes\/export-text/)
|
||||
// 文本导出与导入格式互通;CSV(Excel)含中文表头与状态列。
|
||||
assert.match(service, /\$row\['code'\] \. \$division \. \$this->reward_param_string/)
|
||||
assert.match(service, /__\('邀请码', 'aksrt'\), __\('使用奖励', 'aksrt'\)/)
|
||||
assert.match(service, /\$used > 0 \? __\('已使用', 'aksrt'\) : __\('未使用', 'aksrt'\)/)
|
||||
|
||||
const page = read('src', 'Admin', 'Native', 'UsersMenusInviteCode.php')
|
||||
assert.match(page, /aksrt-inv-remark/)
|
||||
assert.match(page, /remark: root\.querySelector\('#aksrt-inv-remark'\)/)
|
||||
assert.match(page, /invite-codes\/export-text/)
|
||||
assert.match(page, /item\.remark \|\| '—'/)
|
||||
// 添加表单标识字段(默认 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\)/)
|
||||
})
|
||||
|
||||
test('settings reader exposes normalized invite-code registration mode', () => {
|
||||
|
||||
Reference in New Issue
Block a user