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 '
';
echo '
' . esc_html__('工单管理', 'aksrt') . '
';
echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
$this->render_ticket_notices();
echo '
';
foreach (['tickets' => __('工单列表', 'aksrt'), 'config' => __('配置', 'aksrt')] as $key => $label) {
printf(
'%s',
esc_url(admin_url('users.php?page=' . self::TICKET_PAGE_SLUG . '&tab=' . $key)),
$tab === $key ? 'nav-tab-active' : '',
esc_html($label)
);
}
echo '
';
if ($tab === 'config') {
$this->render_ticket_config_tab();
} else {
$this->render_ticket_list_tab();
}
echo '
';
}
/** 写操作后的 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('', 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(
'',
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;}';
}
}