Files
wordpress-theme-aksrt/src/Admin/Native/UsersMenusTicketsList.php
T
kate522andClaude Fable 5 e7ca0ee5df refactor(ticket): move the config tab into the SPA ticket center
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>
2026-10-26 19:48:58 +08:00

269 lines
14 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
/**
* 工单管理后台页 — 工单列表 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;
}
}