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>
166 lines
9.8 KiB
PHP
166 lines
9.8 KiB
PHP
<?php
|
|
/**
|
|
* 工单管理后台页 — 配置 Tab 渲染(部门 / 状态 / 预设回复)。
|
|
*
|
|
* @package AKSRT
|
|
*/
|
|
|
|
namespace AKSRT\Admin\Native;
|
|
|
|
defined('ABSPATH') || exit;
|
|
|
|
trait UsersMenusTicketsConfig {
|
|
|
|
private function render_ticket_config_tab(): void {
|
|
$this->render_ticket_types_panel();
|
|
$this->render_ticket_statuses_panel();
|
|
$this->render_ticket_prereplies_panel();
|
|
}
|
|
|
|
/** 部门:表格 + 行内编辑 + 新建表单。 */
|
|
private function render_ticket_types_panel(): void {
|
|
$types = \AKSRT\Ticket\TicketTypeService::all();
|
|
$edit_id = isset($_GET['type_id']) ? absint($_GET['type_id']) : 0;
|
|
$editing = null;
|
|
foreach ($types as $row) {
|
|
if ((int) $row['id'] === $edit_id) {
|
|
$editing = $row;
|
|
break;
|
|
}
|
|
}
|
|
|
|
echo '<h3>' . esc_html__('工单部门', 'aksrt') . '</h3>';
|
|
echo '<table class="widefat fixed striped posts" style="max-width:900px;">';
|
|
echo '<thead><tr><th width="8%">ID</th><th width="24%">' . esc_html__('名称', 'aksrt') . '</th>'
|
|
. '<th width="38%">' . esc_html__('说明', 'aksrt') . '</th>'
|
|
. '<th width="20%">' . esc_html__('受理管理员', 'aksrt') . '</th><th width="10%"></th></tr></thead><tbody>';
|
|
foreach ($types as $row) {
|
|
$edit_url = add_query_arg(['type_id' => (int) $row['id']], $this->ticket_page_url(['tab' => 'config']));
|
|
$cells = sprintf(
|
|
'<tr><td>%d</td><td>%s</td><td>%s</td><td>%s</td><td>',
|
|
(int) $row['id'],
|
|
esc_html((string) $row['name']),
|
|
esc_html((string) $row['description'] !== '' ? (string) $row['description'] : '—'),
|
|
esc_html($row['admin_ids'] === [] ? '—' : implode(', ', $row['admin_ids']))
|
|
);
|
|
$cells .= '<a href="' . esc_url($edit_url) . '">' . esc_html__('编辑', 'aksrt') . '</a> ';
|
|
$cells .= $this->ticket_delete_button('type_delete', 'type_id', (int) $row['id'], __('删除', 'aksrt'));
|
|
$cells .= '</td></tr>';
|
|
echo $cells; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
|
}
|
|
echo '</tbody></table>';
|
|
|
|
echo '<h4>' . esc_html($editing === null ? __('新增部门', 'aksrt') : __('编辑部门', 'aksrt')) . '</h4>';
|
|
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
|
|
echo '<input type="hidden" name="action" value="aksrt_ticket_update" />';
|
|
echo '<input type="hidden" name="ticket_action" value="type" />';
|
|
echo '<input type="hidden" name="ticket_tab" value="config" />';
|
|
echo '<input type="hidden" name="type_id" value="' . esc_attr((string) ($editing['id'] ?? 0)) . '" />';
|
|
echo wp_nonce_field('aksrt_ticket_type_' . (int) ($editing['id'] ?? 0), '_wpnonce', true, false);
|
|
echo '<p><input type="text" name="name" value="' . esc_attr((string) ($editing['name'] ?? '')) . '" placeholder="' . esc_attr__('部门名称', 'aksrt') . '" /></p>';
|
|
echo '<p><input type="text" name="description" class="large-text" value="' . esc_attr((string) ($editing['description'] ?? '')) . '" placeholder="' . esc_attr__('部门说明(可选)', 'aksrt') . '" /></p>';
|
|
echo '<p><input type="text" name="admin_ids" class="large-text" value="' . esc_attr(implode(',', (array) ($editing['admin_ids'] ?? []))) . '" placeholder="' . esc_attr__('受理管理员用户 ID,逗号分隔(可选)', 'aksrt') . '" /></p>';
|
|
submit_button(__('保存部门', 'aksrt'), 'primary', '', false);
|
|
echo '</form>';
|
|
}
|
|
|
|
/** 自定义状态:默认状态拒绝改删。 */
|
|
private function render_ticket_statuses_panel(): void {
|
|
$statuses = \AKSRT\Ticket\TicketStatusService::all();
|
|
$edit_id = isset($_GET['status_id']) ? absint($_GET['status_id']) : 0;
|
|
$editing = null;
|
|
foreach ($statuses as $row) {
|
|
if ((int) $row['id'] === $edit_id) {
|
|
$editing = $row;
|
|
break;
|
|
}
|
|
}
|
|
|
|
echo '<h3>' . esc_html__('工单状态', 'aksrt') . '</h3>';
|
|
echo '<p class="description">' . esc_html__('默认状态不可修改或删除;删除非默认状态前需先清空该状态下的工单。', 'aksrt') . '</p>';
|
|
echo '<table class="widefat fixed striped posts" style="max-width:760px;">';
|
|
echo '<thead><tr><th width="8%">ID</th><th width="22%">' . esc_html__('名称', 'aksrt') . '</th>'
|
|
. '<th width="18%">' . esc_html__('颜色', 'aksrt') . '</th>'
|
|
. '<th width="16%">' . esc_html__('完结', 'aksrt') . '</th>'
|
|
. '<th width="16%">' . esc_html__('默认', 'aksrt') . '</th><th width="20%"></th></tr></thead><tbody>';
|
|
foreach ($statuses as $row) {
|
|
$status_cells = sprintf(
|
|
'<tr><td>%d</td><td>%s</td>'
|
|
. '<td><span class="aksrt-ticket-badge" style="--aksrt-ticket-color:%s">%s</span></td>'
|
|
. '<td>%s</td><td>%s</td><td>',
|
|
(int) $row['id'],
|
|
esc_html((string) $row['name']),
|
|
esc_attr((string) $row['color']),
|
|
esc_html((string) $row['color']),
|
|
esc_html(!empty($row['is_closed']) ? __('是', 'aksrt') : __('否', 'aksrt')),
|
|
esc_html(!empty($row['is_default']) ? __('是', 'aksrt') : __('否', 'aksrt'))
|
|
);
|
|
if (empty($row['is_default'])) {
|
|
$status_cells .= '<a href="' . esc_url(add_query_arg(['status_id' => (int) $row['id']], $this->ticket_page_url(['tab' => 'config']))) . '">' . esc_html__('编辑', 'aksrt') . '</a> ';
|
|
$status_cells .= $this->ticket_delete_button('status_delete', 'status_id', (int) $row['id'], __('删除', 'aksrt'));
|
|
} else {
|
|
$status_cells .= '—';
|
|
}
|
|
$status_cells .= '</td></tr>';
|
|
echo $status_cells; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
|
}
|
|
echo '</tbody></table>';
|
|
|
|
echo '<h4>' . esc_html($editing === null ? __('新增状态', 'aksrt') : __('编辑状态', 'aksrt')) . '</h4>';
|
|
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
|
|
echo '<input type="hidden" name="action" value="aksrt_ticket_update" />';
|
|
echo '<input type="hidden" name="ticket_action" value="status_def" />';
|
|
echo '<input type="hidden" name="ticket_tab" value="config" />';
|
|
echo '<input type="hidden" name="status_id" value="' . esc_attr((string) ($editing['id'] ?? 0)) . '" />';
|
|
echo wp_nonce_field('aksrt_ticket_status_def_' . (int) ($editing['id'] ?? 0), '_wpnonce', true, false);
|
|
echo '<p><input type="text" name="name" value="' . esc_attr((string) ($editing['name'] ?? '')) . '" placeholder="' . esc_attr__('状态名称', 'aksrt') . '" /> ';
|
|
echo '<input type="text" name="color" value="' . esc_attr((string) ($editing['color'] ?? '#6b7280')) . '" placeholder="#6b7280" style="width:120px;" /> ';
|
|
echo '<label><input type="checkbox" name="is_closed" value="1"' . checked(!empty($editing['is_closed']), true, false) . ' /> ' . esc_html__('完结状态', 'aksrt') . '</label> ';
|
|
submit_button(__('保存状态', 'aksrt'), 'primary', '', false);
|
|
echo '</p></form>';
|
|
}
|
|
|
|
/** 预设回复:表格 + 行内编辑 + 新建表单。 */
|
|
private function render_ticket_prereplies_panel(): void {
|
|
$rows = \AKSRT\Ticket\TicketPrereplyService::all();
|
|
$edit_id = isset($_GET['prereply_id']) ? absint($_GET['prereply_id']) : 0;
|
|
$editing = null;
|
|
foreach ($rows as $row) {
|
|
if ((int) $row['id'] === $edit_id) {
|
|
$editing = $row;
|
|
break;
|
|
}
|
|
}
|
|
|
|
echo '<h3>' . esc_html__('预设回复', 'aksrt') . '</h3>';
|
|
echo '<table class="widefat fixed striped posts" style="max-width:900px;">';
|
|
echo '<thead><tr><th width="8%">ID</th><th width="72%">' . esc_html__('内容', 'aksrt') . '</th><th width="20%"></th></tr></thead><tbody>';
|
|
if ($rows === []) {
|
|
echo '<tr><td colspan="3">' . esc_html__('暂无预设回复', 'aksrt') . '</td></tr>';
|
|
}
|
|
foreach ($rows as $row) {
|
|
$reply_cells = sprintf(
|
|
'<tr><td>%d</td><td>%s</td><td>',
|
|
(int) $row['id'],
|
|
esc_html((string) $row['content'])
|
|
);
|
|
$reply_cells .= '<a href="' . esc_url(add_query_arg(['prereply_id' => (int) $row['id']], $this->ticket_page_url(['tab' => 'config']))) . '">' . esc_html__('编辑', 'aksrt') . '</a> ';
|
|
$reply_cells .= $this->ticket_delete_button('prereply_delete', 'prereply_id', (int) $row['id'], __('删除', 'aksrt'));
|
|
$reply_cells .= '</td></tr>';
|
|
echo $reply_cells; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
|
}
|
|
echo '</tbody></table>';
|
|
|
|
echo '<h4>' . esc_html($editing === null ? __('新增预设回复', 'aksrt') : __('编辑预设回复', 'aksrt')) . '</h4>';
|
|
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
|
|
echo '<input type="hidden" name="action" value="aksrt_ticket_update" />';
|
|
echo '<input type="hidden" name="ticket_action" value="prereply" />';
|
|
echo '<input type="hidden" name="ticket_tab" value="config" />';
|
|
echo '<input type="hidden" name="prereply_id" value="' . esc_attr((string) ($editing['id'] ?? 0)) . '" />';
|
|
echo wp_nonce_field('aksrt_ticket_prereply_' . (int) ($editing['id'] ?? 0), '_wpnonce', true, false);
|
|
echo '<p><textarea name="content" rows="3" class="large-text" placeholder="' . esc_attr__('预设回复内容', 'aksrt') . '">' . esc_textarea((string) ($editing['content'] ?? '')) . '</textarea></p>';
|
|
submit_button(__('保存预设回复', 'aksrt'), 'primary', '', false);
|
|
echo '</form>';
|
|
}
|
|
}
|