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:
kate522andClaude Fable 5 committed 2026-10-26 19:48:58 +08:00
1 parent d32bdc0581
commit e7ca0ee5df
19 files changed
+1074 -509

No files matched your search

@@ -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]*?购买积分/)
@@ -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/);
@@ -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'));
@@ -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');
@@ -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, /<SchemaPage\s+domain="users"\s+section="ticket"/);
for (const panel of ['TicketTypePanel', 'TicketStatusPanel', 'TicketPrereplyPanel']) {
assert.ok(page.includes(panel), `panel ${panel} not mounted`);
assert.ok(
fs.existsSync(path.join(root, `assets/admin/src/pages/users/partials/ticket/${panel}.vue`)),
`panel ${panel} file missing`
);
}
// 反向深链:回到原生工单列表。
assert.match(page, /users\.php\?page=aksrt_tickets/);
assert.match(page, /window\.aksrtAdminData\?\.adminUrl/);
});
test('ticket admin REST is wrapped in its own api module and registered', () => {
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'
);
});