Files
wordpress-theme-aksrt/src/Admin/Native/PayChargeCards.php
T
kate522 7b83893cf6 feat(admin): 支付中心列表页统一为 WP 原生分页
七个支付中心列表页(订单明细 / 售后服务 / 付费文章 / 优惠码 / 创作分成 /
提现审核 / 卡密管理)的分页收敛到共享助手 AdminListPagination,改用 WP 原生
paginate_links() 渲染。

- 基准 URL 显式以 page 参数 + 当前筛选参数拼装,不再让两个参数的
  add_query_arg('paged', ...) 退回 $_SERVER['REQUEST_URI']:链接不再随当前
  请求形态漂移,列条件也不再丢失
- 售后服务从写死的 limit => 100 改为 limit/offset 分页,总数接上原本零调用的
  AfterSaleService::count();after_sale_status 先按状态常量归一,保证 count()
  与 admin_list() 口径一致,分页条不说谎
- 每页 20 条,统一来自 AdminListPagination::DEFAULT_PER_PAGE
- 每页在查询前夹取页码,越界时回退到最后一页并重查,杜绝「空表且没有分页条」
  的死胡同;页码夹取与早退也一并收敛到助手
- 提现审核的翻页链接排除 retry_id / retry_key 两个一次性动作参数
- 订单明细的标签页与翻页链接共用同一份筛选参数,不再各写一份

清理死代码:五个页面里近乎复制的 render_pagination()(其中订单明细与提现审核
两份从未被调用)、两页重复的内联 paginate_links 块、卡密管理的 render_pagenavi()
及其 docblock、PayChargeCards::DEFAULT_PER_PAGE 常量,以及只服务自定义禁用
按钮的 .tablenav-pages a.disabled 规则。

验证:tests/admin/ 76 项通过(新增 pay-pages-pagination.test.cjs,重写
charge-cards-pagination.test.cjs);用 git show HEAD 取出原文件对比,订单明细
与售后服务两页在新旧实现下渲染输出逐字节相同。
2026-10-08 22:22:08 +08:00

1304 lines
68 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';
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));
// 每页条数默认值取自共享助手;ice_perpage 仅作为 URL 参数被尊重,无界面控件。
$per_page = max(1, min(100, (int) ($_REQUEST['ice_perpage'] ?? AdminListPagination::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>';
AdminListPagination::render(self::PAGE_SLUG, $count_all, $paged, $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, __('张', 'aksrt')),
[['key' => 'add_type', 'op' => '==', 'val' => 'auto']],
__('需要生成多少张卡密(1 - 1000 的整数,单次生成数量太多可能会对服务器性能造成影响)', '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="any" 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, __('位数', 'aksrt')),
[
['key' => 'add_type', 'op' => '==', 'val' => 'auto'],
['key' => 'auto_top_s', 'op' => 'checked'],
]
);
$this->field_row(
__('自定义密码位数', 'aksrt'),
$this->spinner('auto_rand_password_limit', 35, __('位数', '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;
}
// 自动生成。数量限定为 (0, 1000] 的整数:非数字 / 空值经 (int) 转换归零,负数与超上限
// 一并落在区间外,因此用一个区间判定即可覆盖全部非法输入,不另设分支。
$auto_num = isset($_REQUEST['auto_num']) ? (int) $_REQUEST['auto_num'] : 0;
if ($auto_num < 1 || $auto_num > 1000) {
$this->page_notice(__('错误!', 'aksrt'), __('生成数量需为 1 - 1000 的整数', '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;
// 面额不接受负数。数字输入不再写原生 min(见 spinner() 注释:原生约束会让条件隐藏行
// 阻止表单提交),因此下限由服务端兜底。
$auto_price = max(0.0, $auto_price);
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>';
}
/**
* 数字输入(参考主题 CSF spinner / number 字段的原生替代)。
*
* **一律不写原生 min / max / step 约束属性,只写 `step="any"`:**
* - 本表单的行是条件显隐的(`field_row()` 会给不满足依赖的 `<tr>` 打上 `display:none`)。
* HTML5 约束校验会检查**隐藏**控件,而隐藏控件无法聚焦、浏览器弹不出提示气泡,
* 于是直接**静默拒绝提交**(控制台只有一句 "An invalid form control ... is not focusable"),
* 页面毫无反应 —— 用户看到的就是「确认提交按钮不起作用」。
* - `step="any"` 不能省:`type="number"` 缺省 `step=1`,会退回整数网格,把非整数输入判为非法,
* 同样触发上面的静默拒绝。
*
* 取值范围不在标记里表达,由服务端单点约束:生成数量见 `handle_add()` 的 (0, 1000] 判定,
* 卡号 / 密码位数见 `ChargeCard::generate_cards()`。
*/
private function spinner(string $name, int $value, string $unit): string {
return '<input type="number" class="small-text" step="any" name="' . esc_attr($name) . '" value="' . esc_attr((string) $value) . '" />'
. ' <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 !== AdminListPagination::DEFAULT_PER_PAGE) {
$args['ice_perpage'] = (string) $per_page;
}
return $args;
}
/** 行内样式与脚本:依赖显隐 + 点击复制。 */
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; }
/* 排序箭头对齐:宿主做成零尺寸 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'));
}
}
}