- 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>
294 lines
12 KiB
PHP
294 lines
12 KiB
PHP
<?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) . '" />';
|
||
}
|
||
}
|