feat(ticket): replace the after-sale flow with a general ticket system

The old 售后 flow was an order-refund appendage: tickets were hard-bound to an
order number, users could only file them from 「我的订单」against a paid order,
and the admin action bottomed out at 「标记订单已退款」. It could not carry
account questions or feature requests that have nothing to do with an order.

Replace it with a general ticket system modelled on 魔方财务云's idcsmart_ticket:
title + department + description are required, linking an order is optional
(user's own paid orders only). Add departments, custom statuses, preset replies
and internal admin notes as four configuration surfaces. Conversation history
moves off the ticket row's JSON log into dedicated ticket_reply rows, which
unlocks quoted replies. Front end gets a 工单中心 panel under 我的服务; back end
moves from 支付中心 → 售后服务 to users.php → 工单管理.

Per CLAUDE.md's rewrite-over-compatibility rule: drop the old table, build new
ones, delete the old code. No dual paths, no data migration. The upstream
plugin's upstream/downstream delivery and internal-ticket conversion are
deliberately left out — both are multi-provider platform concerns with no
meaning on a single WordPress site.

Data: order_aftersale becomes six aksrt_ticket* tables (ticket, ticket_reply,
ticket_status, ticket_type, ticket_prereply, ticket_note). SCHEMA_VERSION
2.6.0 -> 3.0.0. Status and department seeds are idempotent; the default status
「待接单」is flagged as non-editable.

Domain: new src/Ticket/ (13 files, all under 460 lines). Attachments reuse
UserController::rest_upload_comment_image with a ticket-specific permission
callback, mirroring the existing /report/upload-image route rather than
growing a second upload path.

Notifications go through AKSRT\Messages\Message::send() and deep-link to the
right surface; email/SMS stay out of scope since the theme's mail and SMS
stacks are separate implementations.

Refund gate opens up: marking an order refunded no longer requires an in-flight
ticket, only that the order is paid.

Also generalise a hardcoded special case in tools/audit-admin-contracts.php —
schema domain inference now reads the parent directory instead of matching
"PostList" by name, so new leaf schema files need no per-name exception.

Verification:
  php tools/audit-admin-contracts.php   exit 0, errors=0
  node --test (widget-system + admin)   664 pass / 0 fail
  php -l over all new/changed files     clean
  node --check over all JS              clean
  npm run build + build:public          clean

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kate522andClaude Opus 5 committed 2026-10-25 23:29:51 +08:00
1 parent 761c84d70f
commit dbb7de41e9
81 files changed
+5812 -4179

No files matched your search

+271
View File
@@ -0,0 +1,271 @@
<?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 .= '<input type="hidden" name="ticket_tab" value="tickets" />';
$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 .= '<input type="hidden" name="ticket_tab" value="tickets" />';
$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 .= '<input type="hidden" name="ticket_tab" value="tickets" />';
$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;
}
}