refactor(ticket): move the config tab into the SPA ticket center
users.php → 工单管理 的「配置」Tab 只管理部门 / 状态 / 预设回复三块 DB 字典,与 SPA「工单中心」已有的 8 个标量配置分裂成两处;且同一套 CRUD 在 admin_post 与 TicketConfigRest 里各写了一遍,REST 侧此前 没有任何调用方。 - 删除原生 config tab:UsersMenusTicketsConfig.php 整份移除, UsersMenusTicketsCenter 由 512 行降到 251 行(六个写处理方法、 分发分支、十条提示码与 ticket_delete_button 一并清理) - SPA /users/ticket 改为 customPage:内嵌 SchemaPage 承载原有 8 字段, 三块字典改为 n-data-table + n-modal + n-dialog,取色器/管理员多选/ 拖拽排序替代原生手填表单 - TicketConfigRest 成为三块字典的唯一实现,新增 api/modules/tickets.js 封装 aksrt/v1/admin/tickets* - 双向深链:原生页跳 SPA 配置页,SPA 页跳回原生工单列表 契约测试同步更新,并新增 ticket-spa-config-contract 守护接线。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d32bdc0581
commit
e7ca0ee5df
19 files changed
+1074
-509
No files matched your search
@@ -2,12 +2,11 @@
|
||||
/**
|
||||
* 工单管理后台页(users.php → aksrt_tickets)。
|
||||
*
|
||||
* 两个 Tab:
|
||||
* 1. tickets — 工单列表:筛选 + 排序表头 + 行内展开详情(沟通记录 / 回复 / 备注 / 状态迁移)
|
||||
* 2. config — 配置:部门 / 状态 / 预设回复
|
||||
* 单页承载工单列表:筛选 + 排序表头 + 行内展开详情(沟通记录 / 回复 / 备注 / 状态迁移)。
|
||||
* 部门、状态与预设回复的配置已迁至主题后台 SPA「工单中心」(/users/ticket),
|
||||
* 由 TicketConfigRest 提供 REST 实现。
|
||||
*
|
||||
* 写操作走 admin_post(非 REST),与本站其它原生后台页一致;前端 JS 见
|
||||
* assets/js/admin-ticket-center.js。
|
||||
* 写操作走 admin_post(非 REST),与本站其它原生后台页一致。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
@@ -19,7 +18,6 @@ defined('ABSPATH') || exit;
|
||||
trait UsersMenusTicketsCenter {
|
||||
|
||||
use UsersMenusTicketsList;
|
||||
use UsersMenusTicketsConfig;
|
||||
|
||||
private const TICKET_PAGE_SLUG = 'aksrt_tickets';
|
||||
private const TICKET_PER_PAGE = 20;
|
||||
@@ -38,30 +36,18 @@ trait UsersMenusTicketsCenter {
|
||||
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>';
|
||||
printf(
|
||||
'<p><a class="button" href="%s">%s</a></p>',
|
||||
esc_url(admin_url('admin.php?page=aksrt-settings#/users/ticket')),
|
||||
esc_html__('工单配置(部门 / 状态 / 预设回复)', 'aksrt')
|
||||
);
|
||||
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();
|
||||
}
|
||||
$this->render_ticket_list_tab();
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
@@ -76,16 +62,6 @@ trait UsersMenusTicketsCenter {
|
||||
'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'),
|
||||
@@ -100,6 +76,8 @@ trait UsersMenusTicketsCenter {
|
||||
/**
|
||||
* 写操作:统一入口,按 $action 分发。nonce 以 action + id 为单位,
|
||||
* 与 PayOrders 的行内处理表单同模式。
|
||||
*
|
||||
* 部门 / 状态 / 预设回复的写操作已迁至 SPA「工单中心」(TicketConfigRest)。
|
||||
*/
|
||||
public function handle_ticket_update(): void {
|
||||
if (!current_user_can(self::CAPABILITY)) {
|
||||
@@ -117,24 +95,6 @@ trait UsersMenusTicketsCenter {
|
||||
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;
|
||||
@@ -143,11 +103,10 @@ trait UsersMenusTicketsCenter {
|
||||
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')));
|
||||
wp_safe_redirect(add_query_arg(
|
||||
['page' => self::TICKET_PAGE_SLUG, 'aksrt_ticket_msg' => $code],
|
||||
admin_url('users.php')
|
||||
));
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -209,204 +168,6 @@ trait UsersMenusTicketsCenter {
|
||||
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';
|
||||
@@ -471,29 +232,7 @@ trait UsersMenusTicketsCenter {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 行内删除按钮:独立小表单 + 二次确认,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);'
|
||||
|
||||
Reference in new issue
Block a user