Files
wordpress-theme-aksrt/src/Admin/Native/UsersMenusTicketsCenter.php
T
kate522andClaude Fable 5 b74381cfa2 feat(ticket): redesign native ticket admin page UI/UX
- Fix empty status/department selects in the detail panel (printf leak)
- Replace dead 代客提交 button with a working collapsible create form
- Error-aware notices; redirect keeps filters and the open ticket
- Status views with counts, incl. 未关闭 default and 已关闭 view
- Two-column detail: conversation + attachments + prereplies | info,
  status/department/assignee select, internal notes; closed = read-only
- Split detail panel into UsersMenusTicketsDetail trait; page-scoped
  tickets.css / admin-tickets.js replace inline styles
- AdminListPagination accepts parent file so users.php keeps menu highlight
- Drop native loading="lazy" per lazyload contract

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-27 14:04:48 +08:00

294 lines
12 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
/**
* 工单管理后台页(users.php → aksrt_tickets)。
*
* 单页承载:代客提交(折叠卡片)+ 状态视图 + 部门筛选/搜索 + 工单列表 + 详情面板
* (沟通记录 / 回复 / 状态与指派 / 内部备注)。部门、状态与预设回复的配置在主题后台
* SPA「工单中心」(/users/ticket),由 TicketConfigRest 提供 REST 实现。
*
* 写操作走 admin_post(非 REST),与本站其它原生后台页一致;处理完回到来源视图
* (保留筛选与当前展开的工单)。
*
* @package AKSRT
*/
namespace AKSRT\Admin\Native;
defined('ABSPATH') || exit;
trait UsersMenusTicketsCenter {
use UsersMenusTicketsList;
use UsersMenusTicketsDetail;
private const TICKET_PAGE_SLUG = 'aksrt_tickets';
private const TICKET_PER_PAGE = 20;
private const TICKET_CONTENT_MAX = 3000;
/** 待接单工单数(后台 admin_notices 用)。 */
private function count_pending_tickets(): int {
if (!class_exists('\AKSRT\Ticket\TicketRepository') || !class_exists('\AKSRT\Ticket\TicketStatusService')) {
return 0;
}
return \AKSRT\Ticket\TicketRepository::get_instance()->count_by_status(
\AKSRT\Ticket\TicketStatusService::KEY_PENDING
);
}
/** 页面专属样式与脚本,只在本页入队。 */
public function enqueue_ticket_assets(string $hook): void {
if ($hook !== 'users_page_' . self::TICKET_PAGE_SLUG) {
return;
}
$css = '/assets/css/admin/tickets.css';
$js = '/assets/js/admin-tickets.js';
if (is_file(AKSRT_DIR . $css)) {
wp_enqueue_style('aksrt-admin-tickets', AKSRT_URI . $css, [], (string) filemtime(AKSRT_DIR . $css));
}
if (is_file(AKSRT_DIR . $js)) {
wp_enqueue_script('aksrt-admin-tickets', AKSRT_URI . $js, [], (string) filemtime(AKSRT_DIR . $js), true);
}
}
public function render_tickets_page(): void {
if (!current_user_can(self::CAPABILITY)) {
wp_die(esc_html__('权限不足', 'aksrt'));
}
echo '<div class="wrap aksrt-tickets-admin">';
echo '<h1 class="wp-heading-inline">' . esc_html__('工单管理', 'aksrt') . '</h1>';
printf(
'<a class="page-title-action" href="%s">%s</a>',
esc_url(admin_url('admin.php?page=aksrt-settings#/users/ticket')),
esc_html__('工单配置(部门 / 状态 / 预设回复)', 'aksrt')
);
echo '<hr class="wp-header-end">';
$this->render_ticket_notices();
$this->render_ticket_create_form();
$this->render_ticket_list_tab();
echo '</div>';
}
/** 写操作后的 redirect 提示:成功绿色、失败红色。 */
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'), true],
'failed' => [__('操作失败:当前状态不允许该操作,或提交内容无效。', 'aksrt'), false],
'replied' => [__('回复已提交,用户将收到通知。', 'aksrt'), true],
'reply_failed' => [__('回复提交失败:内容为空、超长或工单已关闭。', 'aksrt'), false],
'note_saved' => [__('备注已添加。', 'aksrt'), true],
'created' => [__('工单已创建,用户将收到通知。', 'aksrt'), true],
'create_failed' => [__('工单创建失败:请检查用户 ID、部门、标题与内容。', 'aksrt'), false],
];
if (!isset($messages[$code])) {
return;
}
[$text, $ok] = $messages[$code];
printf(
'<div class="notice %s is-dismissible"><p>%s</p></div>',
$ok ? 'notice-success' : 'notice-error',
esc_html($text)
);
}
/**
* 写操作:统一入口,按 $action 分发。nonce 以 action + id 为单位。
*/
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';
$open_id = $id;
switch ($action) {
case 'status':
$code = $this->ticket_action_status($id);
break;
case 'reply':
$code = $this->ticket_action_reply($id);
break;
case 'note':
$code = $this->ticket_action_note($id);
break;
case 'create':
$code = $this->ticket_action_create($open_id);
break;
}
$this->ticket_redirect($code, $open_id, $action !== 'create');
}
/**
* 回到来源视图(保留筛选 / 分页),并保持处理中的工单展开。
* 新建工单回到默认视图:新单可能不满足来源筛选。
*/
private function ticket_redirect(string $code, int $open_id, bool $keep_view): void {
$target = $this->ticket_page_url();
$referer = $keep_view ? wp_get_referer() : false;
if (is_string($referer) && str_contains($referer, 'page=' . self::TICKET_PAGE_SLUG)) {
$target = remove_query_arg(['aksrt_ticket_msg', 'id'], $referer);
}
$args = ['aksrt_ticket_msg' => $code];
if ($open_id > 0) {
$args['id'] = $open_id;
}
$url = add_query_arg($args, $target);
wp_safe_redirect($open_id > 0 ? $url . '#aksrt-ticket-' . $open_id : $url);
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, 'updated_at' => current_time('mysql')];
$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;
}
if (isset($_POST['admin_id'])) {
// 0 = 取消指派;其余必须是存在的用户。
$admin_id = absint($_POST['admin_id']);
if ($admin_id === 0 || get_userdata($admin_id)) {
$fields['admin_id'] = $admin_id;
}
}
if (!$repo->update_fields($id, $fields)) {
return 'failed';
}
$was_closed = \AKSRT\Ticket\TicketStatusService::is_closed((string) $ticket['status']);
$ticket = array_merge($ticket, $fields);
if ($to === \AKSRT\Ticket\TicketStatusService::KEY_CLOSED && !$was_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 = trim($this->ticket_post_textarea('content'));
if ($content === '' || mb_strlen($content) > self::TICKET_CONTENT_MAX) {
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_note(int $id): string {
if ($id <= 0 || !class_exists('\AKSRT\Ticket\TicketNoteService')) {
return 'failed';
}
$content = trim($this->ticket_post_textarea('content'));
if ($content === '' || mb_strlen($content) > self::TICKET_CONTENT_MAX) {
return 'failed';
}
return \AKSRT\Ticket\TicketNoteService::create($id, $content, get_current_user_id()) > 0
? 'note_saved'
: 'failed';
}
/**
* @param int $new_id 输出:新工单 ID(成功时)
* @return string 提示码
*/
private function ticket_action_create(int &$new_id): string {
$new_id = 0;
if (!class_exists('\AKSRT\Ticket\TicketRepository')) {
return 'create_failed';
}
$user_id = isset($_POST['user_id']) ? absint($_POST['user_id']) : 0;
$title = isset($_POST['title']) ? trim(sanitize_text_field((string) wp_unslash($_POST['title']))) : '';
$content = trim($this->ticket_post_textarea('content'));
$type_id = isset($_POST['ticket_type_id']) ? absint($_POST['ticket_type_id']) : 0;
if ($user_id <= 0 || !get_userdata($user_id) || $title === '' || mb_strlen($title) > 200) {
return 'create_failed';
}
if ($content === '' || mb_strlen($content) > self::TICKET_CONTENT_MAX) {
return 'create_failed';
}
if (!\AKSRT\Ticket\TicketTypeService::exists($type_id)) {
return 'create_failed';
}
$order_number = \AKSRT\Ticket\TicketSettings::order_link_enabled() && isset($_POST['order_number'])
? sanitize_text_field((string) wp_unslash($_POST['order_number']))
: '';
$repo = \AKSRT\Ticket\TicketRepository::get_instance();
$id = $repo->create([
'ticket_num' => \AKSRT\Ticket\TicketNumber::generate(),
'user_id' => $user_id,
'title' => $title,
'ticket_type_id' => $type_id,
'content' => $content,
'order_number' => $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';
}
$new_id = $id;
$ticket = $repo->find($id);
if (is_array($ticket)) {
\AKSRT\Ticket\TicketNotifier::user_replied($ticket);
}
return 'created';
}
private function ticket_post_textarea(string $key): string {
return isset($_POST[$key]) ? sanitize_textarea_field((string) wp_unslash($_POST[$key])) : '';
}
private function ticket_page_url(array $extra = []): string {
return add_query_arg(
array_merge(['page' => self::TICKET_PAGE_SLUG], $extra),
admin_url('users.php')
);
}
/** admin_post 表单开头:action + ticket_action + ticket_id(nonce 由调用方各自输出)。 */
private function ticket_form_open(string $ticket_action, int $id, string $class = ''): string {
return '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '"'
. ($class !== '' ? ' class="' . esc_attr($class) . '"' : '') . '>'
. '<input type="hidden" name="action" value="aksrt_ticket_update" />'
. '<input type="hidden" name="ticket_action" value="' . esc_attr($ticket_action) . '" />'
. '<input type="hidden" name="ticket_id" value="' . esc_attr((string) $id) . '" />';
}
}