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

@@ -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'));