count_by_status( \AKSRT\Ticket\TicketStatusService::KEY_PENDING ); } public function render_tickets_page(): void { if (!current_user_can(self::CAPABILITY)) { wp_die(esc_html__('权限不足', 'aksrt')); } $tab = isset($_GET['tab']) ? sanitize_key((string) wp_unslash($_GET['tab'])) : 'tickets'; $tab = $tab === 'config' ? 'config' : 'tickets'; echo '
'; echo '

' . esc_html__('工单管理', 'aksrt') . '

'; echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped $this->render_ticket_notices(); echo ''; if ($tab === 'config') { $this->render_ticket_config_tab(); } else { $this->render_ticket_list_tab(); } echo '
'; } /** 写操作后的 redirect 提示(aksrt_pay_msg 同款模式)。 */ private function render_ticket_notices(): void { if (empty($_GET['aksrt_ticket_msg'])) { return; } $code = sanitize_key((string) wp_unslash($_GET['aksrt_ticket_msg'])); $messages = [ 'updated' => __('工单状态已更新。', 'aksrt'), 'failed' => __('工单状态更新失败:当前状态不允许该操作。', 'aksrt'), 'replied' => __('回复已提交。', 'aksrt'), 'reply_failed' => __('回复提交失败。', 'aksrt'), 'type_saved' => __('部门已保存。', 'aksrt'), 'type_deleted' => __('部门已删除。', 'aksrt'), 'type_in_use' => __('该部门下还有工单,无法删除。', 'aksrt'), 'status_saved' => __('状态已保存。', 'aksrt'), 'status_deleted' => __('状态已删除。', 'aksrt'), 'status_default' => __('默认状态不可修改或删除。', 'aksrt'), 'status_in_use' => __('该状态下还有工单,无法删除。', 'aksrt'), 'prereply_saved' => __('预设回复已保存。', 'aksrt'), 'prereply_deleted' => __('预设回复已删除。', 'aksrt'), 'prereply_order_failed' => __('预设回复排序失败:需要提交全部条目。', 'aksrt'), 'note_saved' => __('备注已保存。', 'aksrt'), 'note_deleted' => __('备注已删除。', 'aksrt'), 'created' => __('工单已创建。', 'aksrt'), 'create_failed' => __('工单创建失败。', 'aksrt'), ]; if (!isset($messages[$code])) { return; } printf('

%s

', esc_html($messages[$code])); } /** * 写操作:统一入口,按 $action 分发。nonce 以 action + id 为单位, * 与 PayOrders 的行内处理表单同模式。 */ public function handle_ticket_update(): void { if (!current_user_can(self::CAPABILITY)) { wp_die(esc_html__('权限不足', 'aksrt')); } $action = isset($_POST['ticket_action']) ? sanitize_key((string) wp_unslash($_POST['ticket_action'])) : ''; $id = isset($_POST['ticket_id']) ? absint($_POST['ticket_id']) : 0; check_admin_referer('aksrt_ticket_' . $action . '_' . $id); $code = 'failed'; switch ($action) { case 'status': $code = $this->ticket_action_status($id); break; case 'reply': $code = $this->ticket_action_reply($id); break; case 'type': $code = $this->ticket_action_type(); break; case 'status_def': $code = $this->ticket_action_status_def(); break; case 'prereply': $code = $this->ticket_action_prereply(); break; case 'prereply_delete': $code = $this->ticket_action_prereply_delete(); break; case 'type_delete': $code = $this->ticket_action_type_delete(); break; case 'status_delete': $code = $this->ticket_action_status_delete(); break; case 'note': $code = $this->ticket_action_note($id); break; case 'create': $code = $this->ticket_action_create(); break; } $args = ['page' => self::TICKET_PAGE_SLUG, 'aksrt_ticket_msg' => $code]; if (isset($_POST['ticket_tab'])) { $args['tab'] = sanitize_key((string) wp_unslash($_POST['ticket_tab'])); } wp_safe_redirect(add_query_arg($args, admin_url('users.php'))); exit; } /** @return string 提示码 */ private function ticket_action_status(int $id): string { if ($id <= 0 || !class_exists('\AKSRT\Ticket\TicketRepository')) { return 'failed'; } $repo = \AKSRT\Ticket\TicketRepository::get_instance(); $ticket = $repo->find($id); if ($ticket === null) { return 'failed'; } $to = isset($_POST['status']) ? sanitize_key((string) wp_unslash($_POST['status'])) : ''; if (!\AKSRT\Ticket\TicketStatusService::can_transition((string) $ticket['status'], $to)) { return 'failed'; } $fields = ['status' => $to]; $type_id = isset($_POST['ticket_type_id']) ? absint($_POST['ticket_type_id']) : 0; if ($type_id > 0 && \AKSRT\Ticket\TicketTypeService::exists($type_id)) { $fields['ticket_type_id'] = $type_id; } $admin_id = isset($_POST['admin_id']) ? absint($_POST['admin_id']) : 0; if ($admin_id > 0 && get_userdata($admin_id)) { $fields['admin_id'] = $admin_id; } if (!$repo->update_fields($id, $fields)) { return 'failed'; } $ticket = array_merge($ticket, $fields); if ($to === \AKSRT\Ticket\TicketStatusService::KEY_CLOSED) { \AKSRT\Ticket\TicketNotifier::closed($ticket, 'admin'); } return 'updated'; } /** @return string 提示码 */ private function ticket_action_reply(int $id): string { if ($id <= 0 || !class_exists('\AKSRT\Ticket\TicketRepository')) { return 'reply_failed'; } $repo = \AKSRT\Ticket\TicketRepository::get_instance(); $ticket = $repo->find($id); if ($ticket === null || !empty($ticket['is_closed'])) { return 'reply_failed'; } $content = isset($_POST['content']) ? sanitize_textarea_field((string) wp_unslash($_POST['content'])) : ''; $content = trim($content); if ($content === '' || mb_strlen($content) > 3000) { return 'reply_failed'; } $reply_id = $repo->create_reply($id, 'admin', get_current_user_id(), $content, [], 0); if ($reply_id <= 0) { return 'reply_failed'; } $repo->touch_reply($id, 'admin', get_current_user_id()); $repo->apply_reply_status($id, $ticket, 'admin'); \AKSRT\Ticket\TicketNotifier::user_replied($repo->find($id) ?? $ticket); return 'replied'; } /** @return string 提示码 */ private function ticket_action_type(): string { if (!class_exists('\AKSRT\Ticket\TicketTypeService')) { return 'failed'; } $id = isset($_POST['type_id']) ? absint($_POST['type_id']) : 0; $name = isset($_POST['name']) ? sanitize_text_field((string) wp_unslash($_POST['name'])) : ''; $name = trim($name); $description = isset($_POST['description']) ? sanitize_text_field((string) wp_unslash($_POST['description'])) : ''; $admin_ids = isset($_POST['admin_ids']) ? array_map('absint', (array) $_POST['admin_ids']) : []; if ($name === '' || \AKSRT\Ticket\TicketTypeService::name_taken($name, $id)) { return 'failed'; } $payload = [ 'name' => $name, 'description' => $description, 'admin_ids' => \AKSRT\Ticket\TicketTypeService::sanitize_admin_ids($admin_ids), ]; if ($id > 0) { if (!\AKSRT\Ticket\TicketTypeService::exists($id)) { return 'failed'; } global $wpdb; $table = \AKSRT\Core\DBMigrator::table('ticket_type'); $saved = $wpdb->update($table, [ 'name' => $payload['name'], 'description' => $payload['description'], 'admin_ids' => wp_json_encode($payload['admin_ids']), 'updated_at' => current_time('mysql'), ], ['id' => $id], ['%s', '%s', '%s', '%s'], ['%d']); if ($saved === false) { return 'failed'; } \AKSRT\Ticket\TicketTypeService::flush(); return 'type_saved'; } global $wpdb; $table = \AKSRT\Core\DBMigrator::table('ticket_type'); if ($table === '') { return 'failed'; } $max = (int) $wpdb->get_var("SELECT MAX(sort_order) FROM {$table}"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery $inserted = $wpdb->insert($table, [ 'name' => $payload['name'], 'description' => $payload['description'], 'admin_ids' => wp_json_encode($payload['admin_ids']), 'sort_order' => $max + 1, 'created_at' => current_time('mysql'), ], ['%s', '%s', '%s', '%d', '%s']); if ($inserted !== 1) { return 'failed'; } \AKSRT\Ticket\TicketTypeService::flush(); return 'type_saved'; } /** @return string 提示码 */ private function ticket_action_status_def(): string { if (!class_exists('\AKSRT\Ticket\TicketStatusService')) { return 'failed'; } global $wpdb; $table = \AKSRT\Core\DBMigrator::table('ticket_status'); if ($table === '') { return 'failed'; } $id = isset($_POST['status_id']) ? absint($_POST['status_id']) : 0; $name = isset($_POST['name']) ? sanitize_text_field((string) wp_unslash($_POST['name'])) : ''; $name = trim($name); $color = \AKSRT\Ticket\TicketStatusService::sanitize_color( (string) wp_unslash($_POST['color'] ?? '') ); $is_closed = isset($_POST['is_closed']) ? 1 : 0; if ($name === '') { return 'failed'; } if ($id > 0) { $row = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", // phpcs:ignore WordPress.DB.PreparedSQL $id ), ARRAY_A); if (!is_array($row)) { return 'failed'; } if (!empty($row['is_default'])) { return 'status_default'; } $saved = $wpdb->update($table, [ 'name' => $name, 'color' => $color, 'is_closed' => $is_closed, 'updated_at' => current_time('mysql'), ], ['id' => $id], ['%s', '%s', '%d', '%s'], ['%d']); if ($saved === false) { return 'failed'; } \AKSRT\Ticket\TicketStatusService::flush(); return 'status_saved'; } $max = (int) $wpdb->get_var("SELECT MAX(sort_order) FROM {$table}"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery $inserted = $wpdb->insert($table, [ 'status_key' => \AKSRT\Ticket\TicketStatusService::make_key($name), 'name' => $name, 'color' => $color, 'is_closed' => $is_closed, 'sort_order' => $max + 1, 'created_at' => current_time('mysql'), ], ['%s', '%s', '%s', '%d', '%d', '%s']); if ($inserted !== 1) { return 'failed'; } \AKSRT\Ticket\TicketStatusService::flush(); return 'status_saved'; } /** @return string 提示码 */ private function ticket_action_prereply(): string { if (!class_exists('\AKSRT\Ticket\TicketPrereplyService')) { return 'failed'; } $id = isset($_POST['prereply_id']) ? absint($_POST['prereply_id']) : 0; $content = isset($_POST['content']) ? sanitize_textarea_field((string) wp_unslash($_POST['content'])) : ''; $content = trim($content); if ($content === '') { return 'failed'; } if ($id > 0) { if (\AKSRT\Ticket\TicketPrereplyService::find($id) === null) { return 'failed'; } return \AKSRT\Ticket\TicketPrereplyService::update($id, $content) ? 'prereply_saved' : 'failed'; } return \AKSRT\Ticket\TicketPrereplyService::create($content) > 0 ? 'prereply_saved' : 'failed'; } /** @return string 提示码 */ private function ticket_action_prereply_delete(): string { $id = isset($_POST['prereply_id']) ? absint($_POST['prereply_id']) : 0; if ($id <= 0 || !class_exists('\AKSRT\Ticket\TicketPrereplyService')) { return 'failed'; } return \AKSRT\Ticket\TicketPrereplyService::delete($id) ? 'prereply_deleted' : 'failed'; } /** @return string 提示码 */ private function ticket_action_type_delete(): string { $id = isset($_POST['type_id']) ? absint($_POST['type_id']) : 0; if ($id <= 0 || !class_exists('\AKSRT\Ticket\TicketTypeService')) { return 'failed'; } if (\AKSRT\Ticket\TicketRepository::get_instance()->count_by_type($id) > 0) { return 'type_in_use'; } global $wpdb; $table = \AKSRT\Core\DBMigrator::table('ticket_type'); if ($table === '') { return 'failed'; } $wpdb->delete($table, ['id' => $id], ['%d']); \AKSRT\Ticket\TicketTypeService::flush(); return 'type_deleted'; } /** @return string 提示码 */ private function ticket_action_status_delete(): string { $id = isset($_POST['status_id']) ? absint($_POST['status_id']) : 0; if ($id <= 0 || !class_exists('\AKSRT\Ticket\TicketStatusService')) { return 'failed'; } global $wpdb; $table = \AKSRT\Core\DBMigrator::table('ticket_status'); if ($table === '') { return 'failed'; } $row = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", // phpcs:ignore WordPress.DB.PreparedSQL $id ), ARRAY_A); if (!is_array($row)) { return 'failed'; } if (!empty($row['is_default'])) { return 'status_default'; } $key = sanitize_key((string) $row['status_key']); if (\AKSRT\Ticket\TicketRepository::get_instance()->count_by_status($key) > 0) { return 'status_in_use'; } $wpdb->delete($table, ['id' => $id], ['%d']); \AKSRT\Ticket\TicketStatusService::flush(); return 'status_deleted'; } /** @return string 提示码 */ private function ticket_action_note(int $id): string { if ($id <= 0 || !class_exists('\AKSRT\Ticket\TicketNoteService')) { return 'failed'; } $note_id = isset($_POST['note_id']) ? absint($_POST['note_id']) : 0; $content = isset($_POST['content']) ? sanitize_textarea_field((string) wp_unslash($_POST['content'])) : ''; $content = trim($content); if ($content === '') { return 'failed'; } if ($note_id > 0) { return \AKSRT\Ticket\TicketNoteService::update($note_id, $content) ? 'note_saved' : 'failed'; } return \AKSRT\Ticket\TicketNoteService::create($id, $content, get_current_user_id()) > 0 ? 'note_saved' : 'failed'; } /** @return string 提示码 */ private function ticket_action_create(): string { if (!class_exists('\AKSRT\Ticket\TicketRepository')) { return 'create_failed'; } $user_id = isset($_POST['user_id']) ? absint($_POST['user_id']) : 0; $title = isset($_POST['title']) ? sanitize_text_field((string) wp_unslash($_POST['title'])) : ''; $title = trim($title); $content = isset($_POST['content']) ? sanitize_textarea_field((string) wp_unslash($_POST['content'])) : ''; $content = trim($content); $type_id = isset($_POST['ticket_type_id']) ? absint($_POST['ticket_type_id']) : 0; if ($user_id <= 0 || !get_userdata($user_id) || $title === '' || $content === '') { return 'create_failed'; } if (!\AKSRT\Ticket\TicketTypeService::exists($type_id)) { return 'create_failed'; } $id = \AKSRT\Ticket\TicketRepository::get_instance()->create([ 'ticket_num' => \AKSRT\Ticket\TicketNumber::generate(), 'user_id' => $user_id, 'title' => $title, 'ticket_type_id' => $type_id, 'content' => $content, 'order_number' => isset($_POST['order_number']) ? sanitize_text_field((string) wp_unslash($_POST['order_number'])) : '', 'status' => \AKSRT\Ticket\TicketStatusService::KEY_ADMIN_REPLIED, 'post_admin_id' => get_current_user_id(), 'admin_id' => get_current_user_id(), ]); if ($id <= 0) { return 'create_failed'; } $ticket = \AKSRT\Ticket\TicketRepository::get_instance()->find($id); if (is_array($ticket)) { \AKSRT\Ticket\TicketNotifier::user_replied($ticket); } return 'created'; } private function ticket_page_url(array $extra = []): string { return add_query_arg( array_merge(['page' => self::TICKET_PAGE_SLUG], $extra), admin_url('users.php') ); } /** * 行内删除按钮:独立小表单 + 二次确认,nonce 以 action + id 为单位。 * 删除类操作不占用列表主表单,避免误删。 */ private function ticket_delete_button(string $action, string $field, int $id, string $label): string { return sprintf( '
' . '' . '' . '' . '%s' . '
', esc_url(admin_url('admin-post.php')), esc_js(__('确认删除?', 'aksrt')), esc_attr($action), esc_attr($field), $id, wp_nonce_field('aksrt_ticket_' . $action . '_' . $id, '_wpnonce', true, false), esc_html($label) ); } /** 工单页共享样式(徽章取状态颜色,两个 Tab 都用)。 */ private function ticket_page_css(): string { return '.aksrt-ticket-badge{display:inline-block;padding:2px 10px;border-radius:999px;' . 'background:color-mix(in srgb,var(--aksrt-ticket-color,#6b7280) 14%,transparent);' . 'color:var(--aksrt-ticket-color,#6b7280);font-size:12px;font-weight:600;line-height:1.6;}' . '.aksrt-ticket-detail{display:grid;gap:10px;padding:12px;border:1px solid #dcdcde;border-radius:6px;background:#fff;}' . '.aksrt-ticket-detail__content,.aksrt-ticket-detail__reply-body{white-space:pre-wrap;word-break:break-word;}' . '.aksrt-ticket-detail__replies{display:grid;gap:8px;margin:0;padding:0;list-style:none;}' . '.aksrt-ticket-detail__replies li{padding:10px 12px;border-left:3px solid #c3c4c7;border-radius:4px;background:#f6f7f7;}' . '.aksrt-ticket-detail__replies li.is-admin{border-left-color:#2271b1;background:#f0f6fc;}' . '.aksrt-ticket-detail__reply-head time{color:#646970;font-size:11px;}' . '.aksrt-ticket-detail__quote{margin:0 0 6px;padding:6px 10px;border-left:3px solid #dcdcde;background:#fff;color:#50575e;font-size:12px;}' . '.aksrt-ticket-detail__notes{display:grid;gap:6px;margin:0;padding:0;list-style:none;}' . '.aksrt-ticket-detail__notes time{color:#646970;font-size:11px;}' . '.aksrt-ticket-detail__closed{margin:0;color:#8c8f94;font-size:12px;}'; } }