feat(invite-code): one-time invite codes with rewards, usage log, import/export
- 一次性邀请码:批量生成(次数/有效期/备注/四类奖励)、原子消费防超发、 开启注册验证时奖励延迟发放(账本幂等键) - Zibll 兼容文本导入(points/balance/vip/vip_time/level_integral,分隔符可自定义, 第三段标识存为备注)、CSV 全字段导出与可回流文本导出 - 使用记录表 aksrt_invite_code_usage + 后台使用者列;注册表单 400ms 防抖实时校验 - 注册门控三模式(close/open/must)设置链路(PHP schema + admin SPA schema) - 契约测试覆盖建表/消费竞态/导入导出/备注全链路
This commit is contained in:
@@ -12,6 +12,7 @@ class UsersMenus {
|
||||
use UsersMenusVerify;
|
||||
use UsersMenusVip;
|
||||
use UsersMenusReferral;
|
||||
use UsersMenusInviteCode;
|
||||
use UsersMenusTickets;
|
||||
|
||||
public const CAPABILITY = 'manage_options';
|
||||
@@ -50,6 +51,15 @@ class UsersMenus {
|
||||
[$this, 'render_referral_codes_page']
|
||||
);
|
||||
|
||||
add_submenu_page(
|
||||
'users.php',
|
||||
__('邀请码管理', 'aksrt'),
|
||||
__('邀请码管理', 'aksrt'),
|
||||
'edit_users',
|
||||
'aksrt_invite_codes',
|
||||
[$this, 'render_invite_codes_page']
|
||||
);
|
||||
|
||||
if (!current_user_can(self::CAPABILITY)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
<?php
|
||||
/**
|
||||
* Native invite-code operations.
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Admin\Native;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait UsersMenusInviteCode {
|
||||
|
||||
public function render_invite_codes_page(): void {
|
||||
if (!current_user_can('edit_users')) {
|
||||
wp_die(esc_html__('您没有权限访问此页面。', 'aksrt'));
|
||||
}
|
||||
$endpoint = rest_url('aksrt/v1/invite-codes');
|
||||
$nonce = wp_create_nonce('wp_rest');
|
||||
?>
|
||||
<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>
|
||||
<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 || {}) },
|
||||
});
|
||||
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());
|
||||
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(' ');
|
||||
} 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;
|
||||
}
|
||||
});
|
||||
|
||||
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 });
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
@@ -195,6 +195,15 @@ class SchemaUsers {
|
||||
['key' => 'invit_code_open_desc', 'label' => '选填提示', 'type' => 'input', 'default' => '如您受到邀请,可填写推广码。部分推广码会有额外积分赠送'],
|
||||
['key' => 'invit_code_must_desc', 'label' => '必填提示', 'type' => 'textarea', 'default' => '<div>本站需使用推广码注册</div><div>获取推广码请与客服联系</div>'],
|
||||
]],
|
||||
['key' => 'invite_inline', 'title' => '邀请码注册', 'fields' => [
|
||||
['key' => 'invite_code_s', 'label' => '邀请码注册模式', 'type' => 'radio', 'default' => 'close', 'options' => [
|
||||
['label' => '关闭', 'value' => 'close'],
|
||||
['label' => '选填', 'value' => 'open'],
|
||||
['label' => '必填', 'value' => 'must'],
|
||||
], 'help' => '一次性邀请码与推广码相互独立:邀请码用于控制注册并按码发放奖励,在「用户 → 邀请码管理」批量生成'],
|
||||
['key' => 'invite_code_open_desc', 'label' => '选填提示', 'type' => 'input', 'default' => '请输入邀请码,注册成功后奖励将自动发放'],
|
||||
['key' => 'invite_code_must_desc', 'label' => '必填提示', 'type' => 'textarea', 'default' => '<div>本站需凭邀请码注册</div><div>获取邀请码请与客服联系</div>'],
|
||||
]],
|
||||
['key' => 'sign_page_ui', 'title' => '页面 UI 配置', 'fields' => [
|
||||
['key' => 'sign_page_background', 'label' => '页面背景图', 'type' => 'gallery', 'default' => [], 'desc' => '登录注册页背景图,多图随机'],
|
||||
['key' => 'sign_theme_mode', 'label' => '主题模式', 'type' => 'radio', 'default' => 'no', 'options' => [
|
||||
|
||||
Reference in New Issue
Block a user