Files
wordpress-theme-aksrt/src/Admin/Native/PayChargeCards.php
T
kate522 52d790c654 fix(payment): 卡密管理每页默认 20 条,撤掉工具栏「每页显示」控件
- DEFAULT_PER_PAGE 由 30 改为 20。
- 删除 per_page_selector() 及其表单样式(.aksrt-perpage-form / .order-header-right),
  工具栏恢复为「过滤链接 + 搜索表单」两段式。
  ice_perpage 仍作为 URL 参数被尊重,只是不再提供界面入口。
- 保留上一提交的分页健壮性修复:页码夹取到 [1, total_pages]、翻页链接用显式基准 URL、
  越界页码回退并重查、禁用按钮 pointer-events:none。
- 契约测试同步改写:断言默认值为 20,并显式禁止「每页显示」控件回归。

验证:默认态渲染为「每页 20 共10」,页面内不存在 aksrt-perpage-form;
ice_perpage=5 仍能翻到第 2 页且链接携带该参数;paged=2 越界仍回退到第 1 页的 10 行;
node --test 三套 18/18;php -l 通过;无头 Chrome 截图确认工具栏无多余控件。
2026-09-17 13:03:05 +08:00

1336 lines
70 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* @package AKSRT
*
* 后台「卡密管理」页面 — 1:1 复刻参考主题 zibll 的 zibpay/page/charge-card.php。
*
* 结构:列表(过滤/搜索/排序/批量删除/分页) + 添加卡密(自动生成 / 导入) + 导出卡密(text / xls)。
* 卡密类型与参考主题一致:余额充值 / 会员兑换 / 积分兑换 / 自定义卡密;
* 本主题内部类型映射:balance / vip / credits / custom。
* 差异点:AKSRT 无商城发货(shipped)子系统,故不含「已售」标记与链接;删除取代参考主题的批量操作语义一致。
*/
namespace AKSRT\Admin\Native;
defined('ABSPATH') || exit;
class PayChargeCards {
public const CAPABILITY = 'manage_options';
public const PAGE_SLUG = 'aksrt_charge_cards';
public const NONCE = 'aksrt_card_pass_page';
public const EXPORT_AJAX_ACTION = 'aksrt_card_pass_export';
/** 每页条数默认值;列表页可由 ice_perpage 覆盖(无界面控件,仅供 URL / 程序化调用)。 */
public const DEFAULT_PER_PAGE = 20;
private static $instance = null;
/** @var string 当前页面 URL */
private $page_url = '';
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
if (!is_admin()) {
return;
}
add_action('wp_ajax_' . self::EXPORT_AJAX_ACTION, [$this, 'handle_export']);
}
public function render_charge_cards_page(): void {
$this->ensure_cap();
$this->page_url = admin_url('admin.php?page=' . self::PAGE_SLUG);
if (!class_exists('\AKSRT\Payment\ChargeCard')) {
echo '<div class="wrap"><h1>' . esc_html__('卡密管理', 'aksrt') . '</h1>';
echo '<div class="notice notice-error"><p>' . esc_html__('卡密模块未初始化。', 'aksrt') . '</p></div></div>';
return;
}
$tab = isset($_GET['tab']) ? sanitize_key((string) wp_unslash($_GET['tab'])) : '';
$s = isset($_REQUEST['s']) ? sanitize_text_field((string) wp_unslash($_REQUEST['s'])) : '';
$page_title = __('卡密管理', 'aksrt');
$head_but = '';
// 页面级动作(参考主题在渲染前处理 add / delete)。
$action = isset($_REQUEST['action']) ? sanitize_key((string) wp_unslash($_REQUEST['action'])) : '';
if ($action === 'add') {
$this->handle_add();
} elseif ($action === 'delete') {
$this->handle_delete();
}
// 标题右侧按钮是页面头部的一部分,只在此处输出一次(列表页同样如此,由 render_list 负责列表本体)。
switch ($tab) {
case 'add':
$page_title = __('添加卡密', 'aksrt');
$head_but = '<a href="' . esc_url($this->page_url) . '" class="page-title-action">' . esc_html__('返回列表', 'aksrt') . '</a>';
break;
case 'export':
$page_title = __('导出卡密', 'aksrt');
$head_but = '<a href="' . esc_url($this->page_url) . '" class="page-title-action">' . esc_html__('返回列表', 'aksrt') . '</a>';
break;
default:
$head_but = '<a href="' . esc_url(add_query_arg('tab', 'add', $this->page_url)) . '" class="page-title-action">' . esc_html__('添加卡密', 'aksrt') . '</a>';
$head_but .= '<a href="' . esc_url(add_query_arg('tab', 'export', $this->page_url)) . '" class="page-title-action">' . esc_html__('导出卡密', 'aksrt') . '</a>';
if ($s !== '') {
$head_but .= '<div><div class="update-nag" style="margin: 10px 0 0;">' . sprintf(esc_html__('搜索 “%s” 的内容', 'aksrt'), esc_html($s)) . ' </div></div>';
}
break;
}
echo '<div class="wrap">';
$this->print_assets();
echo '<h1 class="wp-heading-inline">' . esc_html($page_title) . '</h1>';
echo $head_but; // phpcs:ignore WordPress.Security.EscapeOutput -- 内部构造,已转义。
if ($tab === 'add') {
$this->render_add_form();
} elseif ($tab === 'export') {
$this->render_export_form();
} else {
$this->render_list($s);
}
echo '</div>';
}
/* ------------------------------------------------------------------
* 列表
* ------------------------------------------------------------------ */
/**
* @param string $s 搜索词(由页面头部统一解析后传入,用于提示、过滤链接与查询)。
*/
private function render_list(string $s): void {
$svc = \AKSRT\Payment\ChargeCard::get_instance();
$status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : '';
$type = isset($_GET['type']) ? sanitize_key((string) wp_unslash($_GET['type'])) : '';
$other = isset($_GET['other']) ? sanitize_text_field((string) wp_unslash($_GET['other'])) : '';
$orderby = isset($_GET['orderby']) ? sanitize_key((string) wp_unslash($_GET['orderby'])) : 'modified_time';
$order = isset($_GET['desc']) ? (strtoupper((string) wp_unslash($_GET['desc'])) === 'ASC' ? 'ASC' : 'DESC') : 'DESC';
$paged = max(1, (int) ($_GET['paged'] ?? 1));
$per_page = max(1, min(100, (int) ($_REQUEST['ice_perpage'] ?? self::DEFAULT_PER_PAGE)));
// 参考主题:status=0 表示未使用;本主题规范状态为 active。
if ($status === '0' || $status === 'active') {
$status = 'active';
} elseif (!in_array($status, ['used', 'expired', 'disabled'], true)) {
$status = '';
}
if (!in_array($type, ['balance', 'vip', 'credits', 'custom'], true)) {
$type = '';
}
// 过滤链接(标题右侧按钮与搜索提示由页面头部统一输出)。
$sub_but = [
['name' => __('全部', 'aksrt'), 'href' => $this->page_url],
];
if ($s === '') {
$sub_but[] = ['name' => __('未使用', 'aksrt'), 'href' => add_query_arg('status', '0', $this->page_url)];
$sub_but[] = ['name' => __('已使用', 'aksrt'), 'href' => add_query_arg('status', 'used', $this->page_url)];
foreach ($this->type_labels() as $_k => $_v) {
$sub_but[] = ['name' => $_v, 'href' => add_query_arg('type', $_k, $this->page_url)];
}
}
$but_html = '';
foreach ($sub_but as $but) {
$but_html .= '<li><a href="' . esc_url($but['href']) . '">' . esc_html($but['name']) . '</a></li> | ';
}
$but_html = substr($but_html, 0, -2);
$search = '<form class="form-inline form-order" method="post">
<div class="form-group" style="float: right;">
<input type="text" class="form-control" name="s" placeholder="' . esc_attr__('搜索卡密', 'aksrt') . '">
<button type="submit" class="button">' . esc_html__('提交', 'aksrt') . '</button>
</div>
</form>';
// 当前筛选参数(不含 paged)。分页链接显式带上它,不再依赖 add_query_arg()
// 默认取 $_SERVER['REQUEST_URI'](链接会随环境漂移,且列条件容易丢)。
$query_args = $this->list_query_args($status, $type, $other, $s, $orderby, $order, $per_page);
echo '<div class="order-header"><ul class="subsubsub">' . $but_html . '</ul>' . $search . '</div>';
$filters = [
'status' => $status,
'type' => $type,
'other' => $other,
'search' => $s,
'orderby' => $orderby,
'order' => $order,
];
$result = $svc->get_list($filters + ['limit' => $per_page, 'offset' => ($paged - 1) * $per_page]);
$items = (array) ($result['items'] ?? []);
$count_all = (int) ($result['total'] ?? 0);
// 页码越界(手工改 URL、或筛选后总数变小)会让 offset 落到数据之外,
// 表现为「空白表格 + 一个分页控件都没有」的死胡同,只能靠手改 URL 退出。
// 这里回退到最后一页重查一次,保证越界页码也能拿到数据与分页条。
$max_page = max(1, (int) ($result['max_pages'] ?? 1));
if (!$items && $count_all > 0 && $paged > $max_page) {
$paged = $max_page;
$result = $svc->get_list($filters + ['limit' => $per_page, 'offset' => ($paged - 1) * $per_page]);
$items = (array) ($result['items'] ?? []);
$count_all = (int) ($result['total'] ?? $count_all);
}
if (!$items) {
echo '<div class="clear"></div>';
echo '<table class="widefat fixed striped posts table table-bordered" style="min-width: 1000px;">';
echo '<thead><tr><td style="color: #ff4a4a;text-align: center;">' . esc_html__('未找到对应内容,或暂无内容', 'aksrt') . '</td></tr></thead>';
echo '</table>';
return;
}
// 表头(与参考主题同列、同排序链接)。
$theads = [
['width' => '6%', 'orderby' => 'card', 'name' => __('卡号', 'aksrt')],
['width' => '10%', 'orderby' => 'password', 'name' => __('密码', 'aksrt')],
['width' => '5%', 'orderby' => 'type', 'name' => __('类型', 'aksrt')],
['width' => '5%', 'orderby' => 'create_time', 'name' => __('创建时间', 'aksrt')],
['width' => '5%', 'orderby' => 'modified_time', 'name' => __('更新时间', 'aksrt')],
['width' => '5%', 'orderby' => 'status', 'name' => __('状态', 'aksrt')],
['width' => '8%', 'orderby' => 'other', 'name' => __('标识', 'aksrt')],
];
$thead_th = '<td id="cb" class="manage-column column-cb check-column" style="width: 2%;"><label class="screen-reader-text" for="cb-select-all-1">' . esc_html__('全选', 'aksrt') . '</label><input id="cb-select-all-1" type="checkbox"></td>';
foreach ($theads as $thead) {
$orderby_links = '';
if ($thead['orderby']) {
$orderby_url = add_query_arg('orderby', $thead['orderby']);
$orderby_links .= '<a title="' . esc_attr__('降序', 'aksrt') . '" href="' . esc_url(add_query_arg('desc', 'ASC', $orderby_url)) . '"><span class="dashicons dashicons-arrow-up"></span></a>';
$orderby_links .= '<a title="' . esc_attr__('升序', 'aksrt') . '" href="' . esc_url(add_query_arg('desc', 'DESC', $orderby_url)) . '"><span class="dashicons dashicons-arrow-down"></span></a>';
$orderby_links = '<span class="orderby-but">' . $orderby_links . '</span>';
}
$thead_th .= '<th class="" width="' . esc_attr($thead['width']) . '">' . esc_html($thead['name']) . $orderby_links . '</th>';
}
$tbody = '';
foreach ($items as $msg) {
$meta = [];
if (!empty($msg['meta'])) {
$decoded = json_decode((string) $msg['meta'], true);
$meta = is_array($decoded) ? $decoded : [];
}
// 自定义卡密不展示状态;其余类型按状态着色(类型链接统一在下方链表中生成)。
$status_html = '';
if ($msg['type'] !== 'custom') {
$status_html = '<span style="color: #3d7ffd;">' . esc_html__('未使用', 'aksrt') . '</span>';
if ($msg['status'] === 'used') {
$status_html = '<span style="color: #f93b3b;">' . esc_html__('已使用', 'aksrt') . '</span>';
if (!empty($msg['order_number'])) {
$order_link_url = admin_url('admin.php?page=aksrt_orders&s=' . urlencode((string) $msg['order_number']));
$status_html .= '<a style="color: #ff6215;" target="_blank" href="' . esc_url($order_link_url) . '"> [' . esc_html__('查看', 'aksrt') . ']</a>';
}
} elseif ($msg['status'] === 'expired') {
$status_html = '<span style="color: #8c8f94;">' . esc_html__('已过期', 'aksrt') . '</span>';
} elseif ($msg['status'] === 'disabled') {
$status_html = '<span style="color: #8c8f94;">' . esc_html__('已禁用', 'aksrt') . '</span>';
}
}
$other_a = '';
if (!empty($msg['note'])) {
$other_a = '<a href="' . esc_url(add_query_arg('other', $msg['note'], $this->page_url)) . '">' . esc_html($msg['note']) . '</a>';
}
if ($msg['type'] === 'vip') {
$type_html = '<a style="color:#975106" href="' . esc_url(add_query_arg('type', 'vip', $this->page_url)) . '">' . esc_html__('会员兑换', 'aksrt') . '</a>';
$level = (int) ($meta['level'] ?? 1);
$type_html .= '<br>' . esc_html($this->vip_level_name($level)) . ':' . esc_html($this->vip_time_text($meta));
} elseif ($msg['type'] === 'credits') {
$type_html = '<a style="color:#289d0f" href="' . esc_url(add_query_arg('type', 'credits', $this->page_url)) . '">' . esc_html__('积分兑换', 'aksrt') . '</a>';
$type_html .= '<br>' . esc_html__('积分:', 'aksrt') . esc_html((string) (int) ($meta['points'] ?? 0));
} elseif ($msg['type'] === 'balance') {
$type_html = '<a style="color:#0c76e6" href="' . esc_url(add_query_arg('type', 'balance', $this->page_url)) . '">' . esc_html__('余额充值', 'aksrt') . '</a>';
$type_html .= '<br>' . esc_html__('金额:', 'aksrt') . esc_html($this->balance_amount_text($meta));
} else {
$type_html = '<a style="color:#a42ade" href="' . esc_url(add_query_arg('type', 'custom', $this->page_url)) . '">' . esc_html__('自定义卡密', 'aksrt') . '</a>';
}
$card_name = (string) ($meta['custom_name']['card_name'] ?? '');
$password_name = (string) ($meta['custom_name']['password_name'] ?? '');
$tbody .= '<tr>';
$tbody .= '<th scope="row" class="check-column"><label class="screen-reader-text" for="cb-select-' . (int) $msg['id'] . '">' . esc_html__('选择', 'aksrt') . '</label>
<input id="cb-select-' . (int) $msg['id'] . '" type="checkbox" name="action_id[]" value="' . (int) $msg['id'] . '">
</th>';
$tbody .= "<td><span data-clipboard-text='" . esc_attr((string) $msg['card']) . "' data-clipboard-tag=\"" . esc_attr($card_name !== '' ? $card_name : __('卡号', 'aksrt')) . "\">" . esc_html($card_name) . ' ' . esc_html((string) $msg['card']) . '</span></td>';
$tbody .= "<td><span data-clipboard-text='" . esc_attr((string) $msg['password']) . "' data-clipboard-tag=\"" . esc_attr($password_name !== '' ? $password_name : __('密码', 'aksrt')) . "\">" . esc_html($password_name) . ' ' . esc_html((string) $msg['password']) . '</span></td>';
$tbody .= "<td>$type_html</td>";
$tbody .= '<td>' . esc_html((string) $msg['created_at']) . '</td>';
$tbody .= '<td>' . esc_html((string) ($msg['used_at'] ?: $msg['created_at'])) . '</td>';
$tbody .= "<td>$status_html</td>";
$tbody .= "<td>$other_a</td>";
$tbody .= '</tr>';
}
echo '<div class="clear"></div>';
echo '<form class="" method="post">';
wp_nonce_field(self::NONCE);
echo '<div class="bulkactions" style="margin: 10px 0;">
<label for="bulk-action-selector-top" class="screen-reader-text">' . esc_html__('选择批量操作', 'aksrt') . '</label><select name="action" id="bulk-action-selector-top">
<option value="-1">' . esc_html__('批量操作', 'aksrt') . '</option>
<option value="delete">' . esc_html__('删除', 'aksrt') . '</option>
</select>
<input type="submit" class="button action" value="' . esc_attr__('应用', 'aksrt') . '">
</div>';
echo '<div style="overflow-y: auto;width: 100%;">';
echo '<table class="widefat fixed striped posts table table-bordered" style="min-width: 1000px;">';
echo '<thead><tr>' . $thead_th . '</tr></thead>'; // phpcs:ignore WordPress.Security.EscapeOutput -- 已转义。
echo '<tbody>' . $tbody . '</tbody>'; // phpcs:ignore WordPress.Security.EscapeOutput -- 已转义。
echo '</table>';
echo '</div>';
echo '</form>';
echo '<div class="clear"></div>';
$this->render_pagenavi($count_all, $per_page, $query_args);
}
/* ------------------------------------------------------------------
* 添加卡密
* ------------------------------------------------------------------ */
private function render_add_form(): void {
$type_labels = $this->type_labels();
?>
<p><b><?php esc_html_e('在此添加卡密', 'aksrt'); ?></b></p>
<ul style="list-style:disc;margin:0 0 12px 18px;line-height:1.9">
<li><?php esc_html_e('如果您已经准备好了卡密资料,请选择导入的方式添加', 'aksrt'); ?></li>
<li><?php esc_html_e('您也可以采用系统生成的方式,自动批量添加卡密', 'aksrt'); ?></li>
</ul>
<form class="aksrt-cardpass-form" method="post">
<?php wp_nonce_field(self::NONCE); ?>
<input type="hidden" name="action" value="add" />
<table class="form-table" role="presentation">
<?php
$this->field_row(
__('卡密类型', 'aksrt'),
$this->button_set('type', $type_labels, 'balance'),
[]
);
$this->field_row(
__('添加方式', 'aksrt'),
$this->button_set('add_type', [
'auto' => __('系统自动生成', 'aksrt'),
'import' => __('导入卡密', 'aksrt'),
], 'auto'),
[]
);
// 自动生成
$this->field_row(
__('生成数量', 'aksrt'),
$this->spinner('auto_num', 20, 1, 1000, 10, __('张', 'aksrt')),
[['key' => 'add_type', 'op' => '==', 'val' => 'auto']],
__('需要生成多少张卡密(单次生成数量太多可能会对服务器性能造成影响)', 'aksrt')
);
$this->field_row(
__('标识', 'aksrt'),
'<input type="text" class="regular-text" name="auto_remarks" value="' . esc_attr('cardpass_' . current_time('YmdHis')) . '" />',
[['key' => 'add_type', 'op' => '==', 'val' => 'auto']],
__('对生成的卡密做标记标识,方便后期查找管理', 'aksrt') . '<br>' . sprintf(esc_html__('注意:标识中不能有%1$s字符,系统已占用', 'aksrt'), '<code>shipped</code>')
);
$this->field_row(
__('面额', 'aksrt'),
'<input type="number" step="0.01" min="0" name="auto_price" value="0" />',
[
['key' => 'add_type', 'op' => '==', 'val' => 'auto'],
['key' => 'type', 'op' => 'any', 'val' => 'balance,credits'],
],
__('单张卡密的面额', 'aksrt')
);
$this->field_row(
__('兑换会员', 'aksrt'),
__('会员等级', 'aksrt') . ' ' . $this->button_set('vip_exchange[level]', [
'1' => $this->vip_level_name(1),
'2' => $this->vip_level_name(2),
], '1'),
[
['key' => 'add_type', 'op' => '==', 'val' => 'auto'],
['key' => 'type', 'op' => '==', 'val' => 'vip'],
]
);
$this->field_row(
' ',
__('兑换会员时长', 'aksrt') . '<br><input type="text" name="vip_exchange[time]" value="" />',
[
['key' => 'add_type', 'op' => '==', 'val' => 'auto'],
['key' => 'type', 'op' => '==', 'val' => 'vip'],
],
__('填', 'aksrt') . '<code>Permanent</code>' . __('为永久', 'aksrt')
);
$this->field_row(
' ',
__('时长单位', 'aksrt') . ' ' . $this->button_set('vip_exchange[unit]', [
'day' => __('天', 'aksrt'),
'month' => __('个月', 'aksrt'),
], 'month'),
[
['key' => 'add_type', 'op' => '==', 'val' => 'auto'],
['key' => 'type', 'op' => '==', 'val' => 'vip'],
['key' => 'vip_exchange[time]', 'op' => '!=', 'val' => 'Permanent'],
]
);
$this->field_row(
__('自定义显示名称', 'aksrt'),
__('卡号显示名称', 'aksrt') . '<br><input type="text" class="regular-text" name="custom_name[card_name]" value="" /><br>' .
__('卡密显示名称', 'aksrt') . '<br><input type="text" class="regular-text" name="custom_name[password_name]" value="" />',
[
['key' => 'add_type', 'op' => '==', 'val' => 'auto'],
['key' => 'type', 'op' => '==', 'val' => 'custom'],
]
);
$this->field_row(
__('高级选项', 'aksrt'),
'<input type="checkbox" name="auto_top_s" value="1" />',
[['key' => 'add_type', 'op' => '==', 'val' => 'auto']]
);
$this->field_row(
__('自定义卡号位数', 'aksrt'),
$this->spinner('auto_rand_number_limit', 20, 1, 50, 5, __('位数', 'aksrt')),
[
['key' => 'add_type', 'op' => '==', 'val' => 'auto'],
['key' => 'auto_top_s', 'op' => 'checked'],
]
);
$this->field_row(
__('自定义密码位数', 'aksrt'),
$this->spinner('auto_rand_password_limit', 35, 1, 50, 5, __('位数', 'aksrt')),
[
['key' => 'add_type', 'op' => '==', 'val' => 'auto'],
['key' => 'auto_top_s', 'op' => 'checked'],
],
__('自定义自动生成的长度(不能太短,太短可能会出现重复)如果启用了单密码模式,可以将卡号位数设置为0', 'aksrt')
);
// 导入提示统一放在「卡密数据」正上方:切换卡密类型/添加方式时,
// 命中的那一条紧邻示例数据与输入框,便于对照填写。
$this->field_row(
__('单密码模式', 'aksrt'),
'<input type="checkbox" name="only_pass_s" value="1" />',
[
['key' => 'add_type', 'op' => '==', 'val' => 'import'],
['key' => 'type', 'op' => '==', 'val' => 'custom'],
]
);
$this->submessage_row(
[
['key' => 'add_type', 'op' => '!=', 'val' => 'auto'],
['key' => 'type', 'op' => 'any', 'val' => 'balance,credits'],
],
'<p><b>' . esc_html__('导入卡密', 'aksrt') . '</b></p>
<ul class="aksrt-submsg-list">
<li>' . esc_html__('一行一个卡密,单行格式为:', 'aksrt') . '<code>' . esc_html__('卡号 密码 面额 标识', 'aksrt') . '</code></li>
<li>' . esc_html__('卡号、密码、面额、标识默认使用空格分割,您可以在下方自定义分割符号,与您的数据对应即可', 'aksrt') . '</li>
<li>' . esc_html__('单次导入数量太多可能会对服务器性能造成影响', 'aksrt') . '</li><li>' . esc_html__('如果使用的是单密码模式,卡号部分随便填什么都行,但不能留空', 'aksrt') . '</li>
</ul>
<p><b>' . esc_html__('数据示例', 'aksrt') . '</b></p>
<div>4849977843617 3w20V4Qe7dRhAIoPYum6KSA 50 ' . sprintf(esc_html__('%1$s余额', 'aksrt'), esc_html('50' . $this->currency_unit())) . '</div>
<div>6484027668705 SkdJZ5vWcpqPxrbs3pqPcpq 100 ' . sprintf(esc_html__('%1$s余额', 'aksrt'), esc_html('100' . $this->currency_unit())) . '</div>'
);
$this->submessage_row(
[
['key' => 'add_type', 'op' => '!=', 'val' => 'auto'],
['key' => 'type', 'op' => '==', 'val' => 'vip'],
],
'<p><b>' . esc_html__('导入会员兑换卡密', 'aksrt') . '</b></p>
<ul class="aksrt-submsg-list">
<li>' . esc_html__('一行一个卡密,单行格式为:', 'aksrt') . '<code>' . esc_html__('卡号 密码 会员参数 标识', 'aksrt') . '</code></li>
<li>' . esc_html__('卡号、密码、面额、标识默认使用空格分割,您可以在下方自定义分割符号,与您的数据对应即可', 'aksrt') . '</li>
<li>' . esc_html__('会员参数格式为:', 'aksrt') . '<code>level=1&time=5&unit=day</code></li>
<li>' . esc_html__('参数说明:level为等级,只能为1或2。time为时长,填Permanent或数字。unit为时长单位,只能填day(天)或者month(月)', 'aksrt') . '</li>
<li>' . esc_html__('单次导入数量太多可能会对服务器性能造成影响', 'aksrt') . '</li><li>' . esc_html__('如果使用的是单密码模式,卡号部分随便填什么都行,但不能留空', 'aksrt') . '</li>
</ul>
<p><b>' . esc_html__('数据示例', 'aksrt') . '</b></p>
<div>4849977843617 3w20V4Qe7dRhAIoPYum6KSA level=1&time=5&unit=day ' . esc_html__('5天一级会员', 'aksrt') . '</div>
<div>6484027668705 SkdJZ5vWcpqPxrbs3pqPcpq level=2&time=Permanent ' . esc_html__('永久2级会员', 'aksrt') . '</div>
'
);
$this->submessage_row(
[
['key' => 'add_type', 'op' => '==', 'val' => 'import'],
['key' => 'type', 'op' => '==', 'val' => 'custom'],
['key' => 'only_pass_s', 'op' => 'unchecked'],
],
'<p><b>' . esc_html__('自定义卡密导入', 'aksrt') . '</b></p>
<ul class="aksrt-submsg-list">
<li>' . esc_html__('一行一个卡密,单行格式为:', 'aksrt') . '<code>' . esc_html__('卡号 密码 标识 自定义参数', 'aksrt') . '</code></li>
<li>' . esc_html__('默认使用空格分割,您可以在下方自定义分割符号,与您的数据对应即可', 'aksrt') . '</li>
<li>' . esc_html__('自定义参数格式为:', 'aksrt') . '<code>card_name=授权号&password_name=授权密码</code>,' . esc_html__('card_name为卡号的显示名称,password_name为卡密的显示名称,可以留空', 'aksrt') . '</li>
<li>' . esc_html__('单次导入数量太多可能会对服务器性能造成影响', 'aksrt') . '</li>
</ul>
<p><b>' . esc_html__('数据示例', 'aksrt') . '</b></p>
<div>4849977843617 3w20V4Qe7dRhAIoPYum6KSA ' . esc_html__('平台卡密01', 'aksrt') . ' card_name=' . esc_html__('授权号', 'aksrt') . '&password_name=' . esc_html__('授权码', 'aksrt') . '</div>
<div>6484027668705 SkdJZ5vWcpqPxrbs3pqPcpq ' . esc_html__('平台卡密01', 'aksrt') . ' card_name=' . esc_html__('授权号', 'aksrt') . '&password_name=' . esc_html__('授权码', 'aksrt') . '</div>
<div>111111@qq.com 11assssddsds ' . esc_html__('某某账号', 'aksrt') . ' card_name=' . esc_html__('账号', 'aksrt') . '&password_name=' . esc_html__('密码', 'aksrt') . '</div>'
);
$this->submessage_row(
[
['key' => 'add_type', 'op' => '==', 'val' => 'import'],
['key' => 'type', 'op' => '==', 'val' => 'custom'],
['key' => 'only_pass_s', 'op' => 'checked'],
],
'<p><b>' . esc_html__('自定义卡密导入(单密码模式)', 'aksrt') . '</b></p>
<ul class="aksrt-submsg-list">
<li>' . esc_html__('一行一个卡密,单行格式为:', 'aksrt') . '<code>' . esc_html__('密码 标识 自定义参数', 'aksrt') . '</code></li>
<li>' . esc_html__('默认使用空格分割,您可以在下方自定义分割符号,与您的数据对应即可', 'aksrt') . '</li>
<li>' . esc_html__('单次导入数量太多可能会对服务器性能造成影响', 'aksrt') . '</li>
<li>' . esc_html__('自定义参数格式为:', 'aksrt') . '<code>password_name=授权密码</code>,' . esc_html__('password_name为卡密的显示名称,可以留空', 'aksrt') . '</li>
</ul>
<p><b>' . esc_html__('数据示例', 'aksrt') . '</b></p>
<div>3w20V4Qe7dRhAIoPYum6KSA ' . esc_html__('平台卡密01', 'aksrt') . ' password_name=' . esc_html__('授权码', 'aksrt') . '</div>
<div>SkdJZ5vWcpqPxrbs3pqPcpq ' . esc_html__('平台卡密01', 'aksrt') . ' password_name=' . esc_html__('授权码', 'aksrt') . '</div>'
);
$this->field_row(
__('卡密数据', 'aksrt'),
'<textarea name="import_data" rows="10" class="large-text code" style="resize: both;max-width: none;"></textarea>',
[['key' => 'add_type', 'op' => '==', 'val' => 'import']]
);
$this->field_row(
__('自定义分隔符号', 'aksrt'),
'<input type="text" class="mini-input" style="width:80px;" name="import_division" value=" " />',
[['key' => 'add_type', 'op' => '==', 'val' => 'import']],
__('卡号和密码之间分割符号(默认为空格分割)', 'aksrt')
);
?>
</table>
<?php submit_button(__('确认提交', 'aksrt'), 'primary', 'submit', true); ?>
</form>
<?php
}
/* ------------------------------------------------------------------
* 导出卡密
* ------------------------------------------------------------------ */
private function render_export_form(): void {
?>
<form class="aksrt-cardpass-form" method="post" action="<?php echo esc_url(admin_url('admin-ajax.php')); ?>">
<input type="hidden" name="action" value="<?php echo esc_attr(self::EXPORT_AJAX_ACTION); ?>" />
<table class="form-table" role="presentation">
<?php
$this->field_row(
__('卡密类型', 'aksrt'),
$this->button_set('type', $this->type_labels(), 'balance'),
[]
);
$this->field_row(
__('选择状态', 'aksrt'),
$this->button_set('status', [
'all' => __('全部', 'aksrt'),
'0' => __('未使用', 'aksrt'),
'used' => __('已使用', 'aksrt'),
], 'all'),
[['key' => 'type', 'op' => '!=', 'val' => 'custom']]
);
$this->field_row(
__('导出格式', 'aksrt'),
$this->button_set('export_format', [
'text' => __('文本文档', 'aksrt'),
'xls' => __('Excel表格', 'aksrt'),
], 'xls'),
[]
);
$this->field_row(
' ',
__('分隔符号', 'aksrt') . '<br><input type="text" class="mini-input" style="width:80px;" name="text_division" value=" " />',
[['key' => 'export_format', 'op' => '==', 'val' => 'text']],
__('卡号和密码之间分割符号(默认为空格分割)', 'aksrt')
);
?>
</table>
<?php submit_button(__('确认提交', 'aksrt'), 'primary', 'submit', true); ?>
</form>
<?php
}
/**
* 导出处理(参考主题 zibpay_ajax_card_pass_export 同款行为)。
*/
public function handle_export(): void {
if (!current_user_can(self::CAPABILITY)) {
wp_die(esc_html__('暂无此权限', 'aksrt'));
}
@set_time_limit(0);
$export_format = isset($_REQUEST['export_format']) && sanitize_key((string) wp_unslash($_REQUEST['export_format'])) === 'text' ? 'text' : 'xls';
$type = isset($_REQUEST['type']) ? sanitize_key((string) wp_unslash($_REQUEST['type'])) : '';
$status_raw = isset($_REQUEST['status']) ? sanitize_key((string) wp_unslash($_REQUEST['status'])) : 'all';
$status = '';
if ($status_raw !== 'all' && $status_raw !== '') {
// 参考主题:0=未使用。
$status = $status_raw === '0' ? 'active' : $status_raw;
}
$rows = \AKSRT\Payment\ChargeCard::get_instance()->get_export_rows($type, $status);
if (!$rows) {
wp_die(esc_html__('暂无可导出的内容', 'aksrt'));
}
$filename = $type . '_' . gmdate('d_m_Y');
// 组装行数据(与参考主题字段一致)。
$title = [];
$data = [];
foreach ($rows as $row) {
$meta = [];
if (!empty($row['meta'])) {
$decoded = json_decode((string) $row['meta'], true);
$meta = is_array($decoded) ? $decoded : [];
}
$status_text = $row['status'] === 'used'
? __('已使用', 'aksrt')
: ($row['status'] === 'expired' ? __('已过期', 'aksrt') : ($row['status'] === 'disabled' ? __('已禁用', 'aksrt') : __('未使用', 'aksrt')));
switch ($type) {
case 'credits': // 积分兑换
$title = [__('卡号', 'aksrt'), __('密码', 'aksrt'), __('面额', 'aksrt'), __('备注', 'aksrt'), __('状态', 'aksrt')];
$data[] = [(string) $row['card'], (string) $row['password'], (string) (int) ($meta['points'] ?? 0), (string) $row['note'], $status_text];
break;
case 'vip': // 会员兑换
$title = [__('卡号', 'aksrt'), __('密码', 'aksrt'), __('兑换会员', 'aksrt'), __('备注', 'aksrt'), __('状态', 'aksrt')];
$data[] = [(string) $row['card'], (string) $row['password'], $this->vip_meta_query($meta), (string) $row['note'], $status_text];
break;
case 'custom': // 自定义
$title = [__('卡号', 'aksrt'), __('密码', 'aksrt'), __('备注', 'aksrt'), __('状态', 'aksrt')];
$data[] = [(string) $row['card'], (string) $row['password'], (string) $row['note'], ''];
break;
default: // 余额充值
$title = [__('卡号', 'aksrt'), __('密码', 'aksrt'), __('面额', 'aksrt'), __('备注', 'aksrt'), __('状态', 'aksrt')];
$data[] = [(string) $row['card'], (string) $row['password'], $this->balance_amount_text($meta), (string) $row['note'], $status_text];
}
}
if ($export_format === 'text') {
$division = isset($_REQUEST['text_division']) ? (string) wp_unslash($_REQUEST['text_division']) : ' ';
header('Content-type:application/octet-stream');
header('Accept-Ranges:bytes');
header('Content-Disposition:attachment;filename=' . $filename . '.txt');
header('Pragma: no-cache');
header('Pragma: public');
header('Expires: 0');
$_data = [];
foreach ($data as $val) {
$_data[] = implode($division, $val);
}
echo implode("\n", $_data); // phpcs:ignore WordPress.Security.EscapeOutput
} else {
$this->export_excel($data, $title, $filename);
}
exit;
}
/**
* 导出 xls(参考主题 zib_export_excel 同款行为,GB2312 编码保证 Excel 兼容)。
*
* @param array<int,array<int,string>> $data
* @param array<int,string> $title
*/
private function export_excel(array $data, array $title, string $filename): void {
header('Content-type:application/octet-stream');
header('Accept-Ranges:bytes');
header('Content-type:application/vnd.ms-excel');
header('Content-Disposition:attachment;filename=' . $filename . '.xls');
header('Pragma: no-cache');
header('Expires: 0');
$convert = static function (string $value): string {
return function_exists('mb_convert_encoding')
? (string) @mb_convert_encoding($value, 'GB2312', 'UTF-8')
: $value;
};
if (!empty($title)) {
$title = array_map($convert, $title);
echo implode("\t", $title) . "\n";
}
if (!empty($data)) {
$_data = [];
foreach ($data as $val) {
$val = array_map($convert, array_map('strval', $val));
$_data[] = implode("\t", $val);
}
echo implode("\n", $_data);
}
}
/* ------------------------------------------------------------------
* 动作处理
* ------------------------------------------------------------------ */
private function handle_add(): void {
check_admin_referer(self::NONCE);
$add_type = isset($_REQUEST['add_type']) ? sanitize_key((string) wp_unslash($_REQUEST['add_type'])) : 'auto';
$pass_type = isset($_REQUEST['type']) ? sanitize_key((string) wp_unslash($_REQUEST['type'])) : '';
if (!in_array($pass_type, ['balance', 'vip', 'credits', 'custom'], true)) {
$this->page_notice(__('错误!', 'aksrt'), __('参数传入错误', 'aksrt'), 'error');
return;
}
if ($add_type === 'import') {
$this->handle_import($pass_type);
return;
}
// 自动生成。
$auto_num = isset($_REQUEST['auto_num']) ? (int) $_REQUEST['auto_num'] : 0;
if (!$auto_num) {
$this->page_notice(__('错误!', 'aksrt'), __('请输入需要生成的数量', 'aksrt'), 'error');
return;
}
$pass_meta = [];
switch ($pass_type) {
case 'balance':
case 'credits':
$auto_price = isset($_REQUEST['auto_price']) ? floatval(round((float) $_REQUEST['auto_price'], 2)) : 0;
if (!$auto_price) {
$this->page_notice(__('错误!', 'aksrt'), __('请输入卡密的面额', 'aksrt'), 'error');
return;
}
$pass_meta = $pass_type === 'credits'
? ['points' => (int) $auto_price]
: ['amount' => number_format($auto_price, 2, '.', '')];
break;
case 'vip':
$vip = isset($_REQUEST['vip_exchange']) && is_array($_REQUEST['vip_exchange']) ? wp_unslash($_REQUEST['vip_exchange']) : [];
if (empty($vip['time'])) {
$this->page_notice(__('错误!', 'aksrt'), __('请填写兑换会员参数', 'aksrt'), 'error');
return;
}
$pass_meta = $this->vip_meta($vip);
break;
}
// 自定义显示名称(参考主题:所有类型均可附加)。
$custom_name = isset($_REQUEST['custom_name']) && is_array($_REQUEST['custom_name']) ? wp_unslash($_REQUEST['custom_name']) : [];
if (!empty($custom_name['card_name'])) {
$pass_meta['custom_name']['card_name'] = trim(sanitize_text_field((string) $custom_name['card_name']));
}
if (!empty($custom_name['password_name'])) {
$pass_meta['custom_name']['password_name'] = trim(sanitize_text_field((string) $custom_name['password_name']));
}
$rand_number = 20;
$rand_password = 35;
if (!empty($_REQUEST['auto_top_s'])) {
$rand_number = isset($_REQUEST['auto_rand_number_limit']) ? (int) $_REQUEST['auto_rand_number_limit'] : 20;
$rand_password = isset($_REQUEST['auto_rand_password_limit']) ? (int) $_REQUEST['auto_rand_password_limit'] : 35;
}
$remarks = isset($_REQUEST['auto_remarks']) ? sanitize_text_field((string) wp_unslash($_REQUEST['auto_remarks'])) : '';
$created = \AKSRT\Payment\ChargeCard::get_instance()->generate_cards($pass_type, $auto_num, $pass_meta, $rand_number, $rand_password, $remarks);
$this->page_notice(__('完成!', 'aksrt'), sprintf(__('已自动生成%s个卡密', 'aksrt'), (string) $created));
}
private function handle_import(string $pass_type): void {
$import_data = isset($_REQUEST['import_data']) ? (string) wp_unslash($_REQUEST['import_data']) : '';
if (!$import_data) {
$this->page_notice(__('错误!', 'aksrt'), __('请粘贴您需要导入的数据', 'aksrt'), 'error');
return;
}
$import_data_array = explode("\r\n", $import_data);
$import_division = isset($_REQUEST['import_division']) ? (string) wp_unslash($_REQUEST['import_division']) : ' ';
if ($import_division === '') {
$import_division = ' ';
}
$success_i = 0;
$error_i = 0;
$svc = \AKSRT\Payment\ChargeCard::get_instance();
foreach ($import_data_array as $line) {
$v = explode($import_division, $line);
$pass_meta = [];
$_card = isset($v[0]) ? trim((string) $v[0]) : '';
$_pass = isset($v[1]) ? trim((string) $v[1]) : '';
$_other = isset($v[3]) ? trim((string) $v[3]) : '';
switch ($pass_type) {
case 'balance':
case 'credits':
$val = isset($v[2]) && $v[2] !== '' ? (float) $v[2] : 0;
if ($pass_type === 'credits') {
$pass_meta = $val ? ['points' => (int) $val] : [];
} else {
$pass_meta = $val ? ['amount' => number_format($val, 2, '.', '')] : [];
}
if (!$pass_meta) {
$error_i++;
continue 2;
}
break;
case 'vip':
$vip_pass_data = isset($v[2]) && $v[2] !== '' ? wp_parse_args(trim((string) $v[2])) : [];
if (!empty($vip_pass_data['level']) && !empty($vip_pass_data['time'])) {
$pass_meta = $this->vip_meta(is_array($vip_pass_data) ? $vip_pass_data : []);
}
if (!$pass_meta) {
$error_i++;
continue 2;
}
break;
case 'custom':
$pass_meta = [];
$only_pass_s = !empty($_REQUEST['only_pass_s']);
if ($only_pass_s) {
// 单密码模式:密码 标识 自定义参数
$_card = '';
$_pass = isset($v[0]) ? trim((string) $v[0]) : '';
$_other = isset($v[1]) ? trim((string) $v[1]) : '';
$custom_name = isset($v[2]) && $v[2] !== '' ? wp_parse_args(trim((string) $v[2])) : [];
} else {
$_other = isset($v[2]) ? trim((string) $v[2]) : '';
$custom_name = isset($v[3]) && $v[3] !== '' ? wp_parse_args(trim((string) $v[3])) : [];
}
if (!empty($custom_name['card_name'])) {
$pass_meta['custom_name']['card_name'] = trim(sanitize_text_field((string) $custom_name['card_name']));
}
if (!empty($custom_name['password_name'])) {
$pass_meta['custom_name']['password_name'] = trim(sanitize_text_field((string) $custom_name['password_name']));
}
break;
}
if ($_pass !== '') {
$id = $svc->create([
'card' => $_card,
'password' => $_pass,
'type' => $pass_type,
'meta' => $pass_meta,
'note' => $_other,
]);
if ($id > 0) {
$success_i++;
} else {
$error_i++;
}
} else {
$error_i++;
}
}
if ($success_i) {
$this->page_notice(
__('导入完成', 'aksrt'),
sprintf(__('成功导入%s个卡密%s', 'aksrt'), (string) $success_i, $error_i ? sprintf(__(',%s个导入失败', 'aksrt'), (string) $error_i) : '')
);
} else {
$this->page_notice(__('导入失败', 'aksrt'), sprintf(__('%s个导入失败,数据格式错误', 'aksrt'), (string) $error_i), 'error');
}
}
private function handle_delete(): void {
check_admin_referer(self::NONCE);
$delete_ids = isset($_REQUEST['action_id']) ? array_map('absint', (array) wp_unslash($_REQUEST['action_id'])) : [];
$delete_ids = array_filter($delete_ids);
if (!$delete_ids) {
$this->page_notice(__('错误!', 'aksrt'), __('未选择需要删除的内容', 'aksrt'), 'error');
return;
}
$delete_i = \AKSRT\Payment\ChargeCard::get_instance()->delete_by_ids($delete_ids);
$this->page_notice(__('删除完成', 'aksrt'), sprintf(__('已删除%s个卡密', 'aksrt'), (string) $delete_i));
}
/* ------------------------------------------------------------------
* 渲染辅助
* ------------------------------------------------------------------ */
/**
* @param array<int,array{key:string,op:string,val:string}> $deps
*/
private function field_row(string $title, string $html, array $deps = [], string $desc = ''): void {
$dep_attr = $deps ? ' data-dep="' . esc_attr((string) wp_json_encode($deps)) . '"' : '';
$hidden = ($deps && !$this->deps_visible($deps)) ? ' style="display:none;"' : '';
echo '<tr' . $dep_attr . $hidden . '><th scope="row">' . esc_html($title) . '</th><td>' . $html; // phpcs:ignore WordPress.Security.EscapeOutput -- 内部构造,已转义。
if ($desc !== '') {
echo '<p class="description">' . wp_kses_post($desc) . '</p>';
}
echo '</td></tr>';
}
/**
* @param array<int,array{key:string,op:string,val:string}> $deps
*/
private function submessage_row(array $deps, string $content): void {
$dep_attr = ' data-dep="' . esc_attr((string) wp_json_encode($deps)) . '"';
$hidden = $this->deps_visible($deps) ? '' : ' style="display:none;"';
// 提示块**不要**挂 WP 的 .notice / .notice-warning:wp-admin/js/common.js 在 DOM
// 就绪时会执行
// $('div.updated, div.error, div.notice').not('.inline, .below-h2').insertAfter($headerEnd)
// 把页面上所有「非 inline」的 notice 整块搬到 .wp-header-end(不存在时取 .wrap 内首个
// h1/h2)之后。提示块一旦带上 notice,就会被搬到页面顶部、脱离「添加方式—卡密数据」
// 之间的字段区,而且搬迁发生在求值之后,行内的显隐逻辑无从纠正。
// 外观改由 .aksrt-submsg 自带(见 print_assets),与参考主题 CSF content 字段一致。
// data-dep 与初始隐藏状态同时挂在 <tr> 和内层提示块上:提示块若脱离所在行,仍能自行显隐。
echo '<tr' . $dep_attr . $hidden . '><td colspan="2"><div' . $dep_attr . ' class="aksrt-submsg"' . $hidden . '>' . $content . '</div></td></tr>'; // phpcs:ignore WordPress.Security.EscapeOutput -- 文案已转义。
}
/**
* 服务端按表单默认值预判依赖可见性(求值规则与行内 JS 完全一致)。
* 仅在表单渲染默认值(添加方式=系统自动生成、类型=余额)下,把不满足依赖的行标记为初始隐藏,
* 避免四条导入提示首帧铺开。
*
* 注意:已知键必须覆盖 field_row/submessage_row 用到的所有依赖键,否则该键的判定会与 JS 不一致,
* 出现「服务端判可见、JS 判隐藏」之类的错配(例如自定义卡密的两条导入提示同时铺开)。
*
* @param array<int,array{key:string,op:string,val:string}> $deps
*/
private function deps_visible(array $deps): bool {
foreach ($deps as $dep) {
$key = (string) ($dep['key'] ?? '');
$val = (string) ($dep['val'] ?? '');
$op = (string) ($dep['op'] ?? '');
if (!$this->is_known_dep_key($key)) {
// 未登记的键:不做服务端判定,交 JS 处理。
continue;
}
$v = (string) $this->default_form_value($key);
switch ($op) {
case '==':
if ($v !== $val) {
return false;
}
break;
case '!=':
if ($v === $val) {
return false;
}
break;
case 'any':
if (!in_array($v, explode(',', $val), true)) {
return false;
}
break;
case 'checked':
if ($v === '') {
return false;
}
break;
case 'unchecked':
if ($v !== '') {
return false;
}
break;
}
}
return true;
}
/**
* 可在服务端求值的字段键(须完整覆盖表单依赖中使用的键,与 default_form_value 保持一致)。
*/
private function is_known_dep_key(string $key): bool {
static $known = [
'add_type',
'type',
'auto_top_s',
'only_pass_s',
'vip_exchange[time]',
'export_format',
'status',
];
return in_array($key, $known, true);
}
/**
* 表单字段的初始默认值(须与 field_row/button_set/spinner 渲染的默认值保持同步)。
*/
private function default_form_value(string $key): string {
switch ($key) {
case 'add_type':
return 'auto';
case 'type':
return 'balance';
case 'export_format':
return 'xls';
case 'status':
return 'all';
// 未勾选的 checkbox/switcher 与空文本,在依赖求值中均等价于空字符串。
case 'auto_top_s':
case 'only_pass_s':
case 'vip_exchange[time]':
return '';
default:
return '';
}
}
/**
* button_set:radio 按钮组(参考主题 CSF button_set 的原生替代)。
*
* @param array<string,string> $options
*/
private function button_set(string $name, array $options, string $current): string {
$html = '<div class="aksrt-btnset">';
foreach ($options as $value => $label) {
$html .= '<label class="aksrt-btnset-item"><input type="radio" name="' . esc_attr($name) . '" value="' . esc_attr((string) $value) . '"' . checked((string) $current, (string) $value, false) . '><span>' . esc_html((string) $label) . '</span></label>';
}
return $html . '</div>';
}
private function spinner(string $name, int $value, int $min, int $max, int $step, string $unit): string {
return '<input type="number" class="small-text" name="' . esc_attr($name) . '" value="' . esc_attr((string) $value) . '" min="' . esc_attr((string) $min) . '" max="' . esc_attr((string) $max) . '" step="' . esc_attr((string) $step) . '" /> <span class="description">' . esc_html($unit) . '</span>';
}
private function page_notice(string $title, string $msg, string $type = 'success'): void {
$html = '';
$html .= $title ? '<h3>' . esc_html($title) . '</h3>' : '';
$html .= $msg ? '<p>' . esc_html($msg) . '</p>' : '';
if ($html) {
echo '<div class="notice notice-' . esc_attr($type) . '">' . $html . '</div>'; // phpcs:ignore WordPress.Security.EscapeOutput
}
}
/**
* 列表筛选参数(不含 paged / page),用于构造翻页链接与「每页显示」表单。
* 显式列条件而不是依赖 add_query_arg() 默认取 $_SERVER['REQUEST_URI']:
* 后者把链接绑到当前请求上,一旦请求形态变化(POST、代理改写)就会丢条件。
*
* @return array<string,string>
*/
private function list_query_args(string $status, string $type, string $other, string $s, string $orderby, string $order, int $per_page): array {
$args = [];
if ($status !== '') {
$args['status'] = $status;
}
if ($type !== '') {
$args['type'] = $type;
}
if ($other !== '') {
$args['other'] = $other;
}
if ($s !== '') {
$args['s'] = $s;
}
if ($orderby !== '' && $orderby !== 'modified_time') {
$args['orderby'] = $orderby;
}
if ($order === 'ASC') {
$args['desc'] = 'ASC';
}
if ($per_page !== self::DEFAULT_PER_PAGE) {
$args['ice_perpage'] = (string) $per_page;
}
return $args;
}
/**
* 分页(参考主题 zibpay_admin_pagenavi 同款结构与禁用逻辑)。
*
* 与参考实现的两处差异,均为缺陷修复:
* 1) 页码先夹到 [1, total_pages]。历史写法在此处会出现「第 2 页 / 共 1 页」这类越界状态,
* 四个分支一个都不命中,上一页/下一页同时停在 '#',按钮既不禁用也点不动。
* 2) 链接基于显式基准 URL(page_url + 当前筛选条件),不再依赖 REQUEST_URI。
*
* @param array<string,string> $query_args 当前筛选参数(不含 paged)。
*/
private function render_pagenavi(int $total_count, int $number_per_page, array $query_args = []): void {
if ($total_count <= 0 || $number_per_page <= 0) {
return;
}
$total_pages = max(1, (int) ceil($total_count / $number_per_page));
$current_page = min(max(1, (int) ($_GET['paged'] ?? 1)), $total_pages);
$base = $query_args ? add_query_arg($query_args, $this->page_url) : $this->page_url;
$page_url = static function (int $page) use ($base): string {
return add_query_arg('paged', $page, $base);
};
$on_first = $current_page <= 1;
$on_last = $current_page >= $total_pages;
$first_page_url = $on_first ? '#' : $page_url(1);
$prev_page_url = $on_first ? '#' : $page_url($current_page - 1);
$next_page_url = $on_last ? '#' : $page_url($current_page + 1);
$last_page_url = $on_last ? '#' : $page_url($total_pages);
?>
<div class="tablenav bottom">
<div class="tablenav-pages">
<span class="displaying-num"><?php printf(esc_html__('每页 %1$s 共%2$s', 'aksrt'), esc_html((string) $number_per_page), esc_html((string) $total_count)); ?></span>
<span class="pagination-links">
<a class="first-page button<?php echo $on_first ? ' disabled' : ''; ?>" title="<?php esc_attr_e('前往第一页', 'aksrt'); ?>" href="<?php echo esc_url($first_page_url); ?>">«</a>
<a class="prev-page button<?php echo $on_first ? ' disabled' : ''; ?>" title="<?php esc_attr_e('前往上一页', 'aksrt'); ?>" href="<?php echo esc_url($prev_page_url); ?>">‹</a>
<span class="paging-input"><?php printf(esc_html__('第 %1$s 页,共 %2$s 页', 'aksrt'), esc_html((string) $current_page), '<span class="total-pages">' . esc_html((string) $total_pages) . '</span>'); ?></span>
<a class="next-page button<?php echo $on_last ? ' disabled' : ''; ?>" title="<?php esc_attr_e('前往下一页', 'aksrt'); ?>" href="<?php echo esc_url($next_page_url); ?>">›</a>
<a class="last-page button<?php echo $on_last ? ' disabled' : ''; ?>" title="<?php esc_attr_e('前往最后一页', 'aksrt'); ?>" href="<?php echo esc_url($last_page_url); ?>">»</a>
</span>
</div>
</div>
<?php
}
/** 行内样式与脚本:依赖显隐 + 点击复制。 */
private function print_assets(): void {
?>
<style>
.order-header { display: flex; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; margin-top: 8px; }
.order-header .subsubsub { margin: 6px 0; }
/* 禁用的翻页按钮已经无法跳转,但 <a href="#"> 仍会把页面滚回顶部;
这里直接让它们不可点击,避免「点了没反应还跳回页首」的错觉。 */
.tablenav-pages a.disabled { pointer-events: none; }
/* 排序箭头对齐:宿主做成零尺寸 inline-block —— 空 inline-block 的基线即其下边缘,
会与文字基线对齐,于是箭头可用 bottom 精确锚定到「基线以上 N em」。
箭头盒中心等于 bottom 值(translateY(50%) 抵消自身高度,与盒子高度无关):
两枚中心分别落在基线以上 .725em / .075em,对称于文字视觉中心(约 .4em)。
不要改回「无尺寸 inline 宿主 + 依赖静态位置 + 固定 -3px/+6px」的写法:
静态位置随表头字体/字号变化,会让整组箭头相对文字偏下(历史缺陷,已量测确认偏下 2px)。 */
.orderby-but { display: inline-block; position: relative; width: 0; height: 0; }
.orderby-but > a { opacity: .4; position: absolute; left: 0; bottom: .725em; transform: translateY(50%); transition: opacity .3s; padding-right: 1em; }
.orderby-but > a + a { bottom: .075em; }
.orderby-but:hover a { opacity: .6; }
.orderby-but > a:hover { opacity: 1; }
.aksrt-btnset { display: flex; flex-wrap: wrap; gap: 6px; }
.aksrt-btnset-item { display: inline-block; cursor: pointer; }
.aksrt-btnset-item input { position: absolute; opacity: 0; pointer-events: none; }
.aksrt-btnset-item span { display: inline-block; padding: 3px 12px; border: 1px solid #c3c4c7; border-radius: 3px; background: #fff; }
.aksrt-btnset-item input:checked + span { background: #2271b1; border-color: #2271b1; color: #fff; }
/* 导入提示:复刻后台 notice-warning 的外观,但**不用** .notice 类 —— 见 submessage_row()
的说明,带 .notice 会被 wp-admin/js/common.js 搬到页面顶部。改这里请连带改那里。 */
.aksrt-submsg { margin: 6px 0; padding: 6px 12px; background: #fef8ee; border-left: 4px solid #f0b849; }
/* 提示内的条目列表必须有 <ul> 承载:list-style-position 默认 outside,
圆点落在内容盒左侧的 padding 区里;若 <li> 没有列表容器提供缩进,
圆点就会溢出到提示块的文字边界之外。这里显式给出缩进,避免依赖 UA/后台的 ul 默认值。 */
.aksrt-submsg-list { margin: 6px 0; padding-left: 20px; list-style: disc; }
.aksrt-copy-tip { margin-left: 6px; color: #00a32a; font-size: 12px; white-space: nowrap; }
</style>
<script>
(function () {
// 按 name 属性逐元素匹配,避免把字段名拼进 CSS 选择器:
// 含方括号的字段名(如 vip_exchange[time])会让选择器抛异常并被 catch 吞掉,
// 从而把依赖行永久留在初始状态。
function fieldsByName(key) {
var all = document.querySelectorAll('.aksrt-cardpass-form [name]');
var out = [];
for (var i = 0; i < all.length; i++) {
if (all[i].getAttribute('name') === key) out.push(all[i]);
}
return out;
}
function formVal(key) {
var els = fieldsByName(key);
if (!els.length) return '';
if (els[0].type === 'radio') {
for (var i = 0; i < els.length; i++) {
if (els[i].checked) return els[i].value;
}
return '';
}
if (els[0].type === 'checkbox') return els[0].checked ? '1' : '';
return els[0].value;
}
function evalDeps() {
// 全文档查找 data-dep 节点:行与提示块各自带状态、各自落 display。
// 刻意不做限域(不写 .aksrt-cardpass-form 前缀):提示块一旦被 HTML 解析
// 移出表格甚至移出表单子树,限域查找会让它停在初始隐藏状态,表现为「提示不见了」。
document.querySelectorAll('[data-dep]').forEach(function (el) {
try {
var deps = JSON.parse(el.getAttribute('data-dep'));
var show = deps.every(function (d) {
var v = formVal(d.key);
switch (d.op) {
case '==': return String(v) === String(d.val);
case '!=': return String(v) !== String(d.val);
case 'any': return String(d.val).split(',').indexOf(String(v)) !== -1;
case 'checked': return !!v;
case 'unchecked': return !v;
}
return true;
});
el.style.display = show ? '' : 'none';
} catch (e) { /* noop */ }
});
}
// 任何 change 都重新求值:不做「是否属于本表单」的限定,
// 因为提示块的祖先链在这种页面上并不保证还挂在表单内。
document.addEventListener('change', evalDeps);
// 唯一的初始求值入口:本脚本在表单之前输出,此刻解析尚未到达表单,
// 直接求值必然取不到任何行(空操作);因此必须等 DOM 就绪后按默认值校正一次。
// 服务端已按默认值预隐藏,这里只做兜底校正。
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', evalDeps);
} else {
evalDeps();
}
document.addEventListener('click', function (e) {
var el = e.target.closest('[data-clipboard-text]');
if (!el) return;
var text = el.getAttribute('data-clipboard-text') || '';
if (!text) return;
var done = function () {
var tip = document.createElement('span');
tip.className = 'aksrt-copy-tip';
var tag = el.getAttribute('data-clipboard-tag') || '';
tip.textContent = (tag ? tag + ' ' : '') + '<?php echo esc_js(__('已复制', 'aksrt')); ?>';
el.appendChild(tip);
setTimeout(function () { tip.remove(); }, 1200);
};
var legacy = function () {
var ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); } catch (err) { /* noop */ }
document.body.removeChild(ta);
done();
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done, legacy);
} else {
legacy();
}
});
})();
</script>
<?php
}
/* ------------------------------------------------------------------
* 数据辅助
* ------------------------------------------------------------------ */
/**
* 卡密类型(key 即本主题内部类型,与参考主题语义一一对应)。
*
* @return array<string,string>
*/
private function type_labels(): array {
return [
'balance' => __('余额充值', 'aksrt'),
'vip' => __('会员兑换', 'aksrt'),
'credits' => __('积分兑换', 'aksrt'),
'custom' => __('自定义卡密', 'aksrt'),
];
}
private function vip_level_name(int $level): string {
$s = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : [];
$name = trim((string) ($s['pay_user_vip_' . max(1, min(2, $level)) . '_name'] ?? ''));
if ($name !== '') {
return $name;
}
return $level === 2 ? __('二级会员', 'aksrt') : __('一级会员', 'aksrt');
}
/**
* VIP 卡展示文本:Permanent 为永久;day 为天;month 为个月。
*
* @param array<string,mixed> $meta
*/
private function vip_time_text(array $meta): string {
$time = array_key_exists('time', $meta) ? (string) $meta['time'] : null;
if ($time !== null && $time !== '') {
if ($time === 'Permanent') {
return __('永久', 'aksrt');
}
$unit = (isset($meta['unit']) && $meta['unit'] === 'month') ? __('个月', 'aksrt') : __('天', 'aksrt');
return (string) ((int) $time) . $unit;
}
$days = (int) ($meta['days'] ?? 0);
return $days > 0 ? $days . __('天', 'aksrt') : __('永久', 'aksrt');
}
/**
* VIP 卡 meta(内部统一保存 level/days + 参考主题的 time/unit)。
*
* @param array<string,mixed> $vip
* @return array<string,mixed>
*/
private function vip_meta(array $vip): array {
$level = max(1, min(2, (int) ($vip['level'] ?? 1)));
$time_raw = trim((string) ($vip['time'] ?? ''));
$unit = (isset($vip['unit']) && $vip['unit'] === 'month') ? 'month' : 'day';
if ($time_raw === 'Permanent') {
$days = 0;
} else {
$time = (int) $time_raw;
$days = $unit === 'month' ? $time * 30 : $time;
}
return [
'level' => $level,
'days' => max(0, $days),
'time' => $time_raw,
'unit' => $unit,
];
}
/**
* VIP 卡导出参数串(与参考主题导出格式一致)。
*
* @param array<string,mixed> $meta
*/
private function vip_meta_query(array $meta): string {
if (isset($meta['time']) && (string) $meta['time'] !== '') {
return build_query([
'level' => (int) ($meta['level'] ?? 1),
'time' => (string) $meta['time'],
'unit' => (isset($meta['unit']) && $meta['unit'] === 'month') ? 'month' : 'day',
]);
}
$days = (int) ($meta['days'] ?? 0);
return build_query([
'level' => (int) ($meta['level'] ?? 1),
'time' => $days > 0 ? (string) $days : 'Permanent',
'unit' => 'day',
]);
}
/**
* 余额卡面额展示(参考主题为数值文本,不追加单位)。
*
* @param array<string,mixed> $meta
*/
private function balance_amount_text(array $meta): string {
$amount = (string) ($meta['amount'] ?? '');
if ($amount === '') {
return '0';
}
return (string) (float) $amount;
}
private function currency_unit(): string {
$s = class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_all() : [];
return trim((string) ($s['pay_currency_unit'] ?? '')) !== '' ? trim((string) $s['pay_currency_unit']) : __('元', 'aksrt');
}
private function ensure_cap(): void {
if (!current_user_can(self::CAPABILITY)) {
wp_die(esc_html__('您没有权限访问此页面。', 'aksrt'));
}
}
}