Files
wordpress-theme-aksrt/src/Admin/Native/UsersMenusTicketsCenter.php
T
kate522andClaude Opus 5 dbb7de41e9 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>
2026-10-25 23:29:51 +08:00

513 lines
22 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
/**
* 工单管理后台页(users.php → aksrt_tickets)。
*
* 两个 Tab:
* 1. tickets — 工单列表:筛选 + 排序表头 + 行内展开详情(沟通记录 / 回复 / 备注 / 状态迁移)
* 2. config — 配置:部门 / 状态 / 预设回复
*
* 写操作走 admin_post(非 REST),与本站其它原生后台页一致;前端 JS 见
* assets/js/admin-ticket-center.js。
*
* @package AKSRT
*/
namespace AKSRT\Admin\Native;
defined('ABSPATH') || exit;
trait UsersMenusTicketsCenter {
use UsersMenusTicketsList;
use UsersMenusTicketsConfig;
private const TICKET_PAGE_SLUG = 'aksrt_tickets';
private const TICKET_PER_PAGE = 20;
/** 待接单工单数(后台 admin_notices 用)。 */
private function count_pending_tickets(): int {
if (!class_exists('\AKSRT\Ticket\TicketRepository') || !class_exists('\AKSRT\Ticket\TicketStatusService')) {
return 0;
}
return \AKSRT\Ticket\TicketRepository::get_instance()->count_by_status(
\AKSRT\Ticket\TicketStatusService::KEY_PENDING
);
}
public function render_tickets_page(): void {
if (!current_user_can(self::CAPABILITY)) {
wp_die(esc_html__('权限不足', 'aksrt'));
}
$tab = isset($_GET['tab']) ? sanitize_key((string) wp_unslash($_GET['tab'])) : 'tickets';
$tab = $tab === 'config' ? 'config' : 'tickets';
echo '<div class="wrap">';
echo '<h1>' . esc_html__('工单管理', 'aksrt') . '</h1>';
echo '<style>' . $this->ticket_page_css() . '</style>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
$this->render_ticket_notices();
echo '<h2 class="nav-tab-wrapper">';
foreach (['tickets' => __('工单列表', 'aksrt'), 'config' => __('配置', 'aksrt')] as $key => $label) {
printf(
'<a href="%s" class="nav-tab %s">%s</a>',
esc_url(admin_url('users.php?page=' . self::TICKET_PAGE_SLUG . '&tab=' . $key)),
$tab === $key ? 'nav-tab-active' : '',
esc_html($label)
);
}
echo '</h2>';
if ($tab === 'config') {
$this->render_ticket_config_tab();
} else {
$this->render_ticket_list_tab();
}
echo '</div>';
}
/** 写操作后的 redirect 提示(aksrt_pay_msg 同款模式)。 */
private function render_ticket_notices(): void {
if (empty($_GET['aksrt_ticket_msg'])) {
return;
}
$code = sanitize_key((string) wp_unslash($_GET['aksrt_ticket_msg']));
$messages = [
'updated' => __('工单状态已更新。', 'aksrt'),
'failed' => __('工单状态更新失败:当前状态不允许该操作。', 'aksrt'),
'replied' => __('回复已提交。', 'aksrt'),
'reply_failed' => __('回复提交失败。', 'aksrt'),
'type_saved' => __('部门已保存。', 'aksrt'),
'type_deleted' => __('部门已删除。', 'aksrt'),
'type_in_use' => __('该部门下还有工单,无法删除。', 'aksrt'),
'status_saved' => __('状态已保存。', 'aksrt'),
'status_deleted' => __('状态已删除。', 'aksrt'),
'status_default' => __('默认状态不可修改或删除。', 'aksrt'),
'status_in_use' => __('该状态下还有工单,无法删除。', 'aksrt'),
'prereply_saved' => __('预设回复已保存。', 'aksrt'),
'prereply_deleted' => __('预设回复已删除。', 'aksrt'),
'prereply_order_failed' => __('预设回复排序失败:需要提交全部条目。', 'aksrt'),
'note_saved' => __('备注已保存。', 'aksrt'),
'note_deleted' => __('备注已删除。', 'aksrt'),
'created' => __('工单已创建。', 'aksrt'),
'create_failed' => __('工单创建失败。', 'aksrt'),
];
if (!isset($messages[$code])) {
return;
}
printf('<div class="notice notice-success is-dismissible"><p>%s</p></div>', esc_html($messages[$code]));
}
/**
* 写操作:统一入口,按 $action 分发。nonce 以 action + id 为单位,
* 与 PayOrders 的行内处理表单同模式。
*/
public function handle_ticket_update(): void {
if (!current_user_can(self::CAPABILITY)) {
wp_die(esc_html__('权限不足', 'aksrt'));
}
$action = isset($_POST['ticket_action']) ? sanitize_key((string) wp_unslash($_POST['ticket_action'])) : '';
$id = isset($_POST['ticket_id']) ? absint($_POST['ticket_id']) : 0;
check_admin_referer('aksrt_ticket_' . $action . '_' . $id);
$code = 'failed';
switch ($action) {
case 'status':
$code = $this->ticket_action_status($id);
break;
case 'reply':
$code = $this->ticket_action_reply($id);
break;
case 'type':
$code = $this->ticket_action_type();
break;
case 'status_def':
$code = $this->ticket_action_status_def();
break;
case 'prereply':
$code = $this->ticket_action_prereply();
break;
case 'prereply_delete':
$code = $this->ticket_action_prereply_delete();
break;
case 'type_delete':
$code = $this->ticket_action_type_delete();
break;
case 'status_delete':
$code = $this->ticket_action_status_delete();
break;
case 'note':
$code = $this->ticket_action_note($id);
break;
case 'create':
$code = $this->ticket_action_create();
break;
}
$args = ['page' => self::TICKET_PAGE_SLUG, 'aksrt_ticket_msg' => $code];
if (isset($_POST['ticket_tab'])) {
$args['tab'] = sanitize_key((string) wp_unslash($_POST['ticket_tab']));
}
wp_safe_redirect(add_query_arg($args, admin_url('users.php')));
exit;
}
/** @return string 提示码 */
private function ticket_action_status(int $id): string {
if ($id <= 0 || !class_exists('\AKSRT\Ticket\TicketRepository')) {
return 'failed';
}
$repo = \AKSRT\Ticket\TicketRepository::get_instance();
$ticket = $repo->find($id);
if ($ticket === null) {
return 'failed';
}
$to = isset($_POST['status']) ? sanitize_key((string) wp_unslash($_POST['status'])) : '';
if (!\AKSRT\Ticket\TicketStatusService::can_transition((string) $ticket['status'], $to)) {
return 'failed';
}
$fields = ['status' => $to];
$type_id = isset($_POST['ticket_type_id']) ? absint($_POST['ticket_type_id']) : 0;
if ($type_id > 0 && \AKSRT\Ticket\TicketTypeService::exists($type_id)) {
$fields['ticket_type_id'] = $type_id;
}
$admin_id = isset($_POST['admin_id']) ? absint($_POST['admin_id']) : 0;
if ($admin_id > 0 && get_userdata($admin_id)) {
$fields['admin_id'] = $admin_id;
}
if (!$repo->update_fields($id, $fields)) {
return 'failed';
}
$ticket = array_merge($ticket, $fields);
if ($to === \AKSRT\Ticket\TicketStatusService::KEY_CLOSED) {
\AKSRT\Ticket\TicketNotifier::closed($ticket, 'admin');
}
return 'updated';
}
/** @return string 提示码 */
private function ticket_action_reply(int $id): string {
if ($id <= 0 || !class_exists('\AKSRT\Ticket\TicketRepository')) {
return 'reply_failed';
}
$repo = \AKSRT\Ticket\TicketRepository::get_instance();
$ticket = $repo->find($id);
if ($ticket === null || !empty($ticket['is_closed'])) {
return 'reply_failed';
}
$content = isset($_POST['content']) ? sanitize_textarea_field((string) wp_unslash($_POST['content'])) : '';
$content = trim($content);
if ($content === '' || mb_strlen($content) > 3000) {
return 'reply_failed';
}
$reply_id = $repo->create_reply($id, 'admin', get_current_user_id(), $content, [], 0);
if ($reply_id <= 0) {
return 'reply_failed';
}
$repo->touch_reply($id, 'admin', get_current_user_id());
$repo->apply_reply_status($id, $ticket, 'admin');
\AKSRT\Ticket\TicketNotifier::user_replied($repo->find($id) ?? $ticket);
return 'replied';
}
/** @return string 提示码 */
private function ticket_action_type(): string {
if (!class_exists('\AKSRT\Ticket\TicketTypeService')) {
return 'failed';
}
$id = isset($_POST['type_id']) ? absint($_POST['type_id']) : 0;
$name = isset($_POST['name']) ? sanitize_text_field((string) wp_unslash($_POST['name'])) : '';
$name = trim($name);
$description = isset($_POST['description']) ? sanitize_text_field((string) wp_unslash($_POST['description'])) : '';
$admin_ids = isset($_POST['admin_ids']) ? array_map('absint', (array) $_POST['admin_ids']) : [];
if ($name === '' || \AKSRT\Ticket\TicketTypeService::name_taken($name, $id)) {
return 'failed';
}
$payload = [
'name' => $name,
'description' => $description,
'admin_ids' => \AKSRT\Ticket\TicketTypeService::sanitize_admin_ids($admin_ids),
];
if ($id > 0) {
if (!\AKSRT\Ticket\TicketTypeService::exists($id)) {
return 'failed';
}
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_type');
$saved = $wpdb->update($table, [
'name' => $payload['name'],
'description' => $payload['description'],
'admin_ids' => wp_json_encode($payload['admin_ids']),
'updated_at' => current_time('mysql'),
], ['id' => $id], ['%s', '%s', '%s', '%s'], ['%d']);
if ($saved === false) {
return 'failed';
}
\AKSRT\Ticket\TicketTypeService::flush();
return 'type_saved';
}
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_type');
if ($table === '') {
return 'failed';
}
$max = (int) $wpdb->get_var("SELECT MAX(sort_order) FROM {$table}"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
$inserted = $wpdb->insert($table, [
'name' => $payload['name'],
'description' => $payload['description'],
'admin_ids' => wp_json_encode($payload['admin_ids']),
'sort_order' => $max + 1,
'created_at' => current_time('mysql'),
], ['%s', '%s', '%s', '%d', '%s']);
if ($inserted !== 1) {
return 'failed';
}
\AKSRT\Ticket\TicketTypeService::flush();
return 'type_saved';
}
/** @return string 提示码 */
private function ticket_action_status_def(): string {
if (!class_exists('\AKSRT\Ticket\TicketStatusService')) {
return 'failed';
}
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_status');
if ($table === '') {
return 'failed';
}
$id = isset($_POST['status_id']) ? absint($_POST['status_id']) : 0;
$name = isset($_POST['name']) ? sanitize_text_field((string) wp_unslash($_POST['name'])) : '';
$name = trim($name);
$color = \AKSRT\Ticket\TicketStatusService::sanitize_color(
(string) wp_unslash($_POST['color'] ?? '')
);
$is_closed = isset($_POST['is_closed']) ? 1 : 0;
if ($name === '') {
return 'failed';
}
if ($id > 0) {
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$table} WHERE id = %d", // phpcs:ignore WordPress.DB.PreparedSQL
$id
), ARRAY_A);
if (!is_array($row)) {
return 'failed';
}
if (!empty($row['is_default'])) {
return 'status_default';
}
$saved = $wpdb->update($table, [
'name' => $name,
'color' => $color,
'is_closed' => $is_closed,
'updated_at' => current_time('mysql'),
], ['id' => $id], ['%s', '%s', '%d', '%s'], ['%d']);
if ($saved === false) {
return 'failed';
}
\AKSRT\Ticket\TicketStatusService::flush();
return 'status_saved';
}
$max = (int) $wpdb->get_var("SELECT MAX(sort_order) FROM {$table}"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
$inserted = $wpdb->insert($table, [
'status_key' => \AKSRT\Ticket\TicketStatusService::make_key($name),
'name' => $name,
'color' => $color,
'is_closed' => $is_closed,
'sort_order' => $max + 1,
'created_at' => current_time('mysql'),
], ['%s', '%s', '%s', '%d', '%d', '%s']);
if ($inserted !== 1) {
return 'failed';
}
\AKSRT\Ticket\TicketStatusService::flush();
return 'status_saved';
}
/** @return string 提示码 */
private function ticket_action_prereply(): string {
if (!class_exists('\AKSRT\Ticket\TicketPrereplyService')) {
return 'failed';
}
$id = isset($_POST['prereply_id']) ? absint($_POST['prereply_id']) : 0;
$content = isset($_POST['content']) ? sanitize_textarea_field((string) wp_unslash($_POST['content'])) : '';
$content = trim($content);
if ($content === '') {
return 'failed';
}
if ($id > 0) {
if (\AKSRT\Ticket\TicketPrereplyService::find($id) === null) {
return 'failed';
}
return \AKSRT\Ticket\TicketPrereplyService::update($id, $content) ? 'prereply_saved' : 'failed';
}
return \AKSRT\Ticket\TicketPrereplyService::create($content) > 0 ? 'prereply_saved' : 'failed';
}
/** @return string 提示码 */
private function ticket_action_prereply_delete(): string {
$id = isset($_POST['prereply_id']) ? absint($_POST['prereply_id']) : 0;
if ($id <= 0 || !class_exists('\AKSRT\Ticket\TicketPrereplyService')) {
return 'failed';
}
return \AKSRT\Ticket\TicketPrereplyService::delete($id) ? 'prereply_deleted' : 'failed';
}
/** @return string 提示码 */
private function ticket_action_type_delete(): string {
$id = isset($_POST['type_id']) ? absint($_POST['type_id']) : 0;
if ($id <= 0 || !class_exists('\AKSRT\Ticket\TicketTypeService')) {
return 'failed';
}
if (\AKSRT\Ticket\TicketRepository::get_instance()->count_by_type($id) > 0) {
return 'type_in_use';
}
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_type');
if ($table === '') {
return 'failed';
}
$wpdb->delete($table, ['id' => $id], ['%d']);
\AKSRT\Ticket\TicketTypeService::flush();
return 'type_deleted';
}
/** @return string 提示码 */
private function ticket_action_status_delete(): string {
$id = isset($_POST['status_id']) ? absint($_POST['status_id']) : 0;
if ($id <= 0 || !class_exists('\AKSRT\Ticket\TicketStatusService')) {
return 'failed';
}
global $wpdb;
$table = \AKSRT\Core\DBMigrator::table('ticket_status');
if ($table === '') {
return 'failed';
}
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$table} WHERE id = %d", // phpcs:ignore WordPress.DB.PreparedSQL
$id
), ARRAY_A);
if (!is_array($row)) {
return 'failed';
}
if (!empty($row['is_default'])) {
return 'status_default';
}
$key = sanitize_key((string) $row['status_key']);
if (\AKSRT\Ticket\TicketRepository::get_instance()->count_by_status($key) > 0) {
return 'status_in_use';
}
$wpdb->delete($table, ['id' => $id], ['%d']);
\AKSRT\Ticket\TicketStatusService::flush();
return 'status_deleted';
}
/** @return string 提示码 */
private function ticket_action_note(int $id): string { if ($id <= 0 || !class_exists('\AKSRT\Ticket\TicketNoteService')) {
return 'failed';
}
$note_id = isset($_POST['note_id']) ? absint($_POST['note_id']) : 0;
$content = isset($_POST['content']) ? sanitize_textarea_field((string) wp_unslash($_POST['content'])) : '';
$content = trim($content);
if ($content === '') {
return 'failed';
}
if ($note_id > 0) {
return \AKSRT\Ticket\TicketNoteService::update($note_id, $content) ? 'note_saved' : 'failed';
}
return \AKSRT\Ticket\TicketNoteService::create($id, $content, get_current_user_id()) > 0
? 'note_saved'
: 'failed';
}
/** @return string 提示码 */
private function ticket_action_create(): string {
if (!class_exists('\AKSRT\Ticket\TicketRepository')) {
return 'create_failed';
}
$user_id = isset($_POST['user_id']) ? absint($_POST['user_id']) : 0;
$title = isset($_POST['title']) ? sanitize_text_field((string) wp_unslash($_POST['title'])) : '';
$title = trim($title);
$content = isset($_POST['content']) ? sanitize_textarea_field((string) wp_unslash($_POST['content'])) : '';
$content = trim($content);
$type_id = isset($_POST['ticket_type_id']) ? absint($_POST['ticket_type_id']) : 0;
if ($user_id <= 0 || !get_userdata($user_id) || $title === '' || $content === '') {
return 'create_failed';
}
if (!\AKSRT\Ticket\TicketTypeService::exists($type_id)) {
return 'create_failed';
}
$id = \AKSRT\Ticket\TicketRepository::get_instance()->create([
'ticket_num' => \AKSRT\Ticket\TicketNumber::generate(),
'user_id' => $user_id,
'title' => $title,
'ticket_type_id' => $type_id,
'content' => $content,
'order_number' => isset($_POST['order_number']) ? sanitize_text_field((string) wp_unslash($_POST['order_number'])) : '',
'status' => \AKSRT\Ticket\TicketStatusService::KEY_ADMIN_REPLIED,
'post_admin_id' => get_current_user_id(),
'admin_id' => get_current_user_id(),
]);
if ($id <= 0) {
return 'create_failed';
}
$ticket = \AKSRT\Ticket\TicketRepository::get_instance()->find($id);
if (is_array($ticket)) {
\AKSRT\Ticket\TicketNotifier::user_replied($ticket);
}
return 'created';
}
private function ticket_page_url(array $extra = []): string {
return add_query_arg(
array_merge(['page' => self::TICKET_PAGE_SLUG], $extra),
admin_url('users.php')
);
}
/**
* 行内删除按钮:独立小表单 + 二次确认,nonce 以 action + id 为单位。
* 删除类操作不占用列表主表单,避免误删。
*/
private function ticket_delete_button(string $action, string $field, int $id, string $label): string {
return sprintf(
'<form method="post" action="%s" style="display:inline;" onsubmit="return confirm(\'%s\');">'
. '<input type="hidden" name="action" value="aksrt_ticket_update" />'
. '<input type="hidden" name="ticket_action" value="%s" />'
. '<input type="hidden" name="ticket_tab" value="config" />'
. '<input type="hidden" name="%s" value="%d" />%s'
. '<button type="submit" class="button-link-delete">%s</button></form>',
esc_url(admin_url('admin-post.php')),
esc_js(__('确认删除?', 'aksrt')),
esc_attr($action),
esc_attr($field),
$id,
wp_nonce_field('aksrt_ticket_' . $action . '_' . $id, '_wpnonce', true, false),
esc_html($label)
);
}
/** 工单页共享样式(徽章取状态颜色,两个 Tab 都用)。 */
private function ticket_page_css(): string {
return '.aksrt-ticket-badge{display:inline-block;padding:2px 10px;border-radius:999px;'
. 'background:color-mix(in srgb,var(--aksrt-ticket-color,#6b7280) 14%,transparent);'
. 'color:var(--aksrt-ticket-color,#6b7280);font-size:12px;font-weight:600;line-height:1.6;}'
. '.aksrt-ticket-detail{display:grid;gap:10px;padding:12px;border:1px solid #dcdcde;border-radius:6px;background:#fff;}'
. '.aksrt-ticket-detail__content,.aksrt-ticket-detail__reply-body{white-space:pre-wrap;word-break:break-word;}'
. '.aksrt-ticket-detail__replies{display:grid;gap:8px;margin:0;padding:0;list-style:none;}'
. '.aksrt-ticket-detail__replies li{padding:10px 12px;border-left:3px solid #c3c4c7;border-radius:4px;background:#f6f7f7;}'
. '.aksrt-ticket-detail__replies li.is-admin{border-left-color:#2271b1;background:#f0f6fc;}'
. '.aksrt-ticket-detail__reply-head time{color:#646970;font-size:11px;}'
. '.aksrt-ticket-detail__quote{margin:0 0 6px;padding:6px 10px;border-left:3px solid #dcdcde;background:#fff;color:#50575e;font-size:12px;}'
. '.aksrt-ticket-detail__notes{display:grid;gap:6px;margin:0;padding:0;list-style:none;}'
. '.aksrt-ticket-detail__notes time{color:#646970;font-size:11px;}'
. '.aksrt-ticket-detail__closed{margin:0;color:#8c8f94;font-size:12px;}';
}
}