Add NativeUi helper + native-ui.css (tokens mirror the SPA Naive UI theme) and move the pay center, orders, products, coupons, charge cards, income, withdrawals, tickets, invite codes, VIP, verify, messages and report/ban pages onto shared header, stat cards, cards, filters, badges and empty states. Remove inline styles and dead helpers; split PayChargeCards into sibling traits to stay under the file size limit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
288 lines
14 KiB
PHP
288 lines
14 KiB
PHP
<?php
|
||
/**
|
||
* 工单管理后台页 — 行内展开的详情面板。
|
||
*
|
||
* 双栏:左侧为原始问题 + 沟通记录 + 回复框;右侧为工单信息、处理(状态 / 部门 / 跟进人)
|
||
* 与内部备注。已关闭工单只读:不再输出回复框与处理表单。
|
||
*
|
||
* @package AKSRT
|
||
*/
|
||
|
||
namespace AKSRT\Admin\Native;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
trait UsersMenusTicketsDetail {
|
||
|
||
/**
|
||
* @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);
|
||
$closed = !empty($ticket['is_closed']);
|
||
|
||
$html = '<div class="aksrt-ticket-detail" data-aksrt-ticket-detail="' . esc_attr((string) $id) . '">';
|
||
|
||
// ---- 左栏:对话 ----
|
||
$html .= '<div class="aksrt-ticket-detail__main">';
|
||
$html .= '<article class="aksrt-ticket-detail__origin">';
|
||
$html .= $this->ticket_message_head(
|
||
(int) $ticket['user_id'],
|
||
(string) $ticket['user_name'],
|
||
__('提交了工单', 'aksrt'),
|
||
(string) $ticket['post_time']
|
||
);
|
||
$html .= '<div class="aksrt-ticket-detail__body">' . wpautop(esc_html((string) $ticket['content'])) . '</div>';
|
||
$html .= $this->ticket_attachments_html((array) $ticket['attachment']);
|
||
$html .= '</article>';
|
||
|
||
$html .= '<h4 class="aksrt-ticket-detail__heading">' . esc_html(sprintf(
|
||
/* translators: %d reply count */
|
||
__('沟通记录(%d)', 'aksrt'),
|
||
count($replies)
|
||
)) . '</h4>';
|
||
if ($replies === []) {
|
||
$html .= '<p class="aksrt-ticket-detail__empty">' . esc_html__('暂无沟通记录', 'aksrt') . '</p>';
|
||
} else {
|
||
$html .= '<ol class="aksrt-ticket-detail__replies">';
|
||
foreach ($replies as $reply) {
|
||
if ((string) $reply['author_type'] === 'system') {
|
||
// 系统记录(如催单):居中提示行,无头像 / 引用 / 附件。
|
||
$html .= '<li class="aksrt-ticket-detail__system"><span>' . esc_html((string) $reply['content'])
|
||
. '</span><time>' . esc_html((string) $reply['created_at']) . '</time></li>';
|
||
continue;
|
||
}
|
||
$is_admin = (string) $reply['author_type'] === 'admin';
|
||
$html .= '<li class="aksrt-ticket-detail__reply is-' . ($is_admin ? 'admin' : 'user') . '">';
|
||
$html .= $this->ticket_message_head(
|
||
(int) $reply['author_id'],
|
||
(string) $reply['author_name'],
|
||
$is_admin ? __('客服', 'aksrt') : __('用户', 'aksrt'),
|
||
(string) $reply['created_at']
|
||
);
|
||
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__body">' . wpautop(esc_html((string) $reply['content'])) . '</div>';
|
||
$html .= $this->ticket_attachments_html((array) $reply['attachment']);
|
||
$html .= '</li>';
|
||
}
|
||
$html .= '</ol>';
|
||
}
|
||
|
||
if ($closed) {
|
||
$html .= '<p class="aksrt-ticket-detail__closed"><span class="dashicons dashicons-lock" aria-hidden="true"></span>'
|
||
. esc_html__('工单已关闭,无法继续回复。', 'aksrt') . '</p>';
|
||
} else {
|
||
$html .= $this->ticket_reply_form($id);
|
||
}
|
||
$html .= '</div>';
|
||
|
||
// ---- 右栏:信息 / 处理 / 备注 ----
|
||
$html .= '<aside class="aksrt-ticket-detail__side">';
|
||
$html .= $this->ticket_info_card($ticket);
|
||
if (!$closed) {
|
||
$html .= $this->ticket_status_form($ticket);
|
||
}
|
||
|
||
$html .= '<section class="aksrt-ticket-detail__card">';
|
||
$html .= '<h4 class="aksrt-ticket-detail__heading">' . esc_html__('内部备注', 'aksrt')
|
||
. ' <span class="aksrt-native-muted">' . esc_html__('用户不可见', 'aksrt') . '</span></h4>';
|
||
if ($notes === []) {
|
||
$html .= '<p class="aksrt-ticket-detail__empty">' . esc_html__('暂无备注', 'aksrt') . '</p>';
|
||
} else {
|
||
$html .= '<ul class="aksrt-ticket-detail__notes">';
|
||
foreach ($notes as $note) {
|
||
$html .= '<li><div class="aksrt-ticket-detail__note-head"><strong>' . esc_html((string) $note['admin_name']) . '</strong>'
|
||
. '<time>' . esc_html((string) $note['created_at']) . '</time></div>'
|
||
. '<div>' . nl2br(esc_html((string) $note['content'])) . '</div></li>';
|
||
}
|
||
$html .= '</ul>';
|
||
}
|
||
$html .= $this->ticket_form_open('note', $id, 'aksrt-ticket-detail__note-form');
|
||
$html .= wp_nonce_field('aksrt_ticket_note_' . $id, '_wpnonce', true, false);
|
||
$html .= '<label class="screen-reader-text" for="aksrt-ticket-note-' . $id . '">' . esc_html__('备注内容', 'aksrt') . '</label>';
|
||
$html .= '<textarea id="aksrt-ticket-note-' . $id . '" name="content" rows="2" required maxlength="' . (int) \AKSRT\Ticket\TicketSettings::reply_max() . '" placeholder="' . esc_attr__('记录处理进展、排查结论…', 'aksrt') . '"></textarea>';
|
||
$html .= '<button type="submit" class="button">' . esc_html__('添加备注', 'aksrt') . '</button>';
|
||
$html .= '</form>';
|
||
$html .= '</section>';
|
||
|
||
$html .= '</aside>';
|
||
$html .= '</div>';
|
||
return $html;
|
||
}
|
||
|
||
private function ticket_reply_form(int $id): string {
|
||
$field = 'aksrt-ticket-reply-' . $id;
|
||
$html = $this->ticket_form_open('reply', $id, 'aksrt-ticket-detail__reply-form');
|
||
$html .= wp_nonce_field('aksrt_ticket_reply_' . $id, '_wpnonce', true, false);
|
||
$html .= '<label class="aksrt-ticket-detail__heading" for="' . esc_attr($field) . '">' . esc_html__('回复用户', 'aksrt') . '</label>';
|
||
$html .= '<textarea id="' . esc_attr($field) . '" name="content" rows="5" required maxlength="' . (int) \AKSRT\Ticket\TicketSettings::reply_max() . '"'
|
||
. ' data-aksrt-ticket-reply-input placeholder="' . esc_attr__('填写回复内容,提交后用户将收到通知', 'aksrt') . '"></textarea>';
|
||
$html .= '<div class="aksrt-ticket-detail__reply-foot">';
|
||
|
||
$prereplies = \AKSRT\Ticket\TicketPrereplyService::all();
|
||
if ($prereplies !== []) {
|
||
$html .= '<label class="screen-reader-text" for="' . esc_attr($field) . '-pre">' . esc_html__('插入预设回复', 'aksrt') . '</label>';
|
||
$html .= '<select id="' . esc_attr($field) . '-pre" data-aksrt-ticket-prereply="' . esc_attr($field) . '">';
|
||
$html .= '<option value="">' . esc_html__('插入预设回复…', 'aksrt') . '</option>';
|
||
foreach ($prereplies as $row) {
|
||
$content = (string) $row['content'];
|
||
$html .= sprintf(
|
||
'<option value="%s">%s</option>',
|
||
esc_attr($content),
|
||
esc_html(mb_strimwidth($content, 0, 40, '…'))
|
||
);
|
||
}
|
||
$html .= '</select>';
|
||
}
|
||
$html .= '<span class="aksrt-ticket-detail__counter" data-aksrt-ticket-counter="' . esc_attr($field) . '">0 / ' . (int) \AKSRT\Ticket\TicketSettings::reply_max() . '</span>';
|
||
$html .= '<button type="submit" class="button button-primary">' . esc_html__('提交回复', 'aksrt') . '</button>';
|
||
$html .= '</div></form>';
|
||
return $html;
|
||
}
|
||
|
||
/** @param array<string,mixed> $ticket */
|
||
private function ticket_info_card(array $ticket): string {
|
||
$rows = [
|
||
__('工单号', 'aksrt') => '<code>' . esc_html((string) $ticket['ticket_num']) . '</code>',
|
||
__('状态', 'aksrt') => '<span class="aksrt-ticket-badge" style="--aksrt-ticket-color:' . esc_attr((string) $ticket['status_color']) . '">'
|
||
. esc_html((string) $ticket['status_label']) . '</span>',
|
||
__('用户', 'aksrt') => $this->ticket_user_chip((int) $ticket['user_id'], (string) $ticket['user_name']),
|
||
__('部门', 'aksrt') => esc_html((string) $ticket['ticket_type_name'] !== '' ? (string) $ticket['ticket_type_name'] : '—'),
|
||
__('跟进人', 'aksrt') => (int) $ticket['admin_id'] > 0
|
||
? esc_html((string) $ticket['admin_name'])
|
||
: '<span class="aksrt-native-muted">' . esc_html__('未指派', 'aksrt') . '</span>',
|
||
];
|
||
if ((string) $ticket['order_number'] !== '') {
|
||
$rows[__('关联订单', 'aksrt')] = '<code>' . esc_html((string) $ticket['order_number']) . '</code>';
|
||
}
|
||
$rows[__('提交时间', 'aksrt')] = esc_html((string) $ticket['post_time']);
|
||
$rows[__('最后回复', 'aksrt')] = esc_html((string) $ticket['last_reply_time'] !== '' ? (string) $ticket['last_reply_time'] : '—');
|
||
if ((int) $ticket['urge_count'] > 0) {
|
||
$rows[__('催单', 'aksrt')] = '<span class="aksrt-ticket-urge">' . esc_html(sprintf(
|
||
/* translators: %d urge count */
|
||
__('%d 次', 'aksrt'),
|
||
(int) $ticket['urge_count']
|
||
)) . '</span> <span class="aksrt-native-muted">' . esc_html(sprintf(
|
||
/* translators: %s last urge time */
|
||
__('最近 %s', 'aksrt'),
|
||
(string) $ticket['last_urge_time']
|
||
)) . '</span>';
|
||
}
|
||
|
||
$html = '<section class="aksrt-ticket-detail__card"><dl class="aksrt-ticket-detail__info">';
|
||
foreach ($rows as $label => $value) {
|
||
$html .= '<dt>' . esc_html($label) . '</dt><dd>' . $value . '</dd>';
|
||
}
|
||
return $html . '</dl></section>';
|
||
}
|
||
|
||
/** 处理表单:状态仅列出 can_transition 允许的目标。 */
|
||
private function ticket_status_form(array $ticket): string {
|
||
$id = (int) $ticket['id'];
|
||
$from = (string) $ticket['status'];
|
||
|
||
$html = '<section class="aksrt-ticket-detail__card">';
|
||
$html .= '<h4 class="aksrt-ticket-detail__heading">' . esc_html__('处理', 'aksrt') . '</h4>';
|
||
$html .= $this->ticket_form_open('status', $id, 'aksrt-ticket-detail__status-form');
|
||
$html .= wp_nonce_field('aksrt_ticket_status_' . $id, '_wpnonce', true, false);
|
||
|
||
$html .= '<label><span>' . esc_html__('状态', 'aksrt') . '</span><select name="status">';
|
||
foreach (\AKSRT\Ticket\TicketStatusService::all() as $row) {
|
||
$key = (string) $row['status_key'];
|
||
if (!\AKSRT\Ticket\TicketStatusService::can_transition($from, $key)) {
|
||
continue;
|
||
}
|
||
$html .= sprintf(
|
||
'<option value="%s"%s>%s</option>',
|
||
esc_attr($key),
|
||
selected($from, $key, false),
|
||
esc_html((string) $row['name'])
|
||
);
|
||
}
|
||
$html .= '</select></label>';
|
||
|
||
$html .= '<label><span>' . esc_html__('部门', 'aksrt') . '</span><select name="ticket_type_id">';
|
||
foreach (\AKSRT\Ticket\TicketTypeService::all() as $row) {
|
||
$html .= sprintf(
|
||
'<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><span>' . esc_html__('跟进人', 'aksrt') . '</span><select name="admin_id">';
|
||
$html .= '<option value="0">' . esc_html__('未指派', 'aksrt') . '</option>';
|
||
$current = (int) $ticket['admin_id'];
|
||
$admins = $this->ticket_admin_options();
|
||
if ($current > 0 && !isset($admins[$current])) {
|
||
$admins[$current] = (string) $ticket['admin_name'];
|
||
}
|
||
foreach ($admins as $admin_id => $name) {
|
||
$html .= sprintf(
|
||
'<option value="%d"%s>%s</option>',
|
||
(int) $admin_id,
|
||
selected($current, (int) $admin_id, false),
|
||
esc_html($admin_id === get_current_user_id() ? sprintf(
|
||
/* translators: %s current admin name */
|
||
__('%s(我)', 'aksrt'),
|
||
$name
|
||
) : $name)
|
||
);
|
||
}
|
||
$html .= '</select></label>';
|
||
|
||
$html .= '<button type="submit" class="button">' . esc_html__('保存', 'aksrt') . '</button>';
|
||
$html .= '</form></section>';
|
||
return $html;
|
||
}
|
||
|
||
/** @return array<int,string> 可指派的管理员 user_id => 显示名(单请求内缓存)。 */
|
||
private function ticket_admin_options(): array {
|
||
static $options = null;
|
||
if ($options === null) {
|
||
$options = [];
|
||
foreach (get_users([
|
||
'capability' => self::CAPABILITY,
|
||
'fields' => ['ID', 'display_name'],
|
||
'orderby' => 'display_name',
|
||
'number' => 200,
|
||
]) as $user) {
|
||
$options[(int) $user->ID] = (string) $user->display_name;
|
||
}
|
||
}
|
||
return $options;
|
||
}
|
||
|
||
private function ticket_message_head(int $user_id, string $name, string $role, string $time): string {
|
||
$avatar = function_exists('aksrt_get_avatar_url') ? aksrt_get_avatar_url($user_id, 64) : '';
|
||
return '<header class="aksrt-ticket-detail__msg-head">'
|
||
. ($avatar !== '' ? '<img src="' . esc_url($avatar) . '" alt="" width="32" height="32" />' : '')
|
||
. '<strong>' . esc_html($name !== '' ? $name : '#' . $user_id) . '</strong>'
|
||
. '<span class="aksrt-native-muted">' . esc_html($role) . '</span>'
|
||
. '<time>' . esc_html($time) . '</time></header>';
|
||
}
|
||
|
||
/** @param list<string> $urls 图片附件(TicketAttachment 已清洗) */
|
||
private function ticket_attachments_html(array $urls): string {
|
||
if ($urls === []) {
|
||
return '';
|
||
}
|
||
$html = '<ul class="aksrt-ticket-detail__attachments">';
|
||
foreach ($urls as $url) {
|
||
$html .= '<li><a href="' . esc_url((string) $url) . '" target="_blank" rel="noopener noreferrer">'
|
||
. '<img src="' . esc_url((string) $url) . '" alt="' . esc_attr__('附件', 'aksrt') . '" /></a></li>';
|
||
}
|
||
return $html . '</ul>';
|
||
}
|
||
}
|