diff --git a/docs/zibll-gap-matrix.md b/docs/zibll-gap-matrix.md index e931f61a..85b066f5 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 + pending-to-ship board + express timeline query; after-sale filters/actions; order note/quick actions; coupons field parity + batch/enable + product_scope; charge-cards expires + code dump + search/labels; product meta shipping/service/limit fields; plate allow_view/add_limit; topic visibility column | -| 5 remaining | deeper forum runtime (moderator roles UI, advanced plate options); coupon product_scope batch/edit | +| 5+ behavior parity | in progress — shipping list create + pending-to-ship board + express timeline query; after-sale filters/actions; order note/quick actions; coupons field parity + batch/enable + product_scope create/edit/batch (native module split); charge-cards expires + code dump + search/labels; product meta shipping/service/limit fields; plate allow_view/add_limit/follow/role + moderator roles UI; topic visibility column | +| 5 remaining | optional plate icon/sort native fields; further pay-menus modular split (shipping/charge-card still in shell); zibll pay-hide/points plate options out of scope | ## Dual-UI rule diff --git a/functions.php b/functions.php index 56694bc9..3e6d147c 100644 --- a/functions.php +++ b/functions.php @@ -356,6 +356,9 @@ if (class_exists('AKSRT_User_Credits_Admin')) { if (is_admin() && class_exists('AKSRT_Admin_Pay_Menus')) { AKSRT_Admin_Pay_Menus::get_instance()->init(); } +if (is_admin() && class_exists('AKSRT_Admin_Pay_Coupons')) { + AKSRT_Admin_Pay_Coupons::get_instance()->init(); +} // WordPress 原生用户菜单:消息/邀请码/认证/禁封(zibll users.php 子菜单对齐) if (is_admin() && class_exists('AKSRT_Admin_Users_Menus')) { diff --git a/inc/bootstrap.php b/inc/bootstrap.php index accb5d0f..2e2db616 100644 --- a/inc/bootstrap.php +++ b/inc/bootstrap.php @@ -173,6 +173,7 @@ require_once AKSRT_DIR . '/inc/classes/class-aksrt-update-checker.php'; require_once AKSRT_DIR . '/inc/classes/class-aksrt-category-rewrite.php'; require_once AKSRT_DIR . '/inc/classes/admin/class-aksrt-admin-message-controller.php'; require_once AKSRT_DIR . '/inc/classes/admin/native/class-aksrt-admin-pay-menus.php'; +require_once AKSRT_DIR . '/inc/classes/admin/native/class-aksrt-admin-pay-coupons.php'; require_once AKSRT_DIR . '/inc/classes/admin/native/class-aksrt-admin-users-menus.php'; require_once AKSRT_DIR . '/inc/classes/class-aksrt-admin.php'; diff --git a/inc/classes/admin/native/class-aksrt-admin-pay-coupons.php b/inc/classes/admin/native/class-aksrt-admin-pay-coupons.php new file mode 100644 index 00000000..93898e42 --- /dev/null +++ b/inc/classes/admin/native/class-aksrt-admin-pay-coupons.php @@ -0,0 +1,478 @@ +ensure_cap(); + $this->render_header(__('优惠码', 'aksrt')); + + if (!class_exists('AKSRT_Coupon')) { + echo '

' . esc_html__('优惠码模块未初始化。', 'aksrt') . '

'; + $this->render_footer(); + return; + } + + $svc = AKSRT_Coupon::get_instance(); + $edit_id = isset($_GET['edit_id']) ? (int) $_GET['edit_id'] : 0; + $edit_row = $edit_id > 0 ? $svc->get($edit_id) : null; + + if ($edit_id > 0 && $edit_row) { + $this->render_edit_form($edit_row); + } elseif ($edit_id > 0) { + echo '

' . esc_html__('优惠码不存在。', 'aksrt') . '

'; + } + + $this->render_create_form(); + $this->render_batch_form(); + $this->render_generated_dump(); + $this->render_list($svc); + $this->render_footer(); + } + + /** + * @param array $row + */ + private function render_edit_form(array $row): void { + $id = (int) ($row['id'] ?? 0); + $scope = is_array($row['product_scope'] ?? null) ? $row['product_scope'] : []; + $scope_type = sanitize_key((string) ($scope['type'] ?? '')); + $scope_ids = array_map('intval', (array) ($scope['ids'] ?? [])); + $expires = (string) ($row['expires_at'] ?? ''); + $expires_local = $expires !== '' ? str_replace(' ', 'T', substr($expires, 0, 16)) : ''; + + echo '

' . esc_html(sprintf( + /* translators: %d coupon id */ + __('编辑优惠码 #%d', 'aksrt'), + $id + )) . '

'; + echo '

← ' . esc_html__('返回列表', 'aksrt') . '

'; + echo '
'; + echo ''; + echo ''; + wp_nonce_field('aksrt_pay_update_coupon_' . $id); + echo '

'; + echo '

'; + echo '

'; + echo '

'; + echo '

'; + echo '

'; + echo '

'; + $this->render_scope_fields($scope_type, implode(',', $scope_ids)); + echo '

'; + submit_button(__('保存修改', 'aksrt'), 'primary', '', false); + echo '

'; + } + + private function render_create_form(): void { + echo '

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

'; + echo '
'; + echo ''; + wp_nonce_field('aksrt_pay_create_coupon'); + echo '

'; + echo '

'; + echo '

'; + echo '

'; + echo '

'; + echo '

'; + echo '

'; + $this->render_scope_fields('', ''); + echo '

'; + submit_button(__('创建', 'aksrt'), 'primary', '', false); + echo '
'; + } + + private function render_batch_form(): void { + echo '

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

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

'; + echo '

'; + echo '

'; + echo '

'; + echo '

'; + echo '

'; + echo '

'; + echo '

'; + echo '

'; + $this->render_scope_fields('', ''); + echo '

'; + submit_button(__('批量生成', 'aksrt'), 'secondary', '', false); + echo '
'; + } + + private function render_scope_fields(string $selected_type, string $ids_text): void { + echo '

'; + echo '

'; + } + + private function render_generated_dump(): void { + 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 '
'; + } + } + + private function render_list(AKSRT_Coupon $svc): void { + $status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : ''; + $search = isset($_GET['s']) ? sanitize_text_field((string) wp_unslash($_GET['s'])) : ''; + $page_num = max(1, (int) ($_GET['paged'] ?? 1)); + $per_page = 20; + $type_labels = [ + 'fixed' => __('固定金额', 'aksrt'), + 'percent' => __('折扣百分比', 'aksrt'), + ]; + $status_labels = [ + 'active' => __('可用', 'aksrt'), + 'disabled' => __('已禁用', 'aksrt'), + ]; + + echo '
'; + echo ''; + echo ' '; + echo ' '; + submit_button(__('筛选', 'aksrt'), 'secondary', '', false); + echo '
'; + + $result = $svc->get_list([ + 'status' => $status, + 'search' => $search, + 'limit' => $per_page, + 'offset' => ($page_num - 1) * $per_page, + ]); + $items = $result['items'] ?? []; + $pages = max(1, (int) ($result['total_pages'] ?? 1)); + + echo ''; + echo ''; + echo ''; + if (!$items) { + echo ''; + } + foreach ($items as $row) { + $id = (int) ($row['id'] ?? 0); + $st = (string) ($row['status'] ?? ''); + $row_type = (string) ($row['type'] ?? ''); + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + } + echo '
ID' . esc_html__('代码', 'aksrt') . '' . esc_html__('类型', 'aksrt') . '' . esc_html__('面值', 'aksrt') . '' . esc_html__('门槛/封顶', 'aksrt') . '' . esc_html__('范围', 'aksrt') . '' . esc_html__('已用/上限/每人', 'aksrt') . '' . esc_html__('状态', 'aksrt') . '' . esc_html__('过期', 'aksrt') . '' . esc_html__('操作', 'aksrt') . '
' . esc_html__('暂无优惠码', 'aksrt') . '
' . esc_html((string) $id) . '' . esc_html((string) ($row['code'] ?? '')) . '' . esc_html($type_labels[$row_type] ?? $row_type) . '' . esc_html((string) ($row['value'] ?? '')) . ($row_type === 'percent' ? '%' : '') . '' . esc_html((string) ($row['min_amount'] ?? 0)) . ' / ' . esc_html((string) ($row['max_discount'] ?? 0)) . '' . esc_html($this->format_scope_label($row['product_scope'] ?? [])) . '' . esc_html((string) ((int) ($row['used_count'] ?? 0)) . '/' . (string) ((int) ($row['usage_limit'] ?? 0)) . '/' . (string) ((int) ($row['per_user_limit'] ?? 0))) . '' . esc_html($status_labels[$st] ?? $st) . '' . esc_html((string) ($row['expires_at'] ?? '')) . ''; + echo '' . esc_html__('编辑', 'aksrt') . ''; + if ($st === 'active') { + echo '
'; + echo ''; + echo ''; + wp_nonce_field('aksrt_pay_disable_coupon_' . $id); + submit_button(__('禁用', 'aksrt'), 'small', '', false); + echo '
'; + } elseif ($st === 'disabled') { + echo '
'; + echo ''; + echo ''; + wp_nonce_field('aksrt_pay_enable_coupon_' . $id); + submit_button(__('启用', 'aksrt'), 'small', '', false); + echo '
'; + } + echo '
'; + $this->render_pagination($pages, $page_num); + } + + public function handle_create_coupon(): void { + if (!current_user_can(self::CAPABILITY)) { + wp_die(esc_html__('权限不足', 'aksrt')); + } + check_admin_referer('aksrt_pay_create_coupon'); + + $ok = false; + if (class_exists('AKSRT_Coupon')) { + $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), + 'max_discount' => (float) ($_POST['max_discount'] ?? 0), + 'usage_limit' => (int) ($_POST['usage_limit'] ?? 0), + 'per_user_limit' => (int) ($_POST['per_user_limit'] ?? 0), + 'product_scope' => $this->parse_product_scope_from_request(), + 'expires_at' => $this->normalize_expires_from_request(), + ]); + $ok = $id > 0; + } + wp_safe_redirect($this->page_url(['aksrt_pay_msg' => $ok ? 'coupon_created' : 'coupon_create_failed'])); + 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')) { + $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), + 'product_scope' => $this->parse_product_scope_from_request(), + 'expires_at' => $this->normalize_expires_from_request(), + ], (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_pay_msg' => $ok ? 'coupon_batch_ok' : 'coupon_batch_failed', + 'aksrt_coupon_generated' => $created, + ])); + exit; + } + + public function handle_update_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_update_coupon_' . $id); + + $ok = false; + if ($id > 0 && class_exists('AKSRT_Coupon')) { + $ok = AKSRT_Coupon::get_instance()->update($id, [ + '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), + 'product_scope' => $this->parse_product_scope_from_request(), + 'expires_at' => $this->normalize_expires_from_request(), + ]); + } + wp_safe_redirect($this->page_url([ + 'aksrt_pay_msg' => $ok ? 'coupon_updated' : 'coupon_update_failed', + 'edit_id' => $id, + ])); + exit; + } + + public function handle_disable_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_disable_coupon_' . $id); + $ok = $id > 0 && class_exists('AKSRT_Coupon') && AKSRT_Coupon::get_instance()->disable($id); + wp_safe_redirect($this->page_url(['aksrt_pay_msg' => $ok ? 'coupon_disabled' : 'coupon_failed'])); + 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_pay_msg' => $ok ? 'coupon_enabled' : 'coupon_failed'])); + exit; + } + + /** + * @return array + */ + private function parse_product_scope_from_request(): array { + $type = isset($_POST['scope_type']) ? sanitize_key((string) wp_unslash($_POST['scope_type'])) : ''; + if ($type === '' || !in_array($type, ['include', 'exclude', 'category'], true)) { + return []; + } + $raw = isset($_POST['scope_ids']) ? (string) wp_unslash($_POST['scope_ids']) : ''; + $parts = preg_split('/[\s,;]+/', $raw) ?: []; + $ids = array_values(array_unique(array_filter(array_map('intval', $parts)))); + if (!$ids) { + return []; + } + $scope = [ + 'type' => $type, + 'ids' => $ids, + ]; + if ($type === 'category') { + $scope['taxonomies'] = ['aksrt_product_cat', 'category']; + } + return $scope; + } + + private function normalize_expires_from_request(): string { + $expires = isset($_POST['expires_at']) ? sanitize_text_field((string) wp_unslash($_POST['expires_at'])) : ''; + if ($expires === '') { + return ''; + } + $expires = str_replace('T', ' ', $expires); + if (strlen($expires) === 16) { + $expires .= ':00'; + } + return $expires; + } + + /** + * @param mixed $scope + */ + private function format_scope_label($scope): string { + if (is_string($scope) && $scope !== '') { + $decoded = json_decode($scope, true); + $scope = is_array($decoded) ? $decoded : []; + } + if (!is_array($scope) || empty($scope)) { + return __('全部', 'aksrt'); + } + $type = sanitize_key((string) ($scope['type'] ?? '')); + $ids = array_map('intval', (array) ($scope['ids'] ?? [])); + $id_text = $ids ? implode(',', $ids) : '—'; + $map = [ + 'include' => __('含商品', 'aksrt'), + 'exclude' => __('排除商品', 'aksrt'), + 'category' => __('分类', 'aksrt'), + ]; + $label = $map[$type] ?? $type; + return $label . ':' . $id_text; + } + + private function ensure_cap(): void { + if (!current_user_can(self::CAPABILITY)) { + wp_die(esc_html__('您没有权限访问此页面。', 'aksrt')); + } + } + + private function render_header(string $title): void { + echo '
'; + echo '

' . esc_html($title) . '

'; + $this->render_flash(); + } + + private function render_footer(): void { + echo '
'; + } + + private function render_flash(): void { + if (empty($_GET['aksrt_pay_msg'])) { + return; + } + $code = sanitize_key((string) wp_unslash($_GET['aksrt_pay_msg'])); + $map = [ + 'coupon_created' => __('优惠码已创建。', 'aksrt'), + 'coupon_create_failed' => __('优惠码创建失败(可能重复)。', 'aksrt'), + 'coupon_batch_ok' => __('优惠码批量生成完成。', 'aksrt'), + 'coupon_batch_failed' => __('优惠码批量生成失败。', 'aksrt'), + 'coupon_updated' => __('优惠码已更新。', 'aksrt'), + 'coupon_update_failed' => __('优惠码更新失败。', 'aksrt'), + 'coupon_disabled' => __('优惠码已禁用。', 'aksrt'), + 'coupon_enabled' => __('优惠码已启用。', 'aksrt'), + 'coupon_failed' => __('优惠码操作失败。', 'aksrt'), + ]; + if (!isset($map[$code])) { + return; + } + $class = str_contains($code, 'failed') ? 'notice-error' : 'notice-success'; + echo '

' . esc_html($map[$code]) . '

'; + } + + /** + * @param array $args + */ + private function page_url(array $args = []): string { + return add_query_arg(array_merge(['page' => self::PAGE_SLUG], $args), admin_url('admin.php')); + } + + private function render_pagination(int $pages, int $page_num): void { + if ($pages <= 1) { + return; + } + echo '
'; + echo paginate_links([ + 'base' => add_query_arg('paged', '%#%'), + 'format' => '', + 'prev_text' => '«', + 'next_text' => '»', + 'total' => $pages, + 'current' => $page_num, + ]); + echo '
'; + } +} 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 ffd80883..c6d4e099 100644 --- a/inc/classes/admin/native/class-aksrt-admin-pay-menus.php +++ b/inc/classes/admin/native/class-aksrt-admin-pay-menus.php @@ -41,10 +41,7 @@ class AKSRT_Admin_Pay_Menus { add_action('admin_post_aksrt_pay_update_after_sale', [$this, 'handle_update_after_sale']); 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']); + // Coupon admin_post handlers live in AKSRT_Admin_Pay_Coupons. 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']); @@ -78,7 +75,13 @@ class AKSRT_Admin_Pay_Menus { 'coupons' => [ 'slug' => 'aksrt_coupons', 'title' => __('优惠码', 'aksrt'), - 'callback' => [$this, 'render_coupons_page'], + 'callback' => static function (): void { + if (class_exists('AKSRT_Admin_Pay_Coupons')) { + AKSRT_Admin_Pay_Coupons::get_instance()->render_page(); + return; + } + wp_die(esc_html__('优惠码模块未初始化。', 'aksrt')); + }, ], 'charge_cards' => [ 'slug' => 'aksrt_charge_cards', @@ -221,13 +224,6 @@ class AKSRT_Admin_Pay_Menus { 'after_sale_failed' => __('售后状态更新失败。', 'aksrt'), 'after_sale_created' => __('售后工单已创建。', 'aksrt'), 'after_sale_create_failed' => __('售后工单创建失败。', 'aksrt'), - 'coupon_created' => __('优惠码已创建。', 'aksrt'), - 'coupon_create_failed'=> __('优惠码创建失败(可能重复)。', 'aksrt'), - 'coupon_batch_ok' => __('优惠码批量生成完成。', 'aksrt'), - 'coupon_batch_failed' => __('优惠码批量生成失败。', 'aksrt'), - 'coupon_disabled' => __('优惠码已禁用。', 'aksrt'), - 'coupon_enabled' => __('优惠码已启用。', 'aksrt'), - 'coupon_failed' => __('优惠码操作失败。', 'aksrt'), 'income_settled' => __('分成已结算。', 'aksrt'), 'income_failed' => __('分成结算失败。', 'aksrt'), 'rebate_settled' => __('返佣已结算。', 'aksrt'), @@ -1608,246 +1604,6 @@ class AKSRT_Admin_Pay_Menus { exit; } - public function render_coupons_page(): void { - $this->ensure_cap(); - $this->render_header(__('优惠码', 'aksrt')); - - if (!class_exists('AKSRT_Coupon')) { - echo '

' . esc_html__('优惠码模块未初始化。', 'aksrt') . '

'; - $this->render_footer(); - return; - } - - $status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : ''; - $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_Coupon::get_instance(); - $type_labels = [ - 'fixed' => __('固定金额', 'aksrt'), - 'percent' => __('折扣百分比', 'aksrt'), - ]; - $status_labels = [ - 'active' => __('可用', 'aksrt'), - 'disabled' => __('已禁用', 'aksrt'), - ]; - - echo '

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

'; - echo '
'; - echo ''; - wp_nonce_field('aksrt_pay_create_coupon'); - echo '

'; - echo '

'; - echo '

'; - 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 ' '; - submit_button(__('筛选', 'aksrt'), 'secondary', '', false); - echo '
'; - - $result = $svc->get_list([ - 'status' => $status, - 'search' => $search, - 'limit' => $per_page, - 'offset' => ($page_num - 1) * $per_page, - ]); - $items = $result['items'] ?? []; - $total = (int) ($result['total'] ?? 0); - $pages = max(1, (int) ($result['total_pages'] ?? 1)); - - echo ''; - echo ''; - echo ''; - if (!$items) { - echo ''; - } - foreach ($items as $row) { - $id = (int) ($row['id'] ?? 0); - $st = (string) ($row['status'] ?? ''); - $row_type = (string) ($row['type'] ?? ''); - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - } - echo '
ID' . esc_html__('代码', 'aksrt') . '' . esc_html__('类型', 'aksrt') . '' . esc_html__('面值', 'aksrt') . '' . esc_html__('门槛/封顶', 'aksrt') . '' . esc_html__('范围', 'aksrt') . '' . esc_html__('已用/上限/每人', 'aksrt') . '' . esc_html__('状态', 'aksrt') . '' . esc_html__('过期', 'aksrt') . '' . esc_html__('操作', 'aksrt') . '
' . esc_html__('暂无优惠码', 'aksrt') . '
' . esc_html((string) $id) . '' . esc_html((string) ($row['code'] ?? '')) . '' . esc_html($type_labels[$row_type] ?? $row_type) . '' . esc_html((string) ($row['value'] ?? '')) . ($row_type === 'percent' ? '%' : '') . '' . esc_html((string) ($row['min_amount'] ?? 0)) . ' / ' . esc_html((string) ($row['max_discount'] ?? 0)) . '' . esc_html($this->format_coupon_scope_label($row['product_scope'] ?? [])) . '' . esc_html((string) ((int) ($row['used_count'] ?? 0)) . '/' . (string) ((int) ($row['usage_limit'] ?? 0)) . '/' . (string) ((int) ($row['per_user_limit'] ?? 0))) . '' . esc_html($status_labels[$st] ?? $st) . '' . esc_html((string) ($row['expires_at'] ?? '')) . ''; - if ($st === 'active') { - echo '
'; - echo ''; - echo ''; - wp_nonce_field('aksrt_pay_disable_coupon_' . $id); - submit_button(__('禁用', 'aksrt'), 'small', '', false); - echo '
'; - } elseif ($st === 'disabled') { - echo '
'; - echo ''; - echo ''; - wp_nonce_field('aksrt_pay_enable_coupon_' . $id); - submit_button(__('启用', 'aksrt'), 'small', '', false); - echo '
'; - } else { - echo '—'; - } - echo '
'; - $this->render_pagination($pages, $page_num); - $this->render_footer(); - } - - public function handle_create_coupon(): void { - if (!current_user_can(self::CAPABILITY)) { - wp_die(esc_html__('权限不足', 'aksrt')); - } - check_admin_referer('aksrt_pay_create_coupon'); - - $ok = false; - 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'; - } - } - $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), - 'max_discount' => (float) ($_POST['max_discount'] ?? 0), - 'usage_limit' => (int) ($_POST['usage_limit'] ?? 0), - 'per_user_limit' => (int) ($_POST['per_user_limit'] ?? 0), - 'product_scope' => $this->parse_coupon_product_scope_from_request(), - 'expires_at' => $expires, - ]); - $ok = $id > 0; - } - wp_safe_redirect($this->page_url('aksrt_coupons', ['aksrt_pay_msg' => $ok ? 'coupon_created' : 'coupon_create_failed'])); - 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')); - } - $id = isset($_POST['coupon_id']) ? (int) $_POST['coupon_id'] : 0; - check_admin_referer('aksrt_pay_disable_coupon_' . $id); - $ok = $id > 0 && class_exists('AKSRT_Coupon') && AKSRT_Coupon::get_instance()->disable($id); - wp_safe_redirect($this->page_url('aksrt_coupons', ['aksrt_pay_msg' => $ok ? 'coupon_disabled' : 'coupon_failed'])); - 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 * ----------------------------------------------------------------*/ @@ -2215,53 +1971,6 @@ class AKSRT_Admin_Pay_Menus { echo ''; } - /** - * @return array - */ - private function parse_coupon_product_scope_from_request(): array { - $type = isset($_POST['scope_type']) ? sanitize_key((string) wp_unslash($_POST['scope_type'])) : ''; - if ($type === '' || !in_array($type, ['include', 'exclude', 'category'], true)) { - return []; - } - $raw = isset($_POST['scope_ids']) ? (string) wp_unslash($_POST['scope_ids']) : ''; - $parts = preg_split('/[\s,;]+/', $raw) ?: []; - $ids = array_values(array_unique(array_filter(array_map('intval', $parts)))); - if (!$ids) { - return []; - } - $scope = [ - 'type' => $type, - 'ids' => $ids, - ]; - if ($type === 'category') { - $scope['taxonomies'] = ['aksrt_product_cat', 'category']; - } - return $scope; - } - - /** - * @param mixed $scope - */ - private function format_coupon_scope_label($scope): string { - if (is_string($scope) && $scope !== '') { - $decoded = json_decode($scope, true); - $scope = is_array($decoded) ? $decoded : []; - } - if (!is_array($scope) || empty($scope)) { - return __('全部', 'aksrt'); - } - $type = sanitize_key((string) ($scope['type'] ?? '')); - $ids = array_map('intval', (array) ($scope['ids'] ?? [])); - $id_text = $ids ? implode(',', $ids) : '—'; - $map = [ - 'include' => __('含商品', 'aksrt'), - 'exclude' => __('排除商品', 'aksrt'), - 'category' => __('分类', 'aksrt'), - ]; - $label = $map[$type] ?? $type; - return $label . ':' . $id_text; - } - private function render_pagination(int $pages, int $page_num): void { if ($pages <= 1) { return; diff --git a/inc/classes/class-aksrt-coupon.php b/inc/classes/class-aksrt-coupon.php index 5b02d871..b2deb815 100644 --- a/inc/classes/class-aksrt-coupon.php +++ b/inc/classes/class-aksrt-coupon.php @@ -250,6 +250,88 @@ class AKSRT_Coupon { return $wpdb->update($this->table(), ['status' => 'active'], ['id' => $id], ['%s'], ['%d']) !== false; } + /** + * Get one coupon by id. + */ + public function get(int $id): ?array { + global $wpdb; + if ($id <= 0) { + return null; + } + $row = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM {$this->table()} WHERE id = %d", + $id + ), ARRAY_A); + if (!$row) { + return null; + } + return $this->normalize_row($row); + } + + /** + * Update mutable coupon fields (not code / used_count / created_at). + */ + public function update(int $id, array $data): bool { + global $wpdb; + if ($id <= 0 || !$this->get($id)) { + return false; + } + + $fields = []; + $format = []; + + if (array_key_exists('type', $data)) { + $type = sanitize_key((string) $data['type']); + if (!in_array($type, [self::TYPE_PERCENT, self::TYPE_FIXED], true)) { + $type = self::TYPE_FIXED; + } + $fields['type'] = $type; + $format[] = '%s'; + } + if (array_key_exists('value', $data)) { + $fields['value'] = (float) $data['value']; + $format[] = '%f'; + } + if (array_key_exists('min_amount', $data)) { + $fields['min_amount'] = (float) $data['min_amount']; + $format[] = '%f'; + } + if (array_key_exists('max_discount', $data)) { + $fields['max_discount'] = (float) $data['max_discount']; + $format[] = '%f'; + } + if (array_key_exists('usage_limit', $data)) { + $fields['usage_limit'] = (int) $data['usage_limit']; + $format[] = '%d'; + } + if (array_key_exists('per_user_limit', $data)) { + $fields['per_user_limit'] = (int) $data['per_user_limit']; + $format[] = '%d'; + } + if (array_key_exists('expires_at', $data)) { + $fields['expires_at'] = sanitize_text_field((string) $data['expires_at']); + $format[] = '%s'; + } + if (array_key_exists('product_scope', $data)) { + $scope = is_array($data['product_scope']) ? $data['product_scope'] : []; + $fields['product_scope'] = wp_json_encode($scope, JSON_UNESCAPED_UNICODE); + $format[] = '%s'; + } + if (array_key_exists('status', $data)) { + $status = sanitize_key((string) $data['status']); + if (in_array($status, ['active', 'disabled'], true)) { + $fields['status'] = $status; + $format[] = '%s'; + } + } + + if (!$fields) { + return false; + } + + return $wpdb->update($this->table(), $fields, ['id' => $id], $format, ['%d']) !== false; + } + /** * Batch-create coupons with auto-generated codes. * diff --git a/inc/classes/forum/board-service-traits/trait-aksrt-forum-board-service-part-2.php b/inc/classes/forum/board-service-traits/trait-aksrt-forum-board-service-part-2.php index 287b5de0..4d4e2d9d 100644 --- a/inc/classes/forum/board-service-traits/trait-aksrt-forum-board-service-part-2.php +++ b/inc/classes/forum/board-service-traits/trait-aksrt-forum-board-service-part-2.php @@ -252,10 +252,15 @@ trait AKSRT_Forum_Board_Service_Part_2 { } $add_limit = (string) (get_post_meta($plate_id, '_aksrt_add_limit', true) ?: 'all'); - if ($add_limit === 'all') { + if ($add_limit === 'all' || $add_limit === 'login') { return true; } + // Moderators already returned true above. + if ($add_limit === 'moderator') { + return false; + } + $user = get_userdata($user_id); if (!$user) { return false; @@ -269,7 +274,8 @@ trait AKSRT_Forum_Board_Service_Part_2 { return !empty(array_intersect($user->roles, $allowed_roles)); } - return true; + // Unknown contract → deny (no legacy fallthrough). + return false; } /** diff --git a/inc/classes/forum/class-aksrt-plate-meta-box.php b/inc/classes/forum/class-aksrt-plate-meta-box.php index 03888059..b649d081 100644 --- a/inc/classes/forum/class-aksrt-plate-meta-box.php +++ b/inc/classes/forum/class-aksrt-plate-meta-box.php @@ -1,6 +1,6 @@ */ + private const ASSIGNABLE_ROLES = [ + 'super_moderator' => '超级版主', + 'moderator' => '版主', + 'junior_moderator' => '初级版主', + ]; + + /** @var list */ + private const ALLOW_VIEW = ['public', 'login', 'follow', 'role']; + + /** @var list */ + private const ADD_LIMIT = ['all', 'login', 'moderator', 'role']; + public static function get_instance(): self { if (null === self::$instance) { self::$instance = new self(); @@ -29,7 +42,7 @@ class AKSRT_Plate_Meta_Box { public function register_meta_box(): void { add_meta_box( 'aksrt_plate_moderators', - __('版主与申请', 'aksrt'), + __('版主与权限', 'aksrt'), [$this, 'render'], 'aksrt_forum_plate', 'side', @@ -40,41 +53,75 @@ class AKSRT_Plate_Meta_Box { public function render(WP_Post $post): void { wp_nonce_field('aksrt_plate_meta_save', 'aksrt_plate_meta_nonce'); + $plate_id = (int) $post->ID; + $author_id = (int) $post->post_author; $moderator_ids = []; + $roles_map = []; $applications = []; + if (class_exists('AKSRT_Forum')) { $forum = AKSRT_Forum::get_instance(); - $moderator_ids = $forum->get_plate_moderators((int) $post->ID); - $applications = $forum->get_plate_moderator_applications((int) $post->ID); + $moderator_ids = $forum->get_plate_moderators($plate_id); + $roles_map = $forum->get_moderator_roles($plate_id); + $applications = $forum->get_plate_moderator_applications($plate_id); } else { - $raw = get_post_meta($post->ID, '_aksrt_moderators', true); + $raw = get_post_meta($plate_id, '_aksrt_moderators', true); $moderator_ids = is_array($raw) ? array_map('intval', $raw) : []; - $applications = get_post_meta($post->ID, '_aksrt_moderator_applications', true); + $roles_raw = get_post_meta($plate_id, '_aksrt_moderator_roles', true); + $roles_map = is_array($roles_raw) ? $roles_raw : []; + $applications = get_post_meta($plate_id, '_aksrt_moderator_applications', true); $applications = is_array($applications) ? $applications : []; } - $allow_view = (string) (get_post_meta($post->ID, '_aksrt_allow_view', true) ?: 'public'); - $add_limit = (string) (get_post_meta($post->ID, '_aksrt_add_limit', true) ?: 'all'); - $moderator_labels = []; - foreach ($moderator_ids as $uid) { - $user = get_userdata((int) $uid); - $moderator_labels[] = $user - ? sprintf('%s (#%d)', $user->user_login, (int) $uid) - : (string) (int) $uid; + $allow_view = (string) (get_post_meta($plate_id, '_aksrt_allow_view', true) ?: 'public'); + if (!in_array($allow_view, self::ALLOW_VIEW, true)) { + // Rewrite legacy values (e.g. moderator) on display; save will persist new contract. + $allow_view = 'login'; } + $add_limit = (string) (get_post_meta($plate_id, '_aksrt_add_limit', true) ?: 'all'); + if (!in_array($add_limit, self::ADD_LIMIT, true)) { + $add_limit = 'all'; + } + $view_roles = get_post_meta($plate_id, '_aksrt_allow_view_roles', true); + $view_roles = is_array($view_roles) ? array_map('sanitize_key', $view_roles) : []; + $add_roles = get_post_meta($plate_id, '_aksrt_add_limit_roles', true); + $add_roles = is_array($add_roles) ? array_map('sanitize_key', $add_roles) : []; + $wp_roles = $this->wp_role_options(); + + $this->render_access_fields($allow_view, $add_limit, $view_roles, $add_roles, $wp_roles); + $this->render_moderator_fields($moderator_ids, $roles_map, $author_id); + $this->render_applications($applications); + } + + /** + * @param list $view_roles + * @param list $add_roles + * @param array $wp_roles + */ + private function render_access_fields(string $allow_view, string $add_limit, array $view_roles, array $add_roles, array $wp_roles): void { ?>

+

+ + + +

- - - + + +

- -

- -
-

$moderator_ids + * @param array $roles_map + */ + private function render_moderator_fields(array $moderator_ids, array $roles_map, int $author_id): void { + if ($author_id > 0) { + $author = get_userdata($author_id); + echo '

' . esc_html__('超级版主(版块作者)', 'aksrt') . '
'; + echo '' . esc_html($author ? $author->user_login . ' (#' . $author_id . ')' : (string) $author_id) . ''; + echo '
' . esc_html__('由版块作者决定,不可在此移除。', 'aksrt') . '

'; + } + + echo '

' . esc_html__('版主列表', 'aksrt') . '

'; + $rows = 0; + foreach ($moderator_ids as $uid) { + $uid = (int) $uid; + if ($uid <= 0 || $uid === $author_id) { + continue; + } + $user = get_userdata($uid); + $role = sanitize_key((string) ($roles_map[$uid] ?? 'moderator')); + if (!isset(self::ASSIGNABLE_ROLES[$role])) { + $role = 'moderator'; + } + $rows++; + echo '
'; + echo '
' . esc_html($user ? $user->user_login . ' (#' . $uid . ')' : (string) $uid) . '
'; + echo ''; + echo ''; + echo '
'; + } + if ($rows === 0) { + echo '

' . esc_html__('暂无版主。', 'aksrt') . '

'; + } + + echo '

' . esc_html__('添加版主', 'aksrt') . '

'; + echo '

'; + echo '

'; + echo '

' . esc_html__('保存后生效。勿将版块作者重复加为版主。', 'aksrt') . '

'; + echo '
'; + } + + /** + * @param array> $applications + */ + private function render_applications(array $applications): void { + echo '

' . esc_html__('版主申请', 'aksrt') . '

'; $pending = array_values(array_filter($applications, static function ($app) { - return is_array($app) && (($app['status'] ?? '') === 'pending' || ($app['status'] ?? '') === AKSRT_Forum::APPLICATION_PENDING); + if (!is_array($app)) { + return false; + } + $status = (string) ($app['status'] ?? ''); + if ($status === 'pending') { + return true; + } + return class_exists('AKSRT_Forum') && $status === AKSRT_Forum::APPLICATION_PENDING; })); if (!$pending) { echo '

' . esc_html__('暂无待处理申请。', 'aksrt') . '

'; @@ -109,9 +232,9 @@ class AKSRT_Plate_Meta_Box { echo '
    '; foreach ($pending as $app) { - $uid = (int) ($app['user_id'] ?? 0); - $user = $uid > 0 ? get_userdata($uid) : null; - $aid = (string) ($app['id'] ?? ''); + $uid = (int) ($app['user_id'] ?? 0); + $user = $uid > 0 ? get_userdata($uid) : null; + $aid = (string) ($app['id'] ?? ''); $reason = (string) ($app['reason'] ?? ''); echo '
  • '; echo '
    ' . esc_html($user ? $user->display_name . ' (#' . $uid . ')' : (string) $uid) . '
    '; @@ -137,23 +260,99 @@ class AKSRT_Plate_Meta_Box { return; } + $this->save_access_meta($post_id); + $this->save_moderators($post_id, (int) $post->post_author); + $this->save_applications(); + } + + private function save_access_meta(int $post_id): void { $allow_view = isset($_POST['aksrt_plate_allow_view']) ? sanitize_key((string) wp_unslash($_POST['aksrt_plate_allow_view'])) : 'public'; - if (!in_array($allow_view, ['public', 'login', 'moderator'], true)) { + if (!in_array($allow_view, self::ALLOW_VIEW, true)) { $allow_view = 'public'; } update_post_meta($post_id, '_aksrt_allow_view', $allow_view); + if ($allow_view === 'role') { + $roles = $this->sanitize_role_list($_POST['aksrt_plate_allow_view_roles'] ?? []); + update_post_meta($post_id, '_aksrt_allow_view_roles', $roles); + } else { + delete_post_meta($post_id, '_aksrt_allow_view_roles'); + } + $add_limit = isset($_POST['aksrt_plate_add_limit']) ? sanitize_key((string) wp_unslash($_POST['aksrt_plate_add_limit'])) : 'all'; - if (!in_array($add_limit, ['all', 'login', 'moderator'], true)) { + if (!in_array($add_limit, self::ADD_LIMIT, true)) { $add_limit = 'all'; } update_post_meta($post_id, '_aksrt_add_limit', $add_limit); - $raw = isset($_POST['aksrt_plate_moderators']) ? (string) wp_unslash($_POST['aksrt_plate_moderators']) : ''; - $ids = preg_split('/[\s,;]+/', $raw) ?: []; - $ids = array_values(array_unique(array_filter(array_map('intval', $ids)))); - update_post_meta($post_id, '_aksrt_moderators', $ids); + if ($add_limit === 'role') { + $roles = $this->sanitize_role_list($_POST['aksrt_plate_add_limit_roles'] ?? []); + update_post_meta($post_id, '_aksrt_add_limit_roles', $roles); + } else { + delete_post_meta($post_id, '_aksrt_add_limit_roles'); + } + } + private function save_moderators(int $post_id, int $author_id): void { + $role_posted = isset($_POST['aksrt_plate_mod_role']) && is_array($_POST['aksrt_plate_mod_role']) + ? wp_unslash($_POST['aksrt_plate_mod_role']) + : []; + $remove = isset($_POST['aksrt_plate_mod_remove']) && is_array($_POST['aksrt_plate_mod_remove']) + ? array_map('intval', wp_unslash($_POST['aksrt_plate_mod_remove'])) + : []; + $remove_set = array_fill_keys($remove, true); + + $desired = []; + foreach ($role_posted as $uid => $role) { + $uid = (int) $uid; + $role = sanitize_key((string) $role); + if ($uid <= 0 || $uid === $author_id || isset($remove_set[$uid])) { + continue; + } + if (!isset(self::ASSIGNABLE_ROLES[$role])) { + $role = 'moderator'; + } + $desired[$uid] = $role; + } + + $add_id = isset($_POST['aksrt_plate_mod_add_id']) ? (int) wp_unslash($_POST['aksrt_plate_mod_add_id']) : 0; + $add_role = isset($_POST['aksrt_plate_mod_add_role']) ? sanitize_key((string) wp_unslash($_POST['aksrt_plate_mod_add_role'])) : 'moderator'; + if ($add_id > 0 && $add_id !== $author_id) { + if (!isset(self::ASSIGNABLE_ROLES[$add_role])) { + $add_role = 'moderator'; + } + $desired[$add_id] = $add_role; + } + + if (class_exists('AKSRT_Forum')) { + $forum = AKSRT_Forum::get_instance(); + $current = $forum->get_plate_moderators($post_id); + foreach ($current as $uid) { + $uid = (int) $uid; + if ($uid === $author_id) { + continue; + } + if (!isset($desired[$uid])) { + $forum->remove_moderator($uid, $post_id); + } + } + foreach ($desired as $uid => $role) { + if (in_array($uid, $current, true)) { + $forum->set_moderator_role($uid, $post_id, $role); + } else { + $forum->add_moderator($uid, $post_id, $role); + } + } + return; + } + + // Fallback without forum service: rewrite meta directly (no legacy keep). + $ids = array_values(array_unique(array_map('intval', array_keys($desired)))); + update_post_meta($post_id, '_aksrt_moderators', $ids); + update_post_meta($post_id, '_aksrt_moderator_roles', $desired); + } + + private function save_applications(): void { $actions = isset($_POST['aksrt_plate_app']) && is_array($_POST['aksrt_plate_app']) ? wp_unslash($_POST['aksrt_plate_app']) : []; @@ -175,4 +374,37 @@ class AKSRT_Plate_Meta_Box { } } } + + /** + * @param mixed $raw + * @return list + */ + private function sanitize_role_list($raw): array { + if (!is_array($raw)) { + return []; + } + $valid = array_keys($this->wp_role_options()); + $out = []; + foreach ($raw as $role) { + $role = sanitize_key((string) $role); + if ($role !== '' && in_array($role, $valid, true)) { + $out[] = $role; + } + } + return array_values(array_unique($out)); + } + + /** + * @return array + */ + private function wp_role_options(): array { + $out = []; + if (!function_exists('wp_roles')) { + return $out; + } + foreach (wp_roles()->roles as $slug => $data) { + $out[sanitize_key((string) $slug)] = translate_user_role((string) ($data['name'] ?? $slug)); + } + return $out; + } }