diff --git a/assets/css/admin/income.css b/assets/css/admin/income.css index 5d62ba5f..1a90026f 100644 --- a/assets/css/admin/income.css +++ b/assets/css/admin/income.css @@ -12,11 +12,12 @@ .aksrt-income-page .aksrt-income-filters__id { width: 96px; } .aksrt-income-filters__submit { display: inline-flex; gap: 8px; } -/* ---------- 批量结算 ---------- */ -.aksrt-income-batch { margin: 0; } -.aksrt-income-batch__count { color: var(--an-muted); font-size: 12px; } -.aksrt-income-batch__count.is-active { color: var(--an-text); font-weight: 500; } -.aksrt-income-page .aksrt-income-batch .button:disabled { opacity: .6; cursor: not-allowed; } +/* ---------- 批量操作 ---------- */ +.aksrt-income-bulk { margin: 0; } +.aksrt-income-bulk__count { color: var(--an-muted); font-size: 12px; font-variant-numeric: tabular-nums; } +.aksrt-income-bulk__count.is-active { color: var(--an-text); font-weight: 500; } +.aksrt-income-page .aksrt-income-bulk .button:disabled { opacity: .6; cursor: not-allowed; } +.aksrt-income-table .check-column input:disabled { cursor: not-allowed; opacity: .45; } /* ---------- 分成表 ---------- */ .aksrt-income-table { min-width: 1040px; table-layout: fixed; } @@ -25,8 +26,8 @@ .aksrt-income-table .column-source { width: 24%; } .aksrt-income-table .column-base { width: 14%; } .aksrt-income-table .column-amount { width: 12%; } -.aksrt-income-table .column-status { width: 13%; } -.aksrt-income-table .column-actions { width: 9%; } +.aksrt-income-table .column-status { width: 12%; } +.aksrt-income-table .column-actions { width: 13%; } .aksrt-income-table td, .aksrt-income-table tbody th { vertical-align: top; } .aksrt-income-id { color: var(--an-text); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; font-weight: 600; } @@ -40,7 +41,8 @@ a.aksrt-income-post:focus { color: var(--an-primary); } .aksrt-income-table .column-base, .aksrt-income-table .column-amount { font-variant-numeric: tabular-nums; } -/* 行内结算:单按钮 POST 表单 */ +/* 行内操作:每个按钮是一个独立 POST 表单 */ +.aksrt-income-actions { display: flex; flex-wrap: wrap; gap: 6px; } .aksrt-income-action { display: inline-flex; margin: 0; } .aksrt-income-action .button[disabled] { opacity: .6; cursor: progress; } diff --git a/assets/js/admin-income.js b/assets/js/admin-income.js index b657dc1c..4b6ae5e2 100644 --- a/assets/js/admin-income.js +++ b/assets/js/admin-income.js @@ -1,4 +1,4 @@ -/** 创作分成:批量勾选计数、全选、结算二次确认 + 防重复提交。仅本页入队。 */ +/** 创作分成:勾选计数、全选、批量操作(结算 / 删除)、二次确认 + 防重复提交。仅本页入队。 */ (function () { 'use strict'; @@ -7,7 +7,8 @@ var checkAll = root.querySelector('[data-aksrt-income-check-all]'); var checks = Array.from(root.querySelectorAll('[data-aksrt-income-check]')); - var batchSubmit = root.querySelector('[data-aksrt-income-batch-submit]'); + var bulkAction = root.querySelector('[data-aksrt-income-bulk-action]'); + var bulkSubmit = root.querySelector('[data-aksrt-income-bulk-submit]'); var selected = root.querySelector('[data-aksrt-income-selected]'); var emptyText = selected ? selected.textContent : ''; @@ -17,12 +18,12 @@ function sync() { var count = checkedCount(); - if (batchSubmit) batchSubmit.disabled = count === 0; + if (bulkSubmit) bulkSubmit.disabled = count === 0 || !bulkAction || bulkAction.value === ''; if (selected) { selected.textContent = count > 0 ? '已选 ' + count + ' 条' : emptyText; selected.classList.toggle('is-active', count > 0); } - if (checkAll) { + if (checkAll && !checkAll.disabled) { checkAll.checked = count > 0 && count === checks.length; checkAll.indeterminate = count > 0 && count < checks.length; } @@ -35,8 +36,18 @@ }); } checks.forEach(function (box) { box.addEventListener('change', sync); }); + if (bulkAction) bulkAction.addEventListener('change', sync); sync(); + /** 单行表单的确认文案在 form 上;批量表单按所选操作取 option 上的文案。 */ + function confirmMessage(form) { + if (bulkAction && form.contains(bulkAction)) { + var option = bulkAction.selectedOptions[0]; + return option ? option.getAttribute('data-confirm') : ''; + } + return form.getAttribute('data-aksrt-confirm'); + } + root.addEventListener('submit', function (event) { var form = event.target.closest('form'); if (!form) return; @@ -44,7 +55,7 @@ event.preventDefault(); return; } - var message = form.getAttribute('data-aksrt-confirm'); + var message = confirmMessage(form); if (message && !window.confirm(message.replace('{count}', String(checkedCount())))) { event.preventDefault(); return; diff --git a/src/Admin/Native/PayIncome.php b/src/Admin/Native/PayIncome.php index c352d535..3ededb78 100644 --- a/src/Admin/Native/PayIncome.php +++ b/src/Admin/Native/PayIncome.php @@ -3,8 +3,8 @@ * 支付中心「创作分成」:分成记录列表、单条与批量结算。 * * 列表渲染在 PayIncomeList(flat sibling trait,PSR-4 要求与宿主类同目录);这里保留入口、 - * admin-post 处理器与共用的展示助手。页面样式在 assets/css/admin/income.css, - * 结算确认、全选与防重复提交在 assets/js/admin-income.js。 + * admin-post 处理器(结算 / 删除 / 批量操作)与共用的展示助手。页面样式在 assets/css/admin/income.css, + * 批量操作、二次确认、全选与防重复提交在 assets/js/admin-income.js。 * * @package AKSRT */ @@ -35,7 +35,8 @@ class PayIncome { } add_action('admin_enqueue_scripts', [$this, 'enqueue_assets']); add_action('admin_post_aksrt_pay_settle_income', [$this, 'handle_settle_income']); - add_action('admin_post_aksrt_pay_batch_settle_income', [$this, 'handle_batch_settle_income']); + add_action('admin_post_aksrt_pay_delete_income', [$this, 'handle_delete_income']); + add_action('admin_post_aksrt_pay_bulk_income', [$this, 'handle_bulk_income']); } public function enqueue_assets(): void { @@ -79,18 +80,43 @@ class PayIncome { $this->redirect_back(['aksrt_pay_msg' => $ok ? 'income_settled' : 'income_failed']); } - public function handle_batch_settle_income(): void { + /** 单条删除:只删待结算记录,已结算记录由 Revenue::delete_pending() 的状态条件挡住。 */ + public function handle_delete_income(): void { $this->ensure_cap(); - check_admin_referer('aksrt_pay_batch_settle_income'); + $id = isset($_POST['revenue_id']) ? (int) $_POST['revenue_id'] : 0; + check_admin_referer('aksrt_pay_delete_income_' . $id); + $deleted = $id > 0 ? \AKSRT\Payment\Revenue::get_instance()->delete_pending([$id]) : 0; + $this->redirect_back(['aksrt_pay_msg' => $deleted > 0 ? 'income_deleted' : 'income_delete_failed', 'done' => $deleted]); + } + + /** 批量操作:bulk_action = settle | delete,作用于勾选的记录。 */ + public function handle_bulk_income(): void { + $this->ensure_cap(); + check_admin_referer('aksrt_pay_bulk_income'); + + $action = isset($_POST['bulk_action']) ? sanitize_key((string) wp_unslash($_POST['bulk_action'])) : ''; $raw_ids = isset($_POST['revenue_ids']) ? wp_unslash($_POST['revenue_ids']) : []; $ids = is_array($raw_ids) ? array_values(array_unique(array_filter(array_map('intval', $raw_ids), static fn (int $id): bool => $id > 0))) : []; + if (!in_array($action, ['settle', 'delete'], true)) { + $this->redirect_back(['aksrt_pay_msg' => 'income_bulk_invalid']); + } if ($ids === []) { $this->redirect_back(['aksrt_pay_msg' => 'income_none_selected']); } + if ($action === 'delete') { + $deleted = \AKSRT\Payment\Revenue::get_instance()->delete_pending($ids); + $failed = count($ids) - $deleted; + $this->redirect_back([ + 'aksrt_pay_msg' => $deleted === 0 ? 'income_delete_failed' : ($failed > 0 ? 'income_delete_partial' : 'income_deleted'), + 'done' => $deleted, + 'failed' => $failed, + ]); + } + $operation_key = $this->request_operation_key('admin-revenue-settle-batch'); $result = $operation_key !== '' ? \AKSRT\Payment\Revenue::get_instance()->settle_batch($ids, get_current_user_id(), $operation_key) @@ -100,7 +126,7 @@ class PayIncome { $this->redirect_back([ 'aksrt_pay_msg' => $settled === 0 ? 'income_failed' : ($failed > 0 ? 'income_batch_partial' : 'income_batch_settled'), - 'settled' => $settled, + 'done' => $settled, 'failed' => $failed, ]); } @@ -124,7 +150,7 @@ class PayIncome { if (is_string($referer) && $referer !== '') { parse_str((string) wp_parse_url($referer, PHP_URL_QUERY), $parsed); if (($parsed['page'] ?? '') === self::PAGE_SLUG) { - unset($parsed['page'], $parsed['aksrt_pay_msg'], $parsed['settled'], $parsed['failed']); + unset($parsed['page'], $parsed['aksrt_pay_msg'], $parsed['done'], $parsed['failed']); $query = $parsed; } } @@ -179,7 +205,7 @@ class PayIncome { return; } $code = sanitize_key((string) wp_unslash($_GET['aksrt_pay_msg'])); - $settled = max(0, (int) ($_GET['settled'] ?? 0)); + $done = max(0, (int) ($_GET['done'] ?? 0)); $failed = max(0, (int) ($_GET['failed'] ?? 0)); // 码 => [文案, notice 类型] $map = [ @@ -187,16 +213,29 @@ class PayIncome { 'income_batch_settled' => [sprintf( /* translators: %d settled count */ __('已结算 %d 条分成记录。', 'aksrt'), - $settled + $done ), 'success'], 'income_batch_partial' => [sprintf( /* translators: 1 settled count 2 failed count */ __('已结算 %1$d 条,%2$d 条失败(记录状态可能已变化),可刷新后重试。', 'aksrt'), - $settled, + $done, $failed ), 'warning'], - 'income_none_selected' => [__('请先勾选要结算的待结算记录。', 'aksrt'), 'warning'], + 'income_none_selected' => [__('请先勾选待结算记录。', 'aksrt'), 'warning'], + 'income_bulk_invalid' => [__('请先选择批量操作。', 'aksrt'), 'warning'], 'income_failed' => [__('分成结算失败:记录可能已被结算,或分成功能未启用。', 'aksrt'), 'error'], + 'income_deleted' => [sprintf( + /* translators: %d deleted count */ + __('已删除 %d 条待结算记录。', 'aksrt'), + $done + ), 'success'], + 'income_delete_partial' => [sprintf( + /* translators: 1 deleted count 2 skipped count */ + __('已删除 %1$d 条,%2$d 条未删除(仅待结算记录可删除,可能已被结算)。', 'aksrt'), + $done, + $failed + ), 'warning'], + 'income_delete_failed' => [__('删除失败:仅待结算记录可删除,记录可能已被结算或不存在。', 'aksrt'), 'error'], ]; if (!isset($map[$code])) { return; diff --git a/src/Admin/Native/PayIncomeList.php b/src/Admin/Native/PayIncomeList.php index 7eb15cef..ddf855bf 100644 --- a/src/Admin/Native/PayIncomeList.php +++ b/src/Admin/Native/PayIncomeList.php @@ -61,17 +61,16 @@ trait PayIncomeList { $this->render_status_views($filters, $query_args, $counts); $this->render_filter_card($filters); - $can_settle = $policy['enabled']; - $has_pending = $can_settle && in_array('pending', array_column($items, 'status'), true); + $can_settle = $policy['enabled']; + $has_pending = in_array('pending', array_column($items, 'status'), true); - NativeUi::table_card_open(__('分成记录', 'aksrt'), $has_pending ? $this->batch_form_html() : ''); + // 复选框列与批量栏始终渲染;不可用时禁用并用 title 说明原因,而不是整块隐藏。 + NativeUi::table_card_open(__('分成记录', 'aksrt'), $this->bulk_form_html($can_settle, $has_pending)); echo ''; echo ''; - echo ''; + echo ''; foreach ([ 'record' => __('记录 / 产生时间', 'aksrt'), 'author' => __('作者', 'aksrt'), @@ -111,12 +110,11 @@ trait PayIncomeList { $pending = $status === 'pending'; echo ''; - echo ''; + // 已结算记录已入账到钱包,不可结算也不可删除:复选框禁用占位,保持列对齐。 + echo ''; echo ''; @@ -148,8 +146,11 @@ trait PayIncomeList { . $this->status_hint($row, $cycle_days) . ''; echo '
' . esc_html__('分成记录', 'aksrt') . '
'; - if ($has_pending) { - echo ''; - } - echo '' + . '
'; - if ($pending && $can_settle) { - echo ''; - } - echo '' + . '#' . esc_html((string) $id) . '' . '
' . esc_html((string) ($row['created_at'] ?? '')) . '
'; - if ($pending && $can_settle) { - echo $this->settle_form_html($id); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + if ($pending) { + echo '
' + . ($can_settle ? $this->settle_form_html($id) : '') + . $this->delete_form_html($id) + . '
'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } else { echo '—'; } @@ -300,18 +301,41 @@ trait PayIncomeList { return ['' => __('全部订单状态', 'aksrt')] + \AKSRT\Core\DomainModel::order_status_labels(); } - /** 批量结算表单只放提交按钮;表格里的复选框通过 form="aksrt-income-batch" 关联进来。 */ - private function batch_form_html(): string { - return '
' - . '' + /** + * 批量操作栏:已选计数 + 「批量操作」下拉 + 应用。表格里的复选框通过 form="aksrt-income-bulk" 关联进来。 + * 各操作的确认文案挂在 option 的 data-confirm 上,由 admin-income.js 按所选操作取用。 + */ + private function bulk_form_html(bool $can_settle, bool $has_pending): string { + $settle_attrs = $can_settle + ? ' data-confirm="' . esc_attr__('确认立即结算选中的 {count} 条分成?金额会转入作者钱包,不可撤销。', 'aksrt') . '"' + : ' disabled'; + $settle_label = $can_settle ? __('立即结算', 'aksrt') : __('立即结算(分成功能未启用)', 'aksrt'); + + return '' + . '' . $this->operation_key_field() - . wp_nonce_field('aksrt_pay_batch_settle_income', '_wpnonce', true, false) - . '' . esc_html__('未选择记录', 'aksrt') . '' - . '' + . wp_nonce_field('aksrt_pay_bulk_income', '_wpnonce', true, false) + . '' + . esc_html($has_pending ? __('未选择记录', 'aksrt') : __('本页没有待结算记录', 'aksrt')) . '' + . '' + . '' + . '' . '
'; } + private function delete_form_html(int $id): string { + return '
' + . '' + . '' + . wp_nonce_field('aksrt_pay_delete_income_' . $id, '_wpnonce', true, false) + . '
'; + } + private function settle_form_html(int $id): string { return '
' diff --git a/src/Payment/Revenue.php b/src/Payment/Revenue.php index 10964779..4ad2f3ae 100644 --- a/src/Payment/Revenue.php +++ b/src/Payment/Revenue.php @@ -171,6 +171,28 @@ class Revenue { return ['settled' => $settled, 'failed' => $failed]; } + /** + * Delete pending records. Settled records are never deleted: their amount is already + * credited to the wallet ledger, which references the record id. The status guard in + * the DELETE makes a concurrent settlement win (settle_one locks the row first). + * + * @param array $record_ids + * @return int Number of records deleted. + */ + public function delete_pending(array $record_ids): int { + global $wpdb; + $ids = array_slice(array_values(array_unique(array_filter(array_map('intval', $record_ids), static fn (int $id): bool => $id > 0))), 0, 100); + if (!$ids) { + return 0; + } + $placeholders = implode(', ', array_fill(0, count($ids), '%d')); + $deleted = $wpdb->query($wpdb->prepare( + "DELETE FROM {$this->table()} WHERE status = %s AND id IN ({$placeholders})", + array_merge(['pending'], $ids) + )); + return max(0, (int) $deleted); + } + /** * Settle all pending records whose rolling earning cycle has ended. *