users.php → 工单管理 的「配置」Tab 只管理部门 / 状态 / 预设回复三块 DB 字典,与 SPA「工单中心」已有的 8 个标量配置分裂成两处;且同一套 CRUD 在 admin_post 与 TicketConfigRest 里各写了一遍,REST 侧此前 没有任何调用方。 - 删除原生 config tab:UsersMenusTicketsConfig.php 整份移除, UsersMenusTicketsCenter 由 512 行降到 251 行(六个写处理方法、 分发分支、十条提示码与 ticket_delete_button 一并清理) - SPA /users/ticket 改为 customPage:内嵌 SchemaPage 承载原有 8 字段, 三块字典改为 n-data-table + n-modal + n-dialog,取色器/管理员多选/ 拖拽排序替代原生手填表单 - TicketConfigRest 成为三块字典的唯一实现,新增 api/modules/tickets.js 封装 aksrt/v1/admin/tickets* - 双向深链:原生页跳 SPA 配置页,SPA 页跳回原生工单列表 契约测试同步更新,并新增 ticket-spa-config-contract 守护接线。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
269 lines
14 KiB
PHP
269 lines
14 KiB
PHP
<?php
|
||
/**
|
||
* 工单管理后台页 — 工单列表 Tab 渲染。
|
||
*
|
||
* @package AKSRT
|
||
*/
|
||
|
||
namespace AKSRT\Admin\Native;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
trait UsersMenusTicketsList {
|
||
|
||
private function render_ticket_list_tab(): void {
|
||
$status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : '';
|
||
$type_id = isset($_GET['ticket_type_id']) ? absint($_GET['ticket_type_id']) : 0;
|
||
$s = isset($_GET['s']) ? sanitize_text_field((string) wp_unslash($_GET['s'])) : '';
|
||
$paged = isset($_GET['paged']) ? max(1, (int) $_GET['paged']) : 1;
|
||
|
||
$statuses = \AKSRT\Ticket\TicketStatusService::all();
|
||
$types = \AKSRT\Ticket\TicketTypeService::all();
|
||
|
||
$base_url = remove_query_arg(['status', 'ticket_type_id', 's', 'paged', 'id'], $this->ticket_page_url());
|
||
|
||
// 状态 subsubsub(完结态单独放最后,默认视图不显示已关闭工单)
|
||
echo '<ul class="subsubsub">';
|
||
$links = ['' => __('全部', 'aksrt')];
|
||
foreach ($statuses as $row) {
|
||
if (!empty($row['is_closed'])) {
|
||
continue;
|
||
}
|
||
$links[(string) $row['status_key']] = (string) $row['name'];
|
||
}
|
||
$i = 0;
|
||
foreach ($links as $key => $label) {
|
||
$url = $key === '' ? $base_url : add_query_arg('status', $key, $base_url);
|
||
$sep = ++$i < count($links) ? ' |' : '';
|
||
printf(
|
||
'<li><a class="%s" href="%s">%s</a>%s</li> ',
|
||
$status === $key ? 'current' : '',
|
||
esc_url($url),
|
||
esc_html($label),
|
||
esc_html($sep)
|
||
);
|
||
}
|
||
echo '</ul>';
|
||
|
||
// 筛选 + 搜索
|
||
echo '<form method="get" class="search-form" style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin:10px 0;">';
|
||
echo '<input type="hidden" name="page" value="' . esc_attr(self::TICKET_PAGE_SLUG) . '" />';
|
||
echo '<select name="status">';
|
||
echo '<option value="">' . esc_html__('全部状态', 'aksrt') . '</option>';
|
||
foreach ($statuses as $row) {
|
||
printf(
|
||
'<option value="%s"%s>%s</option>',
|
||
esc_attr((string) $row['status_key']),
|
||
selected($status, (string) $row['status_key'], false),
|
||
esc_html((string) $row['name'])
|
||
);
|
||
}
|
||
echo '</select> ';
|
||
echo '<select name="ticket_type_id">';
|
||
echo '<option value="">' . esc_html__('全部部门', 'aksrt') . '</option>';
|
||
foreach ($types as $row) {
|
||
printf(
|
||
'<option value="%d"%s>%s</option>',
|
||
(int) $row['id'],
|
||
selected($type_id, (int) $row['id'], false),
|
||
esc_html((string) $row['name'])
|
||
);
|
||
}
|
||
echo '</select> ';
|
||
echo '<input type="search" name="s" value="' . esc_attr($s) . '" placeholder="' . esc_attr__('工单号 / 标题', 'aksrt') . '" /> ';
|
||
submit_button(__('筛选', 'aksrt'), 'primary', '', false);
|
||
echo '</form>';
|
||
|
||
// 新建工单入口
|
||
printf(
|
||
'<p><button type="button" class="button" data-aksrt-ticket-new>%s</button></p>',
|
||
esc_html__('代客提交工单', 'aksrt')
|
||
);
|
||
|
||
$repo = \AKSRT\Ticket\TicketRepository::get_instance();
|
||
$result = $repo->paginate([
|
||
'status' => $status === '' ? [] : [$status],
|
||
'ticket_type_id' => $type_id,
|
||
'keywords' => $s,
|
||
'limit' => self::TICKET_PER_PAGE,
|
||
'offset' => ($paged - 1) * self::TICKET_PER_PAGE,
|
||
], true);
|
||
$items = $result['items'];
|
||
$total = $result['total'];
|
||
$open_id = isset($_GET['id']) ? absint($_GET['id']) : 0;
|
||
|
||
echo '<div class="table-box" style="overflow-y:auto;width:100%;">';
|
||
echo '<table class="widefat fixed striped posts" style="min-width:960px;">';
|
||
echo '<thead><tr>';
|
||
echo '<th width="6%">' . esc_html__('状态', 'aksrt') . '</th>';
|
||
echo '<th width="10%">' . esc_html__('工单号', 'aksrt') . '</th>';
|
||
echo '<th width="22%">' . esc_html__('标题', 'aksrt') . '</th>';
|
||
echo '<th width="10%">' . esc_html__('部门', 'aksrt') . '</th>';
|
||
echo '<th width="10%">' . esc_html__('用户', 'aksrt') . '</th>';
|
||
echo '<th width="10%">' . esc_html__('跟进人', 'aksrt') . '</th>';
|
||
echo '<th width="12%">' . esc_html__('提交时间', 'aksrt') . '</th>';
|
||
echo '<th width="12%">' . esc_html__('最后回复', 'aksrt') . '</th>';
|
||
echo '</tr></thead><tbody>';
|
||
|
||
if ($items === []) {
|
||
echo '<tr><td colspan="8" align="center"><strong>' . esc_html__('暂无工单', 'aksrt') . '</strong></td></tr>';
|
||
}
|
||
|
||
foreach ($items as $ticket) {
|
||
$is_open = $open_id === (int) $ticket['id'];
|
||
printf(
|
||
'<tr><td><span class="aksrt-ticket-badge" style="--aksrt-ticket-color:%s">%s</span></td>'
|
||
. '<td>%s</td><td><a href="%s"><strong>%s</strong></a></td><td>%s</td><td>%s</td><td>%s</td>'
|
||
. '<td>%s</td><td>%s</td></tr>',
|
||
esc_attr((string) $ticket['status_color']),
|
||
esc_html((string) $ticket['status_label']),
|
||
esc_html((string) $ticket['ticket_num']),
|
||
esc_url(add_query_arg('id', (int) $ticket['id'], $base_url)),
|
||
esc_html((string) $ticket['title']),
|
||
esc_html((string) $ticket['ticket_type_name']),
|
||
esc_html((string) $ticket['user_name']),
|
||
esc_html((string) $ticket['admin_name'] !== '' ? (string) $ticket['admin_name'] : '—'),
|
||
esc_html((string) $ticket['post_time']),
|
||
esc_html((string) $ticket['last_reply_time'] !== '' ? (string) $ticket['last_reply_time'] : '—')
|
||
);
|
||
if ($is_open) {
|
||
echo '<tr><td colspan="8">' . $this->render_ticket_detail_panel($ticket) . '</td></tr>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||
}
|
||
}
|
||
echo '</tbody></table></div>';
|
||
|
||
\AKSRT\Admin\Native\AdminListPagination::render(
|
||
self::TICKET_PAGE_SLUG,
|
||
$total,
|
||
$paged,
|
||
self::TICKET_PER_PAGE,
|
||
array_filter([
|
||
'status' => $status,
|
||
'ticket_type_id' => $type_id,
|
||
's' => $s,
|
||
], static fn($value): bool => $value !== '' && $value !== 0)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 行内展开的工单详情:沟通记录 + 回复框 + 状态迁移 + 后台备注。
|
||
*
|
||
* @param array<string,mixed> $ticket
|
||
*/
|
||
private function render_ticket_detail_panel(array $ticket): string {
|
||
$id = (int) $ticket['id'];
|
||
$repo = \AKSRT\Ticket\TicketRepository::get_instance();
|
||
$replies = $repo->replies($id);
|
||
$notes = \AKSRT\Ticket\TicketNoteService::for_ticket($id);
|
||
|
||
$html = '<div class="aksrt-ticket-detail" data-aksrt-ticket-detail="' . esc_attr((string) $id) . '">';
|
||
|
||
// 工单正文
|
||
$html .= '<div class="aksrt-ticket-detail__meta">';
|
||
$html .= '<p><strong>' . esc_html__('工单号', 'aksrt') . ':</strong>' . esc_html((string) $ticket['ticket_num']) . '</p>';
|
||
$html .= '<p><strong>' . esc_html__('提交用户', 'aksrt') . ':</strong>' . esc_html((string) $ticket['user_name']) . ' (#' . esc_html((string) $ticket['user_id']) . ')</p>';
|
||
if ((string) $ticket['order_number'] !== '') {
|
||
$html .= '<p><strong>' . esc_html__('关联订单', 'aksrt') . ':</strong>' . esc_html((string) $ticket['order_number']) . '</p>';
|
||
}
|
||
$html .= '<p><strong>' . esc_html__('问题描述', 'aksrt') . ':</strong></p>';
|
||
$html .= '<div class="aksrt-ticket-detail__content">' . nl2br(esc_html((string) $ticket['content'])) . '</div>';
|
||
$html .= '</div>';
|
||
|
||
// 沟通记录
|
||
$html .= '<h4>' . esc_html__('沟通记录', 'aksrt') . '</h4>';
|
||
if ($replies === []) {
|
||
$html .= '<p class="description">' . esc_html__('暂无沟通记录', 'aksrt') . '</p>';
|
||
}
|
||
$html .= '<ol class="aksrt-ticket-detail__replies">';
|
||
foreach ($replies as $reply) {
|
||
$who = (string) $reply['author_type'] === 'admin'
|
||
? sprintf(
|
||
/* translators: %s admin name */
|
||
__('管理员 %s', 'aksrt'),
|
||
(string) $reply['author_name']
|
||
)
|
||
: (string) $reply['author_name'];
|
||
$html .= '<li class="is-' . esc_attr((string) $reply['author_type']) . '">';
|
||
$html .= '<div class="aksrt-ticket-detail__reply-head"><strong>' . esc_html($who) . '</strong>'
|
||
. ' <time>' . esc_html((string) $reply['created_at']) . '</time></div>';
|
||
if (is_array($reply['quote'] ?? null)) {
|
||
$quote = $reply['quote'];
|
||
$html .= '<blockquote class="aksrt-ticket-detail__quote">'
|
||
. esc_html(!empty($quote['is_deleted']) ? __('原回复已删除', 'aksrt') : (string) $quote['content'])
|
||
. '</blockquote>';
|
||
}
|
||
$html .= '<div class="aksrt-ticket-detail__reply-body">' . nl2br(esc_html((string) $reply['content'])) . '</div>';
|
||
$html .= '</li>';
|
||
}
|
||
$html .= '</ol>';
|
||
|
||
// 管理员回复:已完结工单不再给回复框,只提示已关闭。
|
||
if (!empty($ticket['is_closed'])) {
|
||
$html .= '<p class="aksrt-ticket-detail__closed">' . esc_html__('工单已关闭,无法继续回复。', 'aksrt') . '</p>';
|
||
} else {
|
||
$html .= '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" class="aksrt-ticket-detail__reply-form">';
|
||
$html .= '<input type="hidden" name="action" value="aksrt_ticket_update" />';
|
||
$html .= '<input type="hidden" name="ticket_action" value="reply" />';
|
||
$html .= '<input type="hidden" name="ticket_id" value="' . esc_attr((string) $id) . '" />';
|
||
$html .= wp_nonce_field('aksrt_ticket_reply_' . $id, '_wpnonce', true, false);
|
||
$html .= '<p><textarea name="content" rows="3" class="large-text" placeholder="' . esc_attr__('填写回复内容', 'aksrt') . '"></textarea></p>';
|
||
$html .= '<p><button type="submit" class="button button-primary">' . esc_html__('提交回复', 'aksrt') . '</button></p>';
|
||
$html .= '</form>';
|
||
}
|
||
|
||
// 状态迁移 / 部门 / 跟进人
|
||
$html .= '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" class="aksrt-ticket-detail__status-form">';
|
||
$html .= '<input type="hidden" name="action" value="aksrt_ticket_update" />';
|
||
$html .= '<input type="hidden" name="ticket_action" value="status" />';
|
||
$html .= '<input type="hidden" name="ticket_id" value="' . esc_attr((string) $id) . '" />';
|
||
$html .= wp_nonce_field('aksrt_ticket_status_' . $id, '_wpnonce', true, false);
|
||
$html .= '<p><label>' . esc_html__('状态', 'aksrt') . ' <select name="status">';
|
||
foreach (\AKSRT\Ticket\TicketStatusService::all() as $row) {
|
||
printf(
|
||
'<option value="%s"%s>%s</option>',
|
||
esc_attr((string) $row['status_key']),
|
||
selected((string) $ticket['status'], (string) $row['status_key'], false),
|
||
esc_html((string) $row['name'])
|
||
);
|
||
}
|
||
$html .= '</select></label> ';
|
||
$html .= '<label>' . esc_html__('部门', 'aksrt') . ' <select name="ticket_type_id">';
|
||
foreach (\AKSRT\Ticket\TicketTypeService::all() as $row) {
|
||
printf(
|
||
'<option value="%d"%s>%s</option>',
|
||
(int) $row['id'],
|
||
selected((int) $ticket['ticket_type_id'], (int) $row['id'], false),
|
||
esc_html((string) $row['name'])
|
||
);
|
||
}
|
||
$html .= '</select></label> ';
|
||
$html .= '<label>' . esc_html__('跟进人 ID', 'aksrt') . ' <input type="number" name="admin_id" value="' . esc_attr((string) $ticket['admin_id']) . '" min="0" style="width:80px;" /></label> ';
|
||
$html .= '<button type="submit" class="button">' . esc_html__('保存', 'aksrt') . '</button></p>';
|
||
$html .= '</form>';
|
||
|
||
// 后台备注
|
||
$html .= '<h4>' . esc_html__('后台备注', 'aksrt') . '</h4>';
|
||
if ($notes === []) {
|
||
$html .= '<p class="description">' . esc_html__('暂无备注', 'aksrt') . '</p>';
|
||
}
|
||
$html .= '<ul class="aksrt-ticket-detail__notes">';
|
||
foreach ($notes as $note) {
|
||
$html .= '<li><strong>' . esc_html((string) $note['admin_name']) . '</strong>'
|
||
. ' <time>' . esc_html((string) $note['created_at']) . '</time>'
|
||
. '<div>' . nl2br(esc_html((string) $note['content'])) . '</div></li>';
|
||
}
|
||
$html .= '</ul>';
|
||
$html .= '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
|
||
$html .= '<input type="hidden" name="action" value="aksrt_ticket_update" />';
|
||
$html .= '<input type="hidden" name="ticket_action" value="note" />';
|
||
$html .= '<input type="hidden" name="ticket_id" value="' . esc_attr((string) $id) . '" />';
|
||
$html .= wp_nonce_field('aksrt_ticket_note_' . $id, '_wpnonce', true, false);
|
||
$html .= '<p><input type="text" name="content" class="large-text" placeholder="' . esc_attr__('添加内部备注(用户不可见)', 'aksrt') . '" /> '
|
||
. '<button type="submit" class="button">' . esc_html__('添加备注', 'aksrt') . '</button></p>';
|
||
$html .= '</form>';
|
||
|
||
$html .= '</div>';
|
||
return $html;
|
||
}
|
||
}
|