Files
wordpress-theme-aksrt/src/Admin/Native/UsersMenusTicketsList.php
T
kate522andClaude Fable 5 63ab8b8fc9 feat(tickets): make ticket title/content/reply length limits configurable
Add ticket_title_max / ticket_content_max / ticket_reply_max to the
users/ticket schema (PHP + SPA) with clamped TicketSettings readers.
All write paths (front REST, admin REST, note REST, native admin page)
and front-end maxlength/counters now read the configured limits.
Remove the hardcoded TICKET_CONTENT_MAX constant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-27 23:06:28 +08:00

284 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
/**
* 工单管理后台页 — 代客提交卡片、状态视图、筛选与工单列表。
*
* 详情面板见 UsersMenusTicketsDetail;写操作见 UsersMenusTicketsCenter。
*
* @package AKSRT
*/
namespace AKSRT\Admin\Native;
defined('ABSPATH') || exit;
trait UsersMenusTicketsList {
/** 代客提交:原生 <details> 折叠,失败回跳时自动展开。 */
private function render_ticket_create_form(): void {
$types = \AKSRT\Ticket\TicketTypeService::all();
$failed = isset($_GET['aksrt_ticket_msg']) && sanitize_key((string) wp_unslash($_GET['aksrt_ticket_msg'])) === 'create_failed';
echo '<details class="aksrt-tickets-admin__create"' . ($failed ? ' open' : '') . '>';
echo '<summary><span class="dashicons dashicons-plus-alt2" aria-hidden="true"></span>' . esc_html__('代客提交工单', 'aksrt') . '</summary>';
if ($types === []) {
printf(
'<p class="aksrt-tickets-admin__create-empty">%s <a href="%s">%s</a></p></details>',
esc_html__('尚未配置工单部门,无法创建工单。', 'aksrt'),
esc_url(admin_url('admin.php?page=aksrt-settings#/users/ticket')),
esc_html__('前往配置', 'aksrt')
);
return;
}
echo $this->ticket_form_open('create', 0, 'aksrt-tickets-admin__create-form'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
wp_nonce_field('aksrt_ticket_create_0');
echo '<div class="aksrt-tickets-admin__create-grid">';
printf(
'<label><span>%s</span><input type="number" name="user_id" min="1" required class="regular-text" placeholder="%s" /></label>',
esc_html__('用户 ID', 'aksrt'),
esc_attr__('工单归属用户', 'aksrt')
);
echo '<label><span>' . esc_html__('部门', 'aksrt') . '</span><select name="ticket_type_id" required>';
foreach ($types as $row) {
printf('<option value="%d">%s</option>', (int) $row['id'], esc_html((string) $row['name']));
}
echo '</select></label>';
printf(
'<label class="is-wide"><span>%s</span><input type="text" name="title" maxlength="%d" required class="large-text" /></label>',
esc_html__('标题', 'aksrt'),
\AKSRT\Ticket\TicketSettings::title_max()
);
if (\AKSRT\Ticket\TicketSettings::order_link_enabled()) {
printf(
'<label class="is-wide"><span>%s</span><input type="text" name="order_number" class="regular-text" placeholder="%s" /></label>',
esc_html__('关联订单', 'aksrt'),
esc_attr__('可选', 'aksrt')
);
}
printf(
'<label class="is-wide"><span>%s</span><textarea name="content" rows="4" maxlength="%d" required class="large-text"></textarea></label>',
esc_html__('问题描述', 'aksrt'),
\AKSRT\Ticket\TicketSettings::content_max()
);
echo '</div>';
printf(
'<p class="aksrt-tickets-admin__create-foot"><span class="description">%s</span><button type="submit" class="button button-primary">%s</button></p>',
esc_html__('创建后状态为「已回复」并由你跟进,用户会收到通知。', 'aksrt'),
esc_html__('创建工单', 'aksrt')
);
echo '</form></details>';
}
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;
$open_id = isset($_GET['id']) ? absint($_GET['id']) : 0;
$filters = array_filter([
'status' => $status,
'ticket_type_id' => $type_id,
's' => $s,
], static fn($value): bool => $value !== '' && $value !== 0);
$view_url = $this->ticket_page_url(array_merge($filters, $paged > 1 ? ['paged' => $paged] : []));
$this->render_ticket_status_views($status);
$this->render_ticket_filter_form($status, $type_id, $s);
$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'];
echo '<div class="aksrt-tickets-admin__table-wrap">';
echo '<table class="widefat striped aksrt-tickets-admin__table">';
echo '<thead><tr>';
echo '<th class="column-title">' . esc_html__('工单', 'aksrt') . '</th>';
echo '<th class="column-status">' . esc_html__('状态', 'aksrt') . '</th>';
echo '<th class="column-type">' . esc_html__('部门', 'aksrt') . '</th>';
echo '<th class="column-user">' . esc_html__('用户', 'aksrt') . '</th>';
echo '<th class="column-admin">' . esc_html__('跟进人', 'aksrt') . '</th>';
echo '<th class="column-time">' . esc_html__('最后活动', 'aksrt') . '</th>';
echo '</tr></thead><tbody>';
if ($items === []) {
$hint = $filters === []
? __('暂无进行中的工单,已关闭工单可在「已关闭」视图查看。', 'aksrt')
: __('当前筛选条件下没有工单。', 'aksrt');
printf(
'<tr class="no-items"><td colspan="6"><div class="aksrt-tickets-admin__empty"><strong>%s</strong><span>%s</span></div></td></tr>',
esc_html__('暂无工单', 'aksrt'),
esc_html($hint)
);
}
foreach ($items as $ticket) {
$this->render_ticket_center_row($ticket, $view_url, $open_id === (int) $ticket['id']);
}
echo '</tbody></table></div>';
\AKSRT\Admin\Native\AdminListPagination::render(
self::TICKET_PAGE_SLUG,
$total,
$paged,
self::TICKET_PER_PAGE,
$filters,
'users.php'
);
}
/** 状态视图:未关闭(默认)+ 各进行中状态 + 完结态,均带计数。 */
private function render_ticket_status_views(string $current): void {
$repo = \AKSRT\Ticket\TicketRepository::get_instance();
// 默认口径(不带状态)已排除已关闭;完结态单独计数。
$counts = $repo->statistic([], true);
$open_total = 0;
$views = [];
foreach (\AKSRT\Ticket\TicketStatusService::all() as $row) {
$key = (string) $row['status_key'];
$count = !empty($row['is_closed']) ? $repo->count_by_status($key) : (int) ($counts[$key] ?? 0);
if (empty($row['is_closed'])) {
$open_total += $count;
}
$views[] = [$key, (string) $row['name'], $count, (string) $row['color']];
}
array_unshift($views, ['', __('未关闭', 'aksrt'), $open_total, '']);
echo '<ul class="subsubsub aksrt-tickets-admin__views">';
$last = count($views) - 1;
foreach ($views as $i => [$key, $label, $count, $color]) {
printf(
'<li><a href="%s"%s>%s%s <span class="count">(%d)</span></a>%s</li>',
esc_url($this->ticket_page_url($key === '' ? [] : ['status' => $key])),
$current === $key ? ' class="current" aria-current="page"' : '',
$color !== '' ? '<i class="aksrt-ticket-dot" style="--aksrt-ticket-color:' . esc_attr($color) . '"></i>' : '',
esc_html($label),
$count,
$i < $last ? ' |' : ''
);
}
echo '</ul>';
}
/** 部门 + 关键词筛选(状态由视图链接决定,以隐藏域保留)。 */
private function render_ticket_filter_form(string $status, int $type_id, string $s): void {
echo '<form method="get" action="' . esc_url(admin_url('users.php')) . '" class="aksrt-tickets-admin__filters">';
echo '<input type="hidden" name="page" value="' . esc_attr(self::TICKET_PAGE_SLUG) . '" />';
if ($status !== '') {
echo '<input type="hidden" name="status" value="' . esc_attr($status) . '" />';
}
echo '<label class="screen-reader-text" for="aksrt-ticket-filter-type">' . esc_html__('按部门筛选', 'aksrt') . '</label>';
echo '<select name="ticket_type_id" id="aksrt-ticket-filter-type">';
echo '<option value="">' . esc_html__('全部部门', 'aksrt') . '</option>';
foreach (\AKSRT\Ticket\TicketTypeService::all() 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 '<label class="screen-reader-text" for="aksrt-ticket-filter-s">' . esc_html__('搜索工单', 'aksrt') . '</label>';
printf(
'<input type="search" id="aksrt-ticket-filter-s" name="s" value="%s" placeholder="%s" />',
esc_attr($s),
esc_attr__('工单号 / 标题', 'aksrt')
);
submit_button(__('筛选', 'aksrt'), '', '', false);
if ($type_id > 0 || $s !== '') {
printf(
'<a class="button-link" href="%s">%s</a>',
esc_url($this->ticket_page_url($status === '' ? [] : ['status' => $status])),
esc_html__('清除筛选', 'aksrt')
);
}
echo '</form>';
}
/** @param array<string,mixed> $ticket */
private function render_ticket_center_row(array $ticket, string $view_url, bool $is_open): void {
$id = (int) $ticket['id'];
$toggle_url = $is_open
? remove_query_arg('id', $view_url)
: add_query_arg('id', $id, $view_url) . '#aksrt-ticket-' . $id;
$last = (string) $ticket['last_reply_time'] !== '' ? (string) $ticket['last_reply_time'] : (string) $ticket['post_time'];
$awaiting = in_array((string) $ticket['status'], [
\AKSRT\Ticket\TicketStatusService::KEY_PENDING,
\AKSRT\Ticket\TicketStatusService::KEY_USER_REPLIED,
], true);
printf(
'<tr id="aksrt-ticket-%1$d" class="aksrt-tickets-admin__row%2$s">'
. '<td class="column-title"><a class="row-title" href="%3$s" aria-expanded="%4$s">%5$s</a>%15$s'
. '<div class="aksrt-tickets-admin__sub"><code>%6$s</code>%7$s</div></td>'
. '<td class="column-status"><span class="aksrt-ticket-badge" style="--aksrt-ticket-color:%8$s">%9$s</span></td>'
. '<td class="column-type">%10$s</td>'
. '<td class="column-user">%11$s</td>'
. '<td class="column-admin">%12$s</td>'
. '<td class="column-time"><span>%13$s</span><div class="aksrt-tickets-admin__sub">%14$s</div></td></tr>',
$id,
($is_open ? ' is-open' : '') . ($awaiting ? ' is-awaiting' : ''),
esc_url($toggle_url),
$is_open ? 'true' : 'false',
esc_html((string) $ticket['title']),
esc_html((string) $ticket['ticket_num']),
(string) $ticket['order_number'] !== ''
? ' · ' . esc_html__('订单', 'aksrt') . ' ' . esc_html((string) $ticket['order_number'])
: '',
esc_attr((string) $ticket['status_color']),
esc_html((string) $ticket['status_label']),
esc_html((string) $ticket['ticket_type_name'] !== '' ? (string) $ticket['ticket_type_name'] : '—'),
$this->ticket_user_chip((int) $ticket['user_id'], (string) $ticket['user_name']), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
(int) $ticket['admin_id'] > 0
? esc_html((string) $ticket['admin_name'])
: '<span class="aksrt-tickets-admin__muted">' . esc_html__('未指派', 'aksrt') . '</span>',
esc_html($last),
esc_html(sprintf(
/* translators: %s submit time */
__('提交于 %s', 'aksrt'),
(string) $ticket['post_time']
)),
$this->ticket_urge_badge($ticket) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
);
if ($is_open) {
echo '<tr class="aksrt-tickets-admin__detail-row"><td colspan="6">' . $this->render_ticket_detail_panel($ticket) . '</td></tr>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
}
/** 催单徽标:次数 + 悬停显示最近催单时间;已完结或未催过不输出。 */
private function ticket_urge_badge(array $ticket): string {
$count = (int) ($ticket['urge_count'] ?? 0);
if ($count <= 0 || !empty($ticket['is_closed'])) {
return '';
}
return ' <span class="aksrt-ticket-urge" title="' . esc_attr(sprintf(
/* translators: %s last urge time */
__('最近催单:%s', 'aksrt'),
(string) $ticket['last_urge_time']
)) . '">' . esc_html(sprintf(
/* translators: %d urge count */
__('催单 ×%d', 'aksrt'),
$count
)) . '</span>';
}
/** 头像 + 名称(链接到用户编辑页)。后台不走前台懒加载,直接输出 src。 */
private function ticket_user_chip(int $user_id, string $name): string {
$avatar = function_exists('aksrt_get_avatar_url') ? aksrt_get_avatar_url($user_id, 48) : '';
$label = $name !== '' ? $name : '#' . $user_id;
return '<a class="aksrt-ticket-user" href="' . esc_url(get_edit_user_link($user_id)) . '">'
. ($avatar !== '' ? '<img src="' . esc_url($avatar) . '" alt="" width="24" height="24" />' : '')
. '<span>' . esc_html($label) . '</span></a>';
}
}