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
@@ -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'
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user