diff --git a/docs/zibll-gap-matrix.md b/docs/zibll-gap-matrix.md index 472164bf..ff74df8d 100644 --- a/docs/zibll-gap-matrix.md +++ b/docs/zibll-gap-matrix.md @@ -99,8 +99,8 @@ Note: zibll「商品管理」`zibpay_product_page` is **paid article sales**, no | 2b Shipping/after-sale/coupons/income/rebate | done | | 3 users.php submenus | done | | 4 SPA strip | done (ops routes redirect to native) | -| 5+ behavior parity | in progress — shipping list create + order links; after-sale `s`/type filters + list create + merchant approve/reject/confirm + order links; order admin note (JSON-safe) + complete/refund/close quick actions | -| 5 remaining | coupons/cards UX depth; product/forum runtime field parity; pending-to-ship board; express timeline | +| 5+ behavior parity | in progress — shipping list create + order links; after-sale filters/actions; order note/quick actions; coupons field parity + batch/enable; charge-cards expires + code dump + search/labels | +| 5 remaining | product/forum runtime field parity; pending-to-ship board; express timeline; coupon product_scope UI | ## Dual-UI rule diff --git a/inc/classes/admin/native/class-aksrt-admin-pay-menus.php b/inc/classes/admin/native/class-aksrt-admin-pay-menus.php index 26d453e7..4d5b1e63 100644 --- a/inc/classes/admin/native/class-aksrt-admin-pay-menus.php +++ b/inc/classes/admin/native/class-aksrt-admin-pay-menus.php @@ -41,7 +41,9 @@ class AKSRT_Admin_Pay_Menus { add_action('admin_post_aksrt_pay_create_after_sale', [$this, 'handle_create_after_sale']); add_action('admin_post_aksrt_pay_after_sale_action', [$this, 'handle_after_sale_action']); add_action('admin_post_aksrt_pay_create_coupon', [$this, 'handle_create_coupon']); + add_action('admin_post_aksrt_pay_batch_coupons', [$this, 'handle_batch_coupons']); add_action('admin_post_aksrt_pay_disable_coupon', [$this, 'handle_disable_coupon']); + add_action('admin_post_aksrt_pay_enable_coupon', [$this, 'handle_enable_coupon']); add_action('admin_post_aksrt_pay_settle_income', [$this, 'handle_settle_income']); add_action('admin_post_aksrt_pay_settle_rebate', [$this, 'handle_settle_rebate']); add_action('admin_post_aksrt_pay_cancel_rebate', [$this, 'handle_cancel_rebate']); @@ -218,8 +220,11 @@ class AKSRT_Admin_Pay_Menus { 'after_sale_create_failed' => __('售后工单创建失败。', 'aksrt'), 'coupon_created' => __('优惠码已创建。', 'aksrt'), 'coupon_create_failed'=> __('优惠码创建失败(可能重复)。', 'aksrt'), + 'coupon_batch_ok' => __('优惠码批量生成完成。', 'aksrt'), + 'coupon_batch_failed' => __('优惠码批量生成失败。', 'aksrt'), 'coupon_disabled' => __('优惠码已禁用。', 'aksrt'), - 'coupon_failed' => __('优惠码禁用失败。', 'aksrt'), + 'coupon_enabled' => __('优惠码已启用。', 'aksrt'), + 'coupon_failed' => __('优惠码操作失败。', 'aksrt'), 'income_settled' => __('分成已结算。', 'aksrt'), 'income_failed' => __('分成结算失败。', 'aksrt'), 'rebate_settled' => __('返佣已结算。', 'aksrt'), @@ -786,10 +791,23 @@ class AKSRT_Admin_Pay_Menus { $status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : ''; $type = isset($_GET['type']) ? sanitize_key((string) wp_unslash($_GET['type'])) : ''; + $search = isset($_GET['s']) ? sanitize_text_field((string) wp_unslash($_GET['s'])) : ''; $page_num = max(1, (int) ($_GET['paged'] ?? 1)); $per_page = 20; $svc = AKSRT_Charge_Card::get_instance(); $stats = $svc->get_stats(); + $type_labels = [ + 'balance' => __('余额', 'aksrt'), + 'vip' => __('VIP', 'aksrt'), + 'credits' => __('积分', 'aksrt'), + 'points' => __('积分', 'aksrt'), + ]; + $status_labels = [ + 'active' => __('可用', 'aksrt'), + 'used' => __('已用', 'aksrt'), + 'disabled' => __('已禁用', 'aksrt'), + 'expired' => __('已过期', 'aksrt'), + ]; echo '

' . esc_html(sprintf( /* translators: 1: total 2: active 3: used */ @@ -814,6 +832,7 @@ class AKSRT_Admin_Pay_Menus { echo '

'; echo '

'; echo '

'; + echo '

'; echo '

'; submit_button(__('生成', 'aksrt'), 'primary', '', false); echo ''; @@ -827,6 +846,14 @@ class AKSRT_Admin_Pay_Menus { )) . '

'; } + $generated_codes = get_transient('aksrt_pay_card_codes_' . get_current_user_id()); + if (is_array($generated_codes) && $generated_codes) { + delete_transient('aksrt_pay_card_codes_' . get_current_user_id()); + echo '

' . esc_html__('本次生成卡密(请及时复制保存):', 'aksrt') . '

'; + echo ''; + echo '
'; + } + echo '
'; echo ''; echo ' '; + echo ' '; submit_button(__('筛选', 'aksrt'), 'secondary', '', false); echo '
'; $result = $svc->get_list([ 'status' => $status, 'type' => $type, + 'search' => $search, 'limit' => $per_page, 'offset' => ($page_num - 1) * $per_page, ]); @@ -880,27 +909,30 @@ class AKSRT_Admin_Pay_Menus { echo '' . esc_html__('密码', 'aksrt') . ''; echo '' . esc_html__('面额', 'aksrt') . ''; echo '' . esc_html__('状态', 'aksrt') . ''; + echo '' . esc_html__('过期', 'aksrt') . ''; echo '' . esc_html__('使用者', 'aksrt') . ''; echo '' . esc_html__('创建时间', 'aksrt') . ''; echo '' . esc_html__('操作', 'aksrt') . ''; echo ''; if (!$items) { - echo '' . esc_html__('暂无卡密', 'aksrt') . ''; + echo '' . esc_html__('暂无卡密', 'aksrt') . ''; } foreach ($items as $row) { $id = (int) ($row['id'] ?? 0); $st = (string) ($row['status'] ?? ''); + $row_type = (string) ($row['type'] ?? ''); $user_id = (int) ($row['used_by'] ?? $row['user_id'] ?? 0); $user = $user_id > 0 ? get_userdata($user_id) : null; echo ''; echo '' . esc_html((string) $id) . ''; - echo '' . esc_html((string) ($row['type'] ?? '')) . ''; + echo '' . esc_html($type_labels[$row_type] ?? $row_type) . ''; echo '' . esc_html((string) ($row['password'] ?? $row['code'] ?? '')) . ''; echo '' . esc_html((string) ($row['amount'] ?? '')) . ''; - echo '' . esc_html($st) . ''; + echo '' . esc_html($status_labels[$st] ?? $st) . ''; + echo '' . esc_html((string) ($row['expires_at'] ?? '—')) . ''; echo '' . ($user ? esc_html($user->display_name) : '—') . ''; echo '' . esc_html((string) ($row['created_at'] ?? '')) . ''; echo ''; @@ -974,6 +1006,13 @@ class AKSRT_Admin_Pay_Menus { $note = isset($_POST['note']) ? sanitize_textarea_field((string) wp_unslash($_POST['note'])) : ''; $level = isset($_POST['vip_level']) ? max(1, min(2, (int) $_POST['vip_level'])) : 1; $days = isset($_POST['vip_days']) ? max(1, (int) $_POST['vip_days']) : 30; + $expires = isset($_POST['expires_at']) ? sanitize_text_field((string) wp_unslash($_POST['expires_at'])) : ''; + if ($expires !== '') { + $expires = str_replace('T', ' ', $expires); + if (strlen($expires) === 16) { + $expires .= ':00'; + } + } if ($type === 'points') { $type = 'credits'; @@ -989,8 +1028,11 @@ class AKSRT_Admin_Pay_Menus { $meta['days'] = $days; } - $codes = AKSRT_Charge_Card::get_instance()->batch_create($type, $meta, $count, $length, $prefix, '', $note); + $codes = AKSRT_Charge_Card::get_instance()->batch_create($type, $meta, $count, $length, $prefix, $expires, $note); $ok = !empty($codes); + if ($ok) { + set_transient('aksrt_pay_card_codes_' . get_current_user_id(), $codes, 10 * MINUTE_IN_SECONDS); + } wp_safe_redirect($this->page_url('aksrt_charge_cards', [ 'aksrt_pay_msg' => $ok ? 'card_batch_ok' : 'card_batch_failed', @@ -1415,6 +1457,14 @@ class AKSRT_Admin_Pay_Menus { $page_num = max(1, (int) ($_GET['paged'] ?? 1)); $per_page = 20; $svc = AKSRT_Coupon::get_instance(); + $type_labels = [ + 'fixed' => __('固定金额', 'aksrt'), + 'percent' => __('折扣百分比', 'aksrt'), + ]; + $status_labels = [ + 'active' => __('可用', 'aksrt'), + 'disabled' => __('已禁用', 'aksrt'), + ]; echo '

' . esc_html__('新建优惠码', 'aksrt') . '

'; echo '
'; @@ -1424,11 +1474,46 @@ class AKSRT_Admin_Pay_Menus { echo '

'; echo '

'; echo '

'; + echo '

'; echo '

'; + echo '

'; echo '

'; submit_button(__('创建', 'aksrt'), 'primary', '', false); echo '
'; + echo '

' . esc_html__('批量生成优惠码', 'aksrt') . '

'; + echo '
'; + echo ''; + wp_nonce_field('aksrt_pay_batch_coupons'); + echo '

'; + echo '

'; + echo '

'; + echo '

'; + echo '

'; + echo '

'; + echo '

'; + echo '

'; + echo '

'; + echo '

'; + submit_button(__('批量生成', 'aksrt'), 'secondary', '', false); + echo '
'; + + if (!empty($_GET['aksrt_coupon_generated'])) { + $generated = (int) $_GET['aksrt_coupon_generated']; + echo '

' . esc_html(sprintf( + /* translators: %d count */ + __('成功生成 %d 个优惠码。', 'aksrt'), + $generated + )) . '

'; + } + $generated_codes = get_transient('aksrt_pay_coupon_codes_' . get_current_user_id()); + if (is_array($generated_codes) && $generated_codes) { + delete_transient('aksrt_pay_coupon_codes_' . get_current_user_id()); + echo '

' . esc_html__('本次生成优惠码(请及时复制保存):', 'aksrt') . '

'; + echo ''; + echo '
'; + } + echo '
'; echo ''; echo ''; + echo ''; + wp_nonce_field('aksrt_pay_enable_coupon_' . $id); + submit_button(__('启用', 'aksrt'), 'small', '', false); + echo '
'; } else { echo '—'; } @@ -1495,15 +1589,20 @@ class AKSRT_Admin_Pay_Menus { if (class_exists('AKSRT_Coupon')) { $expires = isset($_POST['expires_at']) ? sanitize_text_field((string) wp_unslash($_POST['expires_at'])) : ''; if ($expires !== '') { - $expires = str_replace('T', ' ', $expires) . ':00'; + $expires = str_replace('T', ' ', $expires); + if (strlen($expires) === 16) { + $expires .= ':00'; + } } $id = AKSRT_Coupon::get_instance()->create([ - 'code' => sanitize_text_field((string) wp_unslash($_POST['code'] ?? '')), - 'type' => sanitize_key((string) wp_unslash($_POST['type'] ?? 'fixed')), - 'value' => (float) ($_POST['value'] ?? 0), - 'min_amount' => (float) ($_POST['min_amount'] ?? 0), - 'usage_limit' => (int) ($_POST['usage_limit'] ?? 0), - 'expires_at' => $expires, + 'code' => sanitize_text_field((string) wp_unslash($_POST['code'] ?? '')), + 'type' => sanitize_key((string) wp_unslash($_POST['type'] ?? 'fixed')), + 'value' => (float) ($_POST['value'] ?? 0), + 'min_amount' => (float) ($_POST['min_amount'] ?? 0), + 'max_discount' => (float) ($_POST['max_discount'] ?? 0), + 'usage_limit' => (int) ($_POST['usage_limit'] ?? 0), + 'per_user_limit' => (int) ($_POST['per_user_limit'] ?? 0), + 'expires_at' => $expires, ]); $ok = $id > 0; } @@ -1511,6 +1610,47 @@ class AKSRT_Admin_Pay_Menus { exit; } + public function handle_batch_coupons(): void { + if (!current_user_can(self::CAPABILITY)) { + wp_die(esc_html__('权限不足', 'aksrt')); + } + check_admin_referer('aksrt_pay_batch_coupons'); + + $ok = false; + $created = 0; + $codes = []; + if (class_exists('AKSRT_Coupon')) { + $expires = isset($_POST['expires_at']) ? sanitize_text_field((string) wp_unslash($_POST['expires_at'])) : ''; + if ($expires !== '') { + $expires = str_replace('T', ' ', $expires); + if (strlen($expires) === 16) { + $expires .= ':00'; + } + } + $result = AKSRT_Coupon::get_instance()->batch_create([ + 'type' => sanitize_key((string) wp_unslash($_POST['type'] ?? 'fixed')), + 'value' => (float) ($_POST['value'] ?? 0), + 'min_amount' => (float) ($_POST['min_amount'] ?? 0), + 'max_discount' => (float) ($_POST['max_discount'] ?? 0), + 'usage_limit' => (int) ($_POST['usage_limit'] ?? 0), + 'per_user_limit' => (int) ($_POST['per_user_limit'] ?? 0), + 'expires_at' => $expires, + ], (int) ($_POST['count'] ?? 10), (int) ($_POST['length'] ?? 10), sanitize_text_field((string) wp_unslash($_POST['prefix'] ?? ''))); + $created = (int) ($result['created'] ?? 0); + $codes = is_array($result['codes'] ?? null) ? $result['codes'] : []; + $ok = $created > 0; + if ($ok) { + set_transient('aksrt_pay_coupon_codes_' . get_current_user_id(), $codes, 10 * MINUTE_IN_SECONDS); + } + } + + wp_safe_redirect($this->page_url('aksrt_coupons', [ + 'aksrt_pay_msg' => $ok ? 'coupon_batch_ok' : 'coupon_batch_failed', + 'aksrt_coupon_generated' => $created, + ])); + exit; + } + public function handle_disable_coupon(): void { if (!current_user_can(self::CAPABILITY)) { wp_die(esc_html__('权限不足', 'aksrt')); @@ -1522,6 +1662,17 @@ class AKSRT_Admin_Pay_Menus { exit; } + public function handle_enable_coupon(): void { + if (!current_user_can(self::CAPABILITY)) { + wp_die(esc_html__('权限不足', 'aksrt')); + } + $id = isset($_POST['coupon_id']) ? (int) $_POST['coupon_id'] : 0; + check_admin_referer('aksrt_pay_enable_coupon_' . $id); + $ok = $id > 0 && class_exists('AKSRT_Coupon') && AKSRT_Coupon::get_instance()->enable($id); + wp_safe_redirect($this->page_url('aksrt_coupons', ['aksrt_pay_msg' => $ok ? 'coupon_enabled' : 'coupon_failed'])); + exit; + } + /* ------------------------------------------------------------------ * Income / rebate * ----------------------------------------------------------------*/ diff --git a/inc/classes/class-aksrt-charge-card.php b/inc/classes/class-aksrt-charge-card.php index e5d18643..e76105a9 100644 --- a/inc/classes/class-aksrt-charge-card.php +++ b/inc/classes/class-aksrt-charge-card.php @@ -612,6 +612,7 @@ class AKSRT_Charge_Card { $offset = max(0, (int) ($args['offset'] ?? 0)); $status = sanitize_key((string) ($args['status'] ?? '')); $type = sanitize_key((string) ($args['type'] ?? '')); + $search = sanitize_text_field((string) ($args['search'] ?? $args['s'] ?? '')); $where = []; $params = []; @@ -620,9 +621,20 @@ class AKSRT_Charge_Card { $params[] = $status; } if ($type !== '') { + if ($type === 'points') { + $type = self::TYPE_CREDITS; + } $where[] = 'type = %s'; $params[] = $type; } + if ($search !== '') { + $like = '%' . $wpdb->esc_like($search) . '%'; + $where[] = '(password LIKE %s OR card LIKE %s OR note LIKE %s OR CAST(id AS CHAR) = %s)'; + $params[] = $like; + $params[] = $like; + $params[] = $like; + $params[] = $search; + } $where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; $count_sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}"; diff --git a/inc/classes/class-aksrt-coupon.php b/inc/classes/class-aksrt-coupon.php index 589e2349..5b02d871 100644 --- a/inc/classes/class-aksrt-coupon.php +++ b/inc/classes/class-aksrt-coupon.php @@ -245,6 +245,42 @@ class AKSRT_Coupon { return $wpdb->update($this->table(), ['status' => 'disabled'], ['id' => $id], ['%s'], ['%d']) !== false; } + public function enable(int $id): bool { + global $wpdb; + return $wpdb->update($this->table(), ['status' => 'active'], ['id' => $id], ['%s'], ['%d']) !== false; + } + + /** + * Batch-create coupons with auto-generated codes. + * + * @return array{created:int,codes:array} + */ + public function batch_create(array $data, int $count = 1, int $length = 10, string $prefix = ''): array { + $count = max(1, min(100, $count)); + $length = max(6, min(24, $length)); + $prefix = sanitize_text_field($prefix); + $codes = []; + $created = 0; + + for ($i = 0; $i < $count; $i++) { + $code = $prefix !== '' + ? $prefix . strtoupper(wp_generate_password($length, false, false)) + : strtoupper(wp_generate_password($length, false, false)); + $payload = $data; + $payload['code'] = $code; + $id = $this->create($payload); + if ($id > 0) { + $created++; + $codes[] = $code; + } + } + + return [ + 'created' => $created, + 'codes' => $codes, + ]; + } + /** * Count how many times a user has used a specific coupon. */