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>
183 lines
9.7 KiB
JavaScript
183 lines
9.7 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* 「工单中心」配置契约:
|
||
* 1) PHP SchemaUsersTicket 分区与 SPA JS schema 的键、顺序、类型、默认值 1:1 对齐;
|
||
* 2) SPA 页面已注册(/users/ticket 可达),承载 schema 表单 + 三块字典 CRUD;
|
||
* 3) 运行时 TicketSettings 缺键回退 schema 默认,数值边界被夹取;
|
||
* 4) 前台入口三处均已注册:Center.php 面板定义 + sidebar-nav.php 侧栏项 +
|
||
* aksrt-user-center.php 的 built_in_tabs 与 view map。
|
||
*/
|
||
|
||
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');
|
||
|
||
const FIELD_ORDER = [
|
||
'ticket_s',
|
||
'ticket_num_prefix',
|
||
'ticket_notice_open',
|
||
'ticket_notice_text',
|
||
'ticket_attachment_s',
|
||
'ticket_attachment_max',
|
||
'ticket_order_link_s',
|
||
'ticket_urge_interval',
|
||
];
|
||
|
||
const GROUPS = [
|
||
{ key: 'main', title: '工单功能' },
|
||
{ key: 'form', title: '提交设置' },
|
||
];
|
||
|
||
test('ticket schema keys match between PHP and SPA JS', () => {
|
||
const php = read('src/Admin/Schema/Users/SchemaUsersTicket.php');
|
||
const js = read('assets/admin/src/schema/sections/users/ticket.js');
|
||
|
||
for (const key of FIELD_ORDER) {
|
||
assert.ok(php.includes(`'key' => '${key}'`), `PHP schema missing ${key}`);
|
||
assert.ok(js.includes(`key: '${key}'`), `JS schema missing ${key}`);
|
||
}
|
||
|
||
// 顺序:两组各自的字段顺序必须一致(前端分组渲染依赖它)。
|
||
for (const group of GROUPS) {
|
||
const phpGroup = php.slice(php.indexOf(`'key' => '${group.key}'`));
|
||
const jsGroup = js.slice(js.indexOf(`key: '${group.key}'`));
|
||
const phpKeys = [...phpGroup.matchAll(/'key' => '([a-z_]+)'/g)].map((m) => m[1]);
|
||
const jsKeys = [...jsGroup.matchAll(/key: '([a-z_]+)'/g)].map((m) => m[1]);
|
||
assert.deepEqual(jsKeys, phpKeys, `group ${group.key} field order drifted`);
|
||
}
|
||
|
||
// 类型逐字段对齐。PHP 里 textarea 带 sanitize_type,取值要跳过它,
|
||
// 因此显式匹配 'type' => '...'(前后都是引号边界,不会撞上 sanitize_type)。
|
||
for (const key of FIELD_ORDER) {
|
||
const phpType = new RegExp(`'key' => '${key}'[^\\]]*'type' => '([a-z]+)'`).exec(php);
|
||
const jsType = new RegExp(`key: '${key}'[^}]*?\\btype: '([a-z]+)'`).exec(js);
|
||
assert.ok(phpType && jsType, `type not found for ${key}`);
|
||
assert.equal(jsType[1], phpType[1], `${key} type drifted`);
|
||
}
|
||
|
||
// sanitize_type 也必须一致:公告内容允许 HTML,两侧都要声明。
|
||
assert.ok(php.includes("'key' => 'ticket_notice_text', 'label' => '公告内容', 'type' => 'textarea', 'sanitize_type' => 'html'"),
|
||
'PHP notice text must declare sanitize_type html');
|
||
assert.ok(js.includes("key: 'ticket_notice_text', label: '公告内容', type: 'textarea', sanitize_type: 'html'"),
|
||
'JS notice text must declare sanitize_type html');
|
||
});
|
||
|
||
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');
|
||
|
||
const index = read('assets/admin/src/schema/sections/users/index.js');
|
||
assert.ok(index.includes("import { ticketSchema } from './ticket.js'"), 'schema not imported');
|
||
assert.ok(/ticket:\s*ticketSchema/.test(index), 'schema not exported in usersSchemas');
|
||
|
||
const constants = read('assets/admin/src/constants/sections/users.js');
|
||
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');
|
||
|
||
// PHP 侧:分区由 SchemaUsers 组装,SchemaUsersTicket 提供内容。
|
||
const users = read('src/Admin/Schema/SchemaUsers.php');
|
||
assert.ok(users.includes("'ticket' => Users\\SchemaUsersTicket::build()"), 'PHP section not composed');
|
||
// 组装位置必须在 'sections' => [ ... ] 内部。SchemaProvider::get_sections() 只读
|
||
// $schema['sections'],分区挂在同级会让 /settings/schema/users/ticket 404
|
||
// (SPA 报「未知的设置分组」)。定位 sections 开括号后用配平扫描找闭括号,
|
||
// 确认 compose 调用落在区间内——单纯 includes() 查不出层级错误。
|
||
const sectionsAt = users.indexOf("'sections' => [");
|
||
assert.ok(sectionsAt !== -1, 'sections array not found');
|
||
const composeAt = users.indexOf("'ticket' => Users\\SchemaUsersTicket::build()");
|
||
assert.ok(composeAt > sectionsAt, 'ticket must be composed after sections opens');
|
||
let depth = 0;
|
||
let sectionsEnd = -1;
|
||
for (let i = users.indexOf('[', sectionsAt); i < users.length; i++) {
|
||
if (users[i] === '[') depth++;
|
||
else if (users[i] === ']') {
|
||
depth--;
|
||
if (depth === 0) {
|
||
sectionsEnd = i;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
assert.ok(sectionsEnd !== -1, 'unbalanced sections array');
|
||
assert.ok(composeAt < sectionsEnd, 'ticket section must be INSIDE the sections array');
|
||
const ticket = read('src/Admin/Schema/Users/SchemaUsersTicket.php');
|
||
assert.ok(ticket.includes("'key' => 'ticket'"), 'PHP section key missing');
|
||
assert.ok(ticket.includes("'option_key' => 'aksrt_settings'"), 'PHP option key missing');
|
||
});
|
||
|
||
test('TicketSettings falls back to schema defaults and clamps numerics', () => {
|
||
const settings = read('src/Ticket/TicketSettings.php');
|
||
|
||
// 只取方法体,避免 docblock 与注释里的同形文字参与断言。
|
||
const body = settings.slice(settings.indexOf('public static function all()'));
|
||
|
||
// 缺键回退:enabled 只在显式关闭时为 false(与 schema default true 对齐)。
|
||
assert.match(body, /!isset\(\$s\['ticket_s'\]\) \|\| !empty\(\$s\['ticket_s'\]\)/);
|
||
assert.match(body, /self::all\(\)\['ticket_num_prefix'\] \?\? 'T'/);
|
||
assert.match(body, /self::all\(\)\['ticket_notice_text'\] \?\? ''\)/);
|
||
assert.match(body, /self::all\(\)\['ticket_attachment_max'\] \?\? 5\)/);
|
||
assert.match(body, /self::all\(\)\['ticket_urge_interval'\] \?\? 15\)/);
|
||
|
||
// 数值夹取:附件上限 1..9,催单冷却 1..1440。
|
||
assert.match(body, /max\(1, min\(9, \(int\)/);
|
||
assert.match(body, /max\(1, min\(1440, \(int\)/);
|
||
|
||
// 公告文案走 kses(后台允许 HTML)。
|
||
assert.match(body, /wp_kses_post/);
|
||
// 分区名与 schema key 一致(常量在 all() 之前声明,故对整份文件断言)。
|
||
assert.match(settings, /const SECTION = 'ticket'/);
|
||
assert.match(body, /read_domain\('users', self::SECTION\)/);
|
||
});
|
||
|
||
test('ticket panel is registered across the user-center wiring', () => {
|
||
const center = read('src/User/Center.php');
|
||
assert.ok(center.includes("'tickets' => ["), 'panel definition missing');
|
||
assert.ok(center.includes("'label' => __('工单中心', 'aksrt')"), 'panel label missing');
|
||
assert.match(center, /'tickets'\s*=>\s*\[[\s\S]*?'priority' => 49/, 'panel priority should sit next to orders (48)');
|
||
// 开关关闭时整块移除。
|
||
assert.match(center, /\$ticket_on = class_exists\('\\AKSRT\\Ticket\\TicketSettings'\)/);
|
||
assert.match(center, /if \(!\$ticket_on\) \{\s*\n\s*\$order = array_values\(array_filter\(\$order, static fn\(string \$key\): bool => \$key !== 'tickets'\)\);/);
|
||
assert.match(center, /if \(\$key === 'tickets' && !\$ticket_on\) \{\s*\n\s*continue;/);
|
||
|
||
const sidebar = read('inc/helpers/user-center-renderers/sidebar-nav.php');
|
||
assert.ok(sidebar.includes("'label' => __('工单中心', 'aksrt')"), 'sidebar label missing');
|
||
assert.ok(sidebar.includes("'icon' => 'manual-service'"), 'sidebar icon should reuse the existing headset sprite');
|
||
assert.ok(sidebar.includes("'tab' => 'tickets'"), 'sidebar tab key missing');
|
||
|
||
const helper = read('inc/helpers/aksrt-user-center.php');
|
||
assert.ok(helper.includes("'tickets'"), 'built_in_tabs missing tickets');
|
||
assert.match(helper, /'tickets'\s*=> 'tickets',/, 'view map missing tickets => tickets');
|
||
|
||
const renderer = read('inc/helpers/user-center-renderers/profile-tickets.php');
|
||
assert.ok(renderer.includes('data-aksrt-ticket-center'), 'panel root hook missing');
|
||
assert.ok(renderer.includes('data-aksrt-ticket-create'), 'create button hook missing');
|
||
assert.ok(renderer.includes("aksrt_render_captcha('ticket_create')"), 'create captcha mount missing');
|
||
assert.ok(renderer.includes("aksrt_render_captcha('ticket_reply')"), 'reply captcha mount missing');
|
||
});
|
||
|
||
test('ticket frontend assets are enqueued only when the panel exists', () => {
|
||
const scripts = read('src/Core/ScriptsPart1b.php');
|
||
assert.match(scripts, /\$has_tickets_panel = false;/, 'panel flag not initialised');
|
||
assert.match(scripts, /\$has_tickets_panel = isset\(\$dashboard_tabs\['tickets'\]\);/, 'panel flag not derived');
|
||
assert.ok(scripts.includes("'aksrt-tickets'"), 'ticket CSS handle missing');
|
||
assert.ok(scripts.includes("'aksrt-ticket-center'"), 'ticket JS handle missing');
|
||
assert.ok(scripts.includes('/assets/js/ticket-center.js'), 'ticket JS path missing');
|
||
assert.ok(scripts.includes('/assets/css/routes/tickets.css'), 'ticket CSS path missing');
|
||
// 两个入队都必须在 $has_tickets_panel 条件内。
|
||
const cssIndex = scripts.indexOf("'aksrt-tickets'");
|
||
const jsIndex = scripts.indexOf("'aksrt-ticket-center'");
|
||
assert.ok(scripts.lastIndexOf('if ($has_tickets_panel) {', cssIndex) > scripts.indexOf("'aksrt-orders'"),
|
||
'ticket CSS must be gated on its own panel flag');
|
||
assert.ok(jsIndex > cssIndex, 'ticket JS should be enqueued alongside its CSS');
|
||
|
||
const localize = read('src/Core/ScriptsPart1c.php');
|
||
assert.ok(localize.includes("'ticket' => (function () {"), 'localize key missing');
|
||
assert.ok(!localize.includes("'afterSale'"), 'stale afterSale localize must be gone');
|
||
});
|