From e7ca0ee5df6a615f31db6deb12478fe377adf0fa Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 26 Oct 2026 19:48:58 +0800 Subject: [PATCH] refactor(ticket): move the config tab into the SPA ticket center MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- assets/admin/src/api/modules/index.js | 2 + assets/admin/src/api/modules/tickets.js | 55 ++++ assets/admin/src/constants/sections/users.js | 5 +- assets/admin/src/pages/users/UsersTicket.vue | 69 ++++ .../partials/ticket/TicketPrereplyPanel.vue | 225 +++++++++++++ .../partials/ticket/TicketStatusPanel.vue | 261 ++++++++++++++++ .../users/partials/ticket/TicketTypePanel.vue | 252 +++++++++++++++ .../src/router/component-loaders/users.js | 1 + .../admin/src/schema/sections/users/ticket.js | 2 +- src/Admin/Native/UsersMenusTicketsCenter.php | 295 +----------------- src/Admin/Native/UsersMenusTicketsConfig.php | 165 ---------- src/Admin/Native/UsersMenusTicketsList.php | 3 - .../financial-flow-contract.test.cjs | 3 +- .../ticket-lifecycle-contract.test.cjs | 8 +- .../ticket-native-admin-contract.test.cjs | 65 ++-- .../ticket-schema-contract.test.cjs | 4 +- .../ticket-spa-config-contract.test.cjs | 106 +++++++ tools/admin-contract-map.md | 1 + tools/admin-contract-report.json | 61 ++-- 19 files changed, 1074 insertions(+), 509 deletions(-) create mode 100644 assets/admin/src/api/modules/tickets.js create mode 100644 assets/admin/src/pages/users/UsersTicket.vue create mode 100644 assets/admin/src/pages/users/partials/ticket/TicketPrereplyPanel.vue create mode 100644 assets/admin/src/pages/users/partials/ticket/TicketStatusPanel.vue create mode 100644 assets/admin/src/pages/users/partials/ticket/TicketTypePanel.vue delete mode 100644 src/Admin/Native/UsersMenusTicketsConfig.php create mode 100644 tests/widget-system/ticket-spa-config-contract.test.cjs diff --git a/assets/admin/src/api/modules/index.js b/assets/admin/src/api/modules/index.js index 9e9d622f..750bdc61 100644 --- a/assets/admin/src/api/modules/index.js +++ b/assets/admin/src/api/modules/index.js @@ -6,6 +6,7 @@ import { extensionsApiMethods } from './extensions.js' import { toolsApiMethods } from './tools.js' import { schemaApiMethods } from './schema.js' import { iconsApiMethods } from './icons.js' +import { ticketsApiMethods } from './tickets.js' export const apiMethods = { ...globalApiMethods, @@ -16,4 +17,5 @@ export const apiMethods = { ...toolsApiMethods, ...schemaApiMethods, ...iconsApiMethods, + ...ticketsApiMethods, } diff --git a/assets/admin/src/api/modules/tickets.js b/assets/admin/src/api/modules/tickets.js new file mode 100644 index 00000000..f6ad3479 --- /dev/null +++ b/assets/admin/src/api/modules/tickets.js @@ -0,0 +1,55 @@ +/** + * 工单管理端 REST(命名空间 aksrt/v1/admin)。 + * + * TicketRestAdmin 把三块字典 CRUD(工单状态 / 部门 / 预设回复)挂在 + * aksrt/v1/admin 下,全部要求 manage_options。api 客户端的 baseUrl 只到 + * aksrt/v1,因此这里必须用 /aksrt/ 全路径形式(joinRestUrl 的 + * startsWith('/aksrt/') 分支会原样拼接,不再追加 base 前缀)。 + */ + +const ADMIN = '/aksrt/v1/admin/tickets' + +export const ticketsApiMethods = { + /* ---------------- 工单状态 ---------------- */ + + listTicketStatuses() { return this.request(`${ADMIN}/statuses`) }, + createTicketStatus(data) { + return this.request(`${ADMIN}/statuses`, { method: 'POST', body: JSON.stringify(data) }) + }, + updateTicketStatus(id, data) { + return this.request(`${ADMIN}/statuses/${id}`, { method: 'POST', body: JSON.stringify(data) }) + }, + deleteTicketStatus(id) { + return this.request(`${ADMIN}/statuses/${id}/delete`, { method: 'POST', body: '{}' }) + }, + + /* ---------------- 工单部门 ---------------- */ + + listTicketTypes() { return this.request(`${ADMIN}/types`) }, + createTicketType(data) { + return this.request(`${ADMIN}/types`, { method: 'POST', body: JSON.stringify(data) }) + }, + updateTicketType(id, data) { + return this.request(`${ADMIN}/types/${id}`, { method: 'POST', body: JSON.stringify(data) }) + }, + deleteTicketType(id) { + return this.request(`${ADMIN}/types/${id}/delete`, { method: 'POST', body: '{}' }) + }, + + /* ---------------- 预设回复 ---------------- */ + + listTicketPrereplies() { return this.request(`${ADMIN}/prereplies`) }, + createTicketPrereply(data) { + return this.request(`${ADMIN}/prereplies`, { method: 'POST', body: JSON.stringify(data) }) + }, + updateTicketPrereply(id, data) { + return this.request(`${ADMIN}/prereplies/${id}`, { method: 'POST', body: JSON.stringify(data) }) + }, + deleteTicketPrereply(id) { + return this.request(`${ADMIN}/prereplies/${id}/delete`, { method: 'POST', body: '{}' }) + }, + /** 排序入参必须是全量 ID,数量不符后端直接拒绝。 */ + reorderTicketPrereplies(ids) { + return this.request(`${ADMIN}/prereplies/order`, { method: 'POST', body: JSON.stringify({ ids }) }) + }, +} diff --git a/assets/admin/src/constants/sections/users.js b/assets/admin/src/constants/sections/users.js index 1c74de48..db8941f9 100644 --- a/assets/admin/src/constants/sections/users.js +++ b/assets/admin/src/constants/sections/users.js @@ -130,13 +130,14 @@ export const usersSection = { canonicalDomain: 'users', section: 'report', }), - schemaPage({ + customPage({ key: 'users-ticket', name: 'UsersTicket', label: '工单中心', route: '/users/ticket', title: '工单中心', - desc: '工单功能开关、工单号前缀、提交公告与提交表单(图片上传、关联订单、催单冷却)配置;部门、状态与预设回复在 WordPress 用户菜单的「工单管理」中维护。', + desc: '工单功能开关、工单号前缀、提交公告与提交表单(图片上传、关联订单、催单冷却)配置,以及部门、状态、预设回复维护;工单处理在 WordPress 用户菜单的「工单管理」中进行。', + component: 'UsersTicket', canonicalDomain: 'users', section: 'ticket', }), diff --git a/assets/admin/src/pages/users/UsersTicket.vue b/assets/admin/src/pages/users/UsersTicket.vue new file mode 100644 index 00000000..76df3d9e --- /dev/null +++ b/assets/admin/src/pages/users/UsersTicket.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/assets/admin/src/pages/users/partials/ticket/TicketPrereplyPanel.vue b/assets/admin/src/pages/users/partials/ticket/TicketPrereplyPanel.vue new file mode 100644 index 00000000..05a1cb38 --- /dev/null +++ b/assets/admin/src/pages/users/partials/ticket/TicketPrereplyPanel.vue @@ -0,0 +1,225 @@ + + + + + diff --git a/assets/admin/src/pages/users/partials/ticket/TicketStatusPanel.vue b/assets/admin/src/pages/users/partials/ticket/TicketStatusPanel.vue new file mode 100644 index 00000000..a1c54ebc --- /dev/null +++ b/assets/admin/src/pages/users/partials/ticket/TicketStatusPanel.vue @@ -0,0 +1,261 @@ + + + + + diff --git a/assets/admin/src/pages/users/partials/ticket/TicketTypePanel.vue b/assets/admin/src/pages/users/partials/ticket/TicketTypePanel.vue new file mode 100644 index 00000000..29f396f6 --- /dev/null +++ b/assets/admin/src/pages/users/partials/ticket/TicketTypePanel.vue @@ -0,0 +1,252 @@ + + + diff --git a/assets/admin/src/router/component-loaders/users.js b/assets/admin/src/router/component-loaders/users.js index a171cab8..cbe34425 100644 --- a/assets/admin/src/router/component-loaders/users.js +++ b/assets/admin/src/router/component-loaders/users.js @@ -3,4 +3,5 @@ export const usersPageLoaders = { UsersLevel: () => import('@/pages/users/UsersLevel.vue'), UsersBadge: () => import('@/pages/users/UsersBadge.vue'), UsersWechatTemplate: () => import('@/pages/users/UsersWechatTemplate.vue'), + UsersTicket: () => import('@/pages/users/UsersTicket.vue'), } diff --git a/assets/admin/src/schema/sections/users/ticket.js b/assets/admin/src/schema/sections/users/ticket.js index 31ca3ed4..5fb18fa7 100644 --- a/assets/admin/src/schema/sections/users/ticket.js +++ b/assets/admin/src/schema/sections/users/ticket.js @@ -8,7 +8,7 @@ export const ticketSchema = { key: 'users.ticket', title: '工单中心', icon: 'fa fas fa-headset', - description: '工单功能开关、工单号前缀、提交公告与提交表单(图片上传、关联订单、催单冷却)配置。部门、状态与预设回复在「用户 → 工单管理」中维护。', + description: '工单功能开关、工单号前缀、提交公告与提交表单(图片上传、关联订单、催单冷却)配置;部门、状态与预设回复在本页其余分页中维护。', defaults: { ticket_s: true, ticket_num_prefix: 'T', diff --git a/src/Admin/Native/UsersMenusTicketsCenter.php b/src/Admin/Native/UsersMenusTicketsCenter.php index 41ee0fe2..e94470f5 100644 --- a/src/Admin/Native/UsersMenusTicketsCenter.php +++ b/src/Admin/Native/UsersMenusTicketsCenter.php @@ -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 '
'; echo '

' . esc_html__('工单管理', 'aksrt') . '

'; + printf( + '

%s

', + esc_url(admin_url('admin.php?page=aksrt-settings#/users/ticket')), + esc_html__('工单配置(部门 / 状态 / 预设回复)', 'aksrt') + ); echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped $this->render_ticket_notices(); - echo ''; - - if ($tab === 'config') { - $this->render_ticket_config_tab(); - } else { - $this->render_ticket_list_tab(); - } + $this->render_ticket_list_tab(); echo '
'; } @@ -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( - '
' - . '' - . '' - . '' - . '%s' - . '
', - 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);' diff --git a/src/Admin/Native/UsersMenusTicketsConfig.php b/src/Admin/Native/UsersMenusTicketsConfig.php deleted file mode 100644 index 90ea7c13..00000000 --- a/src/Admin/Native/UsersMenusTicketsConfig.php +++ /dev/null @@ -1,165 +0,0 @@ -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 '

' . esc_html__('工单部门', 'aksrt') . '

'; - echo ''; - echo '' - . '' - . ''; - foreach ($types as $row) { - $edit_url = add_query_arg(['type_id' => (int) $row['id']], $this->ticket_page_url(['tab' => 'config'])); - $cells = sprintf( - ''; - echo $cells; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped - } - echo '
ID' . esc_html__('名称', 'aksrt') . '' . esc_html__('说明', 'aksrt') . '' . esc_html__('受理管理员', 'aksrt') . '
%d%s%s%s', - (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 .= '' . esc_html__('编辑', 'aksrt') . ' '; - $cells .= $this->ticket_delete_button('type_delete', 'type_id', (int) $row['id'], __('删除', 'aksrt')); - $cells .= '
'; - - echo '

' . esc_html($editing === null ? __('新增部门', 'aksrt') : __('编辑部门', 'aksrt')) . '

'; - echo '
'; - echo ''; - echo ''; - echo ''; - echo ''; - echo wp_nonce_field('aksrt_ticket_type_' . (int) ($editing['id'] ?? 0), '_wpnonce', true, false); - echo '

'; - echo '

'; - echo '

'; - submit_button(__('保存部门', 'aksrt'), 'primary', '', false); - echo '
'; - } - - /** 自定义状态:默认状态拒绝改删。 */ - 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 '

' . esc_html__('工单状态', 'aksrt') . '

'; - echo '

' . esc_html__('默认状态不可修改或删除;删除非默认状态前需先清空该状态下的工单。', 'aksrt') . '

'; - echo ''; - echo '' - . '' - . '' - . ''; - foreach ($statuses as $row) { - $status_cells = sprintf( - '' - . '' - . ''; - echo $status_cells; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped - } - echo '
ID' . esc_html__('名称', 'aksrt') . '' . esc_html__('颜色', 'aksrt') . '' . esc_html__('完结', 'aksrt') . '' . esc_html__('默认', 'aksrt') . '
%d%s%s%s%s', - (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 .= '' . esc_html__('编辑', 'aksrt') . ' '; - $status_cells .= $this->ticket_delete_button('status_delete', 'status_id', (int) $row['id'], __('删除', 'aksrt')); - } else { - $status_cells .= '—'; - } - $status_cells .= '
'; - - echo '

' . esc_html($editing === null ? __('新增状态', 'aksrt') : __('编辑状态', 'aksrt')) . '

'; - echo '
'; - echo ''; - echo ''; - echo ''; - echo ''; - echo wp_nonce_field('aksrt_ticket_status_def_' . (int) ($editing['id'] ?? 0), '_wpnonce', true, false); - echo '

'; - echo ' '; - echo ' '; - submit_button(__('保存状态', 'aksrt'), 'primary', '', false); - echo '

'; - } - - /** 预设回复:表格 + 行内编辑 + 新建表单。 */ - 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 '

' . esc_html__('预设回复', 'aksrt') . '

'; - echo ''; - echo ''; - if ($rows === []) { - echo ''; - } - foreach ($rows as $row) { - $reply_cells = sprintf( - ''; - echo $reply_cells; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped - } - echo '
ID' . esc_html__('内容', 'aksrt') . '
' . esc_html__('暂无预设回复', 'aksrt') . '
%d%s', - (int) $row['id'], - esc_html((string) $row['content']) - ); - $reply_cells .= '' . esc_html__('编辑', 'aksrt') . ' '; - $reply_cells .= $this->ticket_delete_button('prereply_delete', 'prereply_id', (int) $row['id'], __('删除', 'aksrt')); - $reply_cells .= '
'; - - echo '

' . esc_html($editing === null ? __('新增预设回复', 'aksrt') : __('编辑预设回复', 'aksrt')) . '

'; - echo '
'; - echo ''; - echo ''; - echo ''; - echo ''; - echo wp_nonce_field('aksrt_ticket_prereply_' . (int) ($editing['id'] ?? 0), '_wpnonce', true, false); - echo '

'; - submit_button(__('保存预设回复', 'aksrt'), 'primary', '', false); - echo '
'; - } -} diff --git a/src/Admin/Native/UsersMenusTicketsList.php b/src/Admin/Native/UsersMenusTicketsList.php index b2a7cc78..d0b6bee8 100644 --- a/src/Admin/Native/UsersMenusTicketsList.php +++ b/src/Admin/Native/UsersMenusTicketsList.php @@ -205,7 +205,6 @@ trait UsersMenusTicketsList { $html .= ''; $html .= ''; $html .= ''; - $html .= ''; $html .= wp_nonce_field('aksrt_ticket_reply_' . $id, '_wpnonce', true, false); $html .= '

'; $html .= '

'; @@ -217,7 +216,6 @@ trait UsersMenusTicketsList { $html .= ''; $html .= ''; $html .= ''; - $html .= ''; $html .= wp_nonce_field('aksrt_ticket_status_' . $id, '_wpnonce', true, false); $html .= '

' . '

'; diff --git a/tests/widget-system/financial-flow-contract.test.cjs b/tests/widget-system/financial-flow-contract.test.cjs index b832f6f9..2f715773 100644 --- a/tests/widget-system/financial-flow-contract.test.cjs +++ b/tests/widget-system/financial-flow-contract.test.cjs @@ -144,7 +144,8 @@ test('order detail page drops tabs; ticket center replaces the after-sale submen assert.ok(usersMenus.includes('render_tickets_page'), 'ticket admin page entry registered') const ticketTrait = read('src/Admin/Native/UsersMenusTicketsCenter.php') assert.ok(ticketTrait.includes('use UsersMenusTicketsList;'), 'ticket list renderer trait composed') - assert.ok(ticketTrait.includes('use UsersMenusTicketsConfig;'), 'ticket config renderer trait composed') + // 部门 / 状态 / 预设回复已迁至 SPA「工单中心」,原生页只留列表。 + assert.ok(!ticketTrait.includes('use UsersMenusTicketsConfig;'), 'config tab must not be composed any more') const domain = read('src/Core/DomainModel.php') for (const status of ['pending', 'paid', 'refunded', 'closed']) assert.ok(domain.includes(`ORDER_STATUS_${status.toUpperCase()}`)) assert.match(page, /ORDER_PRODUCT_READING[\s\S]*?付费阅读[\s\S]*?ORDER_PRODUCT_DOWNLOAD[\s\S]*?付费下载[\s\S]*?ORDER_PRODUCT_IMAGE[\s\S]*?付费图片[\s\S]*?ORDER_PRODUCT_VIDEO[\s\S]*?付费视频[\s\S]*?ORDER_PRODUCT_VIP[\s\S]*?购买会员[\s\S]*?ORDER_PRODUCT_RECHARGE[\s\S]*?余额充值[\s\S]*?ORDER_PRODUCT_POINTS[\s\S]*?购买积分/) diff --git a/tests/widget-system/ticket-lifecycle-contract.test.cjs b/tests/widget-system/ticket-lifecycle-contract.test.cjs index a727054c..2bc1ea3b 100644 --- a/tests/widget-system/ticket-lifecycle-contract.test.cjs +++ b/tests/widget-system/ticket-lifecycle-contract.test.cjs @@ -64,14 +64,16 @@ test('closing notifies the counterparty exactly once', () => { test('default status cannot be edited or deleted in the admin surface', () => { // REST 侧的状态 CRUD 在 TicketConfigRest trait 里,由 TicketRestAdmin 组合。 + // 配置入口已从原生后台迁至 SPA「工单中心」,兜底只在这一处。 const config = read('src/Ticket/TicketConfigRest.php'); assert.equal((config.match(/!empty\(\$row\['is_default'\]\)/g) || []).length, 2, 'both update and delete must honour is_default'); assert.match(config, /'aksrt_ticket_status_default'/); - // 原生后台页同样要挡。 + + // 原生后台不再维护状态定义,因此不再出现 is_default 判断。 const native = read('src/Admin/Native/UsersMenusTicketsCenter.php'); - assert.equal((native.match(/!empty\(\$row\['is_default'\]\)/g) || []).length, 2, - 'native admin must honour is_default in both paths'); + assert.ok(!native.includes("!empty(\$row['is_default'])"), + 'status definition guards belong to TicketConfigRest only'); // 删除前还要确认没有工单挂在该状态/部门下(守卫在 TicketConfigRest trait 中)。 assert.match(config, /count_by_status\(\(string\) \$row\['status_key'\]\) > 0/); diff --git a/tests/widget-system/ticket-native-admin-contract.test.cjs b/tests/widget-system/ticket-native-admin-contract.test.cjs index 84545d9b..56c0ce8f 100644 --- a/tests/widget-system/ticket-native-admin-contract.test.cjs +++ b/tests/widget-system/ticket-native-admin-contract.test.cjs @@ -4,9 +4,10 @@ * 工单后台(users.php → aksrt_tickets)契约: * 1) 子菜单已注册,trait 已组合,写操作走 admin_post 且每个都带 nonce; * 2) 分页用共享 AdminListPagination,不写死 limit; - * 3) 默认状态与「仍有工单」两类删除被拒; + * 3) 默认状态与「仍有工单」两类删除被拒(兜底在 TicketConfigRest,SPA 工单中心消费); * 4) SPA 深链 redirect 指向 users.php?page=aksrt_tickets; * 5) 前端 JS 与 PHP 的 data-* 挂载点对齐。 + * 部门 / 状态 / 预设回复的配置已迁至 SPA「工单中心」,本页只剩工单列表。 */ const assert = require('node:assert/strict'); @@ -36,14 +37,48 @@ test('write actions are nonce-protected and dispatched by action name', () => { assert.match(center, /check_admin_referer\('aksrt_ticket_' \. \$action \. '_' \. \$id\);/); assert.ok(center.includes('current_user_can(self::CAPABILITY)'), 'capability check missing'); - // 每个动作分支都要有对应处理器。 - for (const action of ['status', 'reply', 'type', 'status_def', 'prereply', 'prereply_delete', 'type_delete', 'status_delete', 'note', 'create']) { + // 部门 / 状态 / 预设回复已迁至 SPA 工单中心,本页只剩工单自身的四个动作。 + for (const action of ['status', 'reply', 'note', 'create']) { assert.ok(center.includes(`case '${action}':`), `dispatch case ${action} missing`); } - // 表单里 nonce 以 action + id 为单位,删除类操作独立小表单 + 二次确认。 - assert.match(center, /wp_nonce_field\('aksrt_ticket_' \. \$action \. '_' \. \$id, '_wpnonce', true, false\)/); - assert.match(center, /onsubmit="return confirm\(/); + // nonce 以 action + id 为单位,行内表单各自带自己的 nonce(渲染在 List trait 中)。 + // 删除类操作已迁至 SPA(n-dialog 二次确认),本页不再有 onsubmit=confirm 的小表单。 + const list = read('src/Admin/Native/UsersMenusTicketsList.php'); + for (const hook of [ + 'aksrt_ticket_reply_', + 'aksrt_ticket_status_', + 'aksrt_ticket_note_', + ]) { + assert.ok(list.includes(hook), `nonce prefix ${hook} missing`); + } + // Tab 已移除,三个行内表单不再输出 ticket_tab 隐藏域。 + assert.ok(!list.includes('ticket_tab'), 'ticket_tab hidden input must be gone'); + assert.ok(!center.includes('onsubmit="return confirm('), 'config delete forms must be gone'); +}); + +test('the native page carries no config tab and deep-links the SPA instead', () => { + const center = read('src/Admin/Native/UsersMenusTicketsCenter.php'); + + // Tab 结构已移除:不再有 nav-tab-wrapper,也不读 ?tab=。 + assert.ok(!center.includes('nav-tab-wrapper'), 'tab wrapper must be gone'); + assert.ok(!center.includes("'config'"), 'config tab must be gone'); + assert.ok(!/\$_GET\['tab'\]/.test(center), 'tab param must not be read'); + + // 配置 Tab 的渲染 trait 与删除按钮 helper 一并移除。 + assert.ok(!center.includes('UsersMenusTicketsConfig'), 'config trait must not be composed'); + assert.ok(!center.includes('render_ticket_config_tab'), 'config renderer must be gone'); + assert.ok(!center.includes('ticket_delete_button'), 'config-only delete helper must be gone'); + + // 六类配置写操作全部迁至 TicketConfigRest,本页不再分发。 + for (const action of ['type', 'status_def', 'prereply', 'prereply_delete', 'type_delete', 'status_delete']) { + assert.ok(!center.includes(`case '${action}':`), `config action ${action} must be gone`); + } + + // 页面顶部提供跳往 SPA「工单中心」的快捷入口。 + assert.match(center, /admin_url\('admin\.php\?page=aksrt-settings#\/users\/ticket'\)/); + assert.ok(!fs.existsSync(path.join(root, 'src/Admin/Native/UsersMenusTicketsConfig.php')), + 'the config trait file must be deleted'); }); test('the ticket list paginates through the shared helper', () => { @@ -61,24 +96,6 @@ test('the ticket list paginates through the shared helper', () => { assert.match(list, /'limit' => self::TICKET_PER_PAGE,/); }); -test('config tab edits departments, statuses and prereplies', () => { - const config = read('src/Admin/Native/UsersMenusTicketsConfig.php'); - - assert.match(config, /render_ticket_types_panel/); - assert.match(config, /render_ticket_statuses_panel/); - assert.match(config, /render_ticket_prereplies_panel/); - - // 默认状态行不给编辑/删除入口。 - assert.match(config, /empty\(\$row\['is_default'\]\)/); - // 删除按钮带 confirm,且走独立小表单。 - assert.match(config, /ticket_delete_button\('status_delete', 'status_id'/); - assert.match(config, /ticket_delete_button\('type_delete', 'type_id'/); - assert.match(config, /ticket_delete_button\('prereply_delete', 'prereply_id'/); - - // 状态徽章颜色来自状态表,不写死。 - assert.match(config, /--aksrt-ticket-color:%s/); -}); - test('detail panel renders replies, quote blocks and internal notes', () => { const list = read('src/Admin/Native/UsersMenusTicketsList.php'); const body = list.slice(list.indexOf('private function render_ticket_detail_panel')); diff --git a/tests/widget-system/ticket-schema-contract.test.cjs b/tests/widget-system/ticket-schema-contract.test.cjs index 8d9b3971..edc22603 100644 --- a/tests/widget-system/ticket-schema-contract.test.cjs +++ b/tests/widget-system/ticket-schema-contract.test.cjs @@ -3,7 +3,7 @@ /** * 「工单中心」配置契约: * 1) PHP SchemaUsersTicket 分区与 SPA JS schema 的键、顺序、类型、默认值 1:1 对齐; - * 2) SPA 页面已注册(/users/ticket 可达),且挂的是 schemaPage 而非自定义页; + * 2) SPA 页面已注册(/users/ticket 可达),承载 schema 表单 + 三块字典 CRUD; * 3) 运行时 TicketSettings 缺键回退 schema 默认,数值边界被夹取; * 4) 前台入口三处均已注册:Center.php 面板定义 + sidebar-nav.php 侧栏项 + * aksrt-user-center.php 的 built_in_tabs 与 view map。 @@ -67,7 +67,7 @@ test('ticket schema keys match between PHP and SPA JS', () => { 'JS notice text must declare sanitize_type html'); }); -test('ticket schema is registered as a schema page under users', () => { +test('ticket schema is registered under users', () => { const js = read('assets/admin/src/schema/sections/users/ticket.js'); assert.equal(js.includes("key: 'users.ticket'"), true, 'schema key must be users.ticket'); diff --git a/tests/widget-system/ticket-spa-config-contract.test.cjs b/tests/widget-system/ticket-spa-config-contract.test.cjs new file mode 100644 index 00000000..f99d9aa7 --- /dev/null +++ b/tests/widget-system/ticket-spa-config-contract.test.cjs @@ -0,0 +1,106 @@ +'use strict'; + +/** + * SPA「工单中心」接线契约: + * 1) /users/ticket 是 customPage,组件 UsersTicket 已注册并能被 router 解析; + * 2) 页面内嵌 SchemaPage 承载 users/ticket 分区,schema 表单零改动复用; + * 3) 三块字典 CRUD 走 TicketRestAdmin(aksrt/v1/admin)的封装模块; + * 4) 双向深链:SPA → 原生工单列表,原生 → SPA 工单中心; + * 5) 配置 Tab 的残留(tab=config / UsersMenusTicketsConfig)不得回流。 + */ + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const test = require('node:test'); + +const root = path.resolve(__dirname, '../..'); +const read = (file) => fs.readFileSync(path.join(root, file), 'utf8'); + +test('the ticket page is a custom page backed by UsersTicket', () => { + const loaders = read('assets/admin/src/router/component-loaders/users.js'); + assert.ok( + loaders.includes("UsersTicket: () => import('@/pages/users/UsersTicket.vue')"), + 'UsersTicket loader missing' + ); + + const constants = read('assets/admin/src/constants/sections/users.js'); + // 入口必须是 customPage 且显式指定组件,否则 router 会回落到 SchemaPage。 + assert.match(constants, /customPage\(\{[\s\S]*?component: 'UsersTicket'/); + assert.ok(constants.includes("key: 'users-ticket'"), 'menu page key missing'); + assert.ok(constants.includes("route: '/users/ticket'"), 'menu route missing'); + assert.ok(constants.includes("section: 'ticket'"), 'menu section missing'); + assert.ok(constants.includes("canonicalDomain: 'users'"), 'menu canonical domain missing'); +}); + +test('UsersTicket embeds the schema page and the three CRUD panels', () => { + const page = read('assets/admin/src/pages/users/UsersTicket.vue'); + + // 基础配置复用 SchemaPage,域/分区显式传入,避免依赖路由 meta。 + assert.match(page, / { + const module = read('assets/admin/src/api/modules/tickets.js'); + + // TicketRestAdmin 挂在 aksrt/v1/admin,客户端 baseUrl 只到 aksrt/v1, + // 因此必须走 /aksrt/ 全路径形式。 + assert.match(module, /'\/aksrt\/v1\/admin\/tickets'/); + + for (const method of [ + 'listTicketStatuses', 'createTicketStatus', 'updateTicketStatus', 'deleteTicketStatus', + 'listTicketTypes', 'createTicketType', 'updateTicketType', 'deleteTicketType', + 'listTicketPrereplies', 'createTicketPrereply', 'updateTicketPrereply', + 'deleteTicketPrereply', 'reorderTicketPrereplies', + ]) { + assert.ok(module.includes(`${method}(`), `api method ${method} missing`); + } + + const index = read('assets/admin/src/api/modules/index.js'); + assert.ok(index.includes("import { ticketsApiMethods } from './tickets.js'"), 'module not imported'); + assert.ok(index.includes('...ticketsApiMethods,'), 'module not spread into apiMethods'); +}); + +test('panels enforce the backend guards instead of trusting the client', () => { + const status = read('assets/admin/src/pages/users/partials/ticket/TicketStatusPanel.vue'); + + // 默认状态的编辑/删除入口在前端禁用,但后端 TicketConfigRest 仍有 409 兜底。 + assert.match(status, /disabled: row\.is_default/); + // 颜色必须落在 TicketStatusService::sanitize_color() 接受的范围内。 + assert.match(status, /\^#\(\?:\[0-9a-f\]\{3\}\|\[0-9a-f\]\{6\}\)\$/i); + assert.match(status, /#6b7280/); + + const prereply = read('assets/admin/src/pages/users/partials/ticket/TicketPrereplyPanel.vue'); + // 排序接口要求全量 ID。 + assert.match(prereply, /rows\.value\.map\(\(row\) => row\.id\)/); + assert.match(prereply, /reorderTicketPrereplies\(ids\)/); +}); + +test('no config tab residue survives in the SPA or the native page', () => { + const router = read('assets/admin/src/router/index.js'); + const constants = read('assets/admin/src/constants/sections/users.js'); + const schema = read('assets/admin/src/schema/sections/users/ticket.js'); + + for (const [file, source] of [['router', router], ['constants', constants], ['ticket schema', schema]]) { + assert.ok(!source.includes('tab=config'), `${file} still references tab=config`); + } + + // 原生页不再有配置 Tab 的渲染 trait。 + assert.ok( + !fs.existsSync(path.join(root, 'src/Admin/Native/UsersMenusTicketsConfig.php')), + 'native config trait must be deleted' + ); +}); diff --git a/tools/admin-contract-map.md b/tools/admin-contract-map.md index 8607f64d..32f0ddcd 100644 --- a/tools/admin-contract-map.md +++ b/tools/admin-contract-map.md @@ -40,6 +40,7 @@ These use dedicated Vue pages + dedicated REST modules: - Footer: `FooterSettings` + footer settings API - Permissions capabilities matrix: `PermissionsCapabilities` + `/permissions/capabilities` - Shop products/orders and other operational tables +- 工单中心: `UsersTicket` + schema `users/ticket` + `aksrt/v1/admin/tickets*`(部门 / 状态 / 预设回复) ## Deploy checklist diff --git a/tools/admin-contract-report.json b/tools/admin-contract-report.json index 3dae58c2..baa2e87a 100644 --- a/tools/admin-contract-report.json +++ b/tools/admin-contract-report.json @@ -1,5 +1,5 @@ { - "generated_at": "2026-10-25T15:29:20+00:00", + "generated_at": "2026-10-26T11:36:52+00:00", "errors": [], "warnings": [ "customPage tools-update section 'tools/update' not in PHP schema (ok if pure operational page)", @@ -64,7 +64,7 @@ "info": [ "PHP schema domains: global, page_display, post_list, permissions, users, payment, extensions, tools, ExtensionsAudit, ExtensionsIP, ExtensionsPush, ExtensionsSecurity, ExtensionsSystem, ToolsBackup, ToolsHealth, ToolsImportExport, PostListList", "Menu pages discovered: 61", - "admin build ok; chunks=30" + "admin build ok; chunks=31" ], "domains": { "global": { @@ -260,36 +260,37 @@ "entry": true, "missing_chunks": [], "chunks": [ - "SchemaPage.DCJ4QdH8.js", - "vendor-vue.BqIbVHit.js", - "FaIcon.BMA0TNWc.js", - "AksMediaField.C9RRDn2V.js", - "FaIconPicker.CA3w9r30.js", - "shared-api.Cak1GAri.js", - "vendor-misc.Dr7TL_YE.js", - "vendor-draggable.CjYjXbZD.js", + "SchemaPage.C79jkx8B.js", + "vendor-vue.AXlcobq-.js", + "FaIcon.Cta3HYOc.js", + "AksMediaField.CiF8KSwp.js", + "FaIconPicker.DJI1XtMq.js", + "shared-api.GLIRMK1p.js", + "vendor-misc.DSSPvhm2.js", + "vendor-draggable.CsbWt1CM.js", "level.E3ANSDAX.js", "badge.CKTsmH1z.js", - "vendor-naive.Dzr6v856.js", - "DeferredPage.Bo6-Z82H.js", - "NativeOpsRedirect.wMMcdVjx.js", - "FloatingButtonsSettings.DM1NXEcr.js", - "PageHeader.UhySXCpq.js", - "FooterTabbarSettings.8D3P8zNv.js", - "PostPageSettings.DfkMmr7s.js", - "CommentSettings.ke9PbifA.js", - "PermissionsCapabilities.CnQfjtxC.js", - "CmsCredits.Dh2b9uHb.js", - "ThemeInfoPage.zYktV7qz.js", - "ThemeAuthorizationPage.e-ksjN6X.js", - "DocsPage.Dtd_-3fP.js", - "UsersVerify.CAJUfACd.js", - "UsersLevel.CBoqr3ux.js", - "UsersBadge.B_lwRfdl.js", - "UsersWechatTemplate.F0aCC5C-.js", - "ToolsImportExport.CN30A6Ge.js", - "ToolsHealth.B0GkAdJR.js", - "ToolsUpdate.DHtpNLfB.js" + "vendor-naive.DgdCsaiA.js", + "DeferredPage.CDpa6_lq.js", + "NativeOpsRedirect.CD8j4uaH.js", + "FloatingButtonsSettings.BB4Ds-Vh.js", + "PageHeader.C1t645NL.js", + "FooterTabbarSettings.D7iCVwp2.js", + "PostPageSettings.Cnn7DRgi.js", + "CommentSettings.1g5G6LQL.js", + "PermissionsCapabilities.eEUosHCJ.js", + "CmsCredits.DUise9Hl.js", + "ThemeInfoPage.DuqoCync.js", + "ThemeAuthorizationPage.BDGht6JA.js", + "DocsPage.CBhfO0JX.js", + "UsersVerify.CWq6m0ZY.js", + "UsersLevel.BnvrTSj0.js", + "UsersBadge.CZSYaoC4.js", + "UsersWechatTemplate.BGKwGvbY.js", + "UsersTicket.DmhmPeKC.js", + "ToolsImportExport.CuVI1mrN.js", + "ToolsHealth.BT7gJFVm.js", + "ToolsUpdate.59iH8s4m.js" ] } } \ No newline at end of file