feat(after-sale): 支持撤销售后工单并接入人机验证

This commit is contained in:
2026-10-07 09:20:59 +08:00
parent 918ca371be
commit 8023e2e472
14 changed files with 427 additions and 82 deletions
@@ -257,10 +257,11 @@ test('order view model exposes the after-sale button matrix', () => {
test('frontend after-sale modals are standalone and config-driven', () => {
const js = read('assets/js/order-after-sale.js')
// 三个独立弹窗 + modal-mini 几何 + 蓝色炫彩标题区
// 四个独立弹窗 + modal-mini 几何 + 蓝色炫彩标题区
assert.match(js, /className: 'aksrt-after-sale-apply-modal'/)
assert.match(js, /className: 'aksrt-after-sale-detail-modal'/)
assert.match(js, /className: 'aksrt-after-sale-expired-modal'/)
assert.match(js, /className: 'aksrt-after-sale-withdraw-modal'/)
assert.match(js, /modal-colorful-header colorful-bg jb-blue/)
assert.match(js, /AksrtModalRuntime\.open\(\{/)
@@ -307,6 +308,21 @@ test('frontend after-sale modals are standalone and config-driven', () => {
assert.match(js, /data-aksrt-modal-close>我知道了</)
assert.match(js, /data-aksrt-after-sale-reapply data-aksrt-after-sale-payload="/)
// 撤销售后:入口仅随待审核状态渲染,点击先过二次确认弹窗,确认后 DELETE 工单
assert.match(js, /status === 'pending'[\s\S]*?data-aksrt-after-sale-withdraw="' \+ escapeHtml\(\(order && order\.order_number\) \|\| ''\)/)
assert.match(js, /buildWithdrawHtml\(orderNumber\)/)
assert.match(js, /className: 'aksrt-after-sale-withdraw-modal'/)
assert.match(js, /data-aksrt-after-sale-withdraw-confirm="' \+ escapeHtml\(orderNumber\) \+ '"/)
assert.match(js, /method: 'DELETE'/)
assert.match(js, /AksrtModalRuntime\.close\('withdraw'\)/)
assert.match(js, /notyf\('售后申请已撤销', 'success'\)/)
// 人机验证:提交与撤销前先过验证(场景未启用时挂载点缺失,run 直接放行)
assert.match(js, /function runCaptcha\(scene\)/)
assert.match(js, /runCaptcha\('after_sale_apply'\)/)
assert.match(js, /runCaptcha\('after_sale_cancel'\)/)
assert.match(js, /AKSRTCaptcha\.run\(scene\)/)
// 提交后关闭弹窗并刷新订单列表
assert.match(js, /AKSRTOrderCenter\.refresh\(orderNumber\)/)
assert.match(js, /window\.AksrtModalRuntime\.close\('submit'\)/)
@@ -343,9 +359,12 @@ test('order center drops the embedded after-sale form and exposes refresh()', ()
assert.match(js, /data-aksrt-after-sale-detail="/)
assert.match(js, /data-aksrt-after-sale-apply="/)
// 售后记录区与取消申请保留在订单详情弹窗
// 售后记录区保留在订单详情弹窗:记录卡片带申请时间,「查看详细」逐条入口;
// 取消申请按钮与处理器已移除(撤销入口移入售后详细弹窗)。
assert.match(js, /售后记录/)
assert.match(js, /data-aksrt-after-sale-cancel/)
assert.match(js, /<time>" \+ escapeHtml\(item\.created_at\)/)
assert.ok(!js.includes('data-aksrt-after-sale-cancel'), 'cancel entry moved into the after-sale detail modal')
assert.ok(!js.includes('取消申请'), 'order detail must not render the legacy cancel button')
// refresh 供售后弹窗提交后刷新列表与统计
assert.match(js, /function refresh\(orderNumber\) \{/)
@@ -373,14 +392,30 @@ test('after-sale assets, template buttons and runtime data are wired', () => {
assert.match(template, /esc_html_e\('售后详细', 'aksrt'\)/)
assert.ok(!template.includes('can_after_sale'), 'template must not use the removed can_after_sale flag')
// 售后人机验证挂载点(申请 / 撤销):场景未启用时 aksrt_render_captcha 无输出
assert.match(template, /function_exists\('aksrt_render_captcha'\)/)
assert.match(template, /aksrt_render_captcha\('after_sale_apply'\)/)
assert.match(template, /aksrt_render_captcha\('after_sale_cancel'\)/)
const css = read('assets/css/frontend/modal-runtime.css')
assert.match(css, /\.aksrt-after-sale-apply-modal:not\(\.aksrt-modal-runtime--bottom\) \.aksrt-modal-runtime__dialog/)
assert.match(css, /\.aksrt-after-sale-detail-modal:not\(\.aksrt-modal-runtime--bottom\) \.aksrt-modal-runtime__dialog/)
assert.match(css, /\.aksrt-after-sale-expired-modal:not\(\.aksrt-modal-runtime--bottom\) \.aksrt-modal-runtime__dialog/)
assert.match(css, /\.aksrt-after-sale-withdraw-modal:not\(\.aksrt-modal-runtime--bottom\) \.aksrt-modal-runtime__dialog/)
assert.match(css, /width: min\(400px, calc\(100vw - 32px\)\)/)
assert.match(css, /\.aksrt-after-sale-apply-modal \.modal-colorful-header/)
assert.match(css, /\.aksrt-after-sale-apply-modal \.colorful-bg > \.colorful-make/)
// 弹窗底部按钮:四个售后弹窗统一全宽平铺贴底,子按钮去独立胶囊观感(覆盖全局按钮网格)
for (const modal of ['apply', 'detail', 'expired', 'withdraw']) {
assert.match(css, new RegExp(`\\.aksrt-after-sale-${modal}-modal \\.but-average\\.modal-buts[\\s\\S]*?margin: 16px -18px -16px`))
assert.match(css, new RegExp(`\\.aksrt-after-sale-${modal}-modal \\.but-average\\.modal-buts > \\.but[\\s\\S]*?border-radius: 0`))
}
assert.match(css, /\.but:only-child \{ grid-column: 1 \/ -1; \}/)
// 撤销售后入口:待审核状态行内的文字式入口按钮
assert.match(css, /\.aksrt-after-sale-detail__statusCell/)
assert.match(css, /\.aksrt-after-sale-withdraw \{[^}]*background: transparent/)
const field = read('assets/admin/src/components/form/AksField.vue')
assert.match(field, /import draggable from 'vuedraggable'/)
assert.match(field, /v-model="repeaterDragList"/)
@@ -61,3 +61,28 @@ test('legacy public SMS-code AJAX surface is removed while SMS service remains',
assert.doesNotMatch(sms, /function ajax_send_code\s*\(/);
assert.match(sms, /public function send_code\(string \$phone, string \$code\): array/);
});
test('after-sale submit and withdraw endpoints verify captcha once per request', () => {
const service = read('src/Payment/AfterSaleService.php');
// 两个新场景已在场景白名单与后台「启用场景」勾选列表同步注册
const scenes = read('inc/modules/captcha.php');
assert.match(scenes, /'after_sale_apply'\s*=> __\('申请售后', 'aksrt'\)/);
assert.match(scenes, /'after_sale_cancel'\s*=> __\('撤销售后', 'aksrt'\)/);
const permissions = read('src/Admin/Schema/SchemaPermissions.php');
assert.match(permissions, /'label' => '申请售后', 'value' => 'after_sale_apply'/);
assert.match(permissions, /'label' => '撤销售后', 'value' => 'after_sale_cancel'/);
const adminSchema = read('assets/admin/src/schema/sections/permissions/index.js');
assert.match(adminSchema, /label: '申请售后', value: 'after_sale_apply'/);
assert.match(adminSchema, /label: '撤销售后', value: 'after_sale_cancel'/);
// REST 提交 / 撤销各过一次场景校验(统一入口 helper 内恰一次 aksrt_require_captcha);
// 场景未启用时 aksrt_require_captcha 直接放行
const submit = service.slice(service.indexOf('public function rest_submit'), service.indexOf('public function rest_list'));
const cancel = service.slice(service.indexOf('public function rest_cancel'), service.indexOf('public function create'));
assert.match(submit, /require_captcha\(\$request, 'after_sale_apply'\)/);
assert.match(cancel, /require_captcha\(\$request, 'after_sale_cancel'\)/);
assert.equal((service.match(/require_captcha\(\$request, '/g) || []).length, 2);
assert.equal((service.match(/aksrt_require_captcha\(/g) || []).length, 1);
assert.match(service, /function_exists\('aksrt_require_captcha'\)/);
});
@@ -592,6 +592,10 @@ test('profile routes load versioned leaf styles and settings dialogs use the sha
assert.match(orderCenterJs, /data-aksrt-order-filter/);
assert.match(orderCenterJs, /data-aksrt-order-page/);
assert.doesNotMatch(orderCenterJs, /new URL\(window\.location\.href\)/);
// 订单详情弹窗滚动发生在 runtime dialog 本体:滚动条必须完全隐藏(含细滚动条),滚动行为不变。
assert.match(ordersCss, /\.aksrt-order-detail-modal \.aksrt-modal-runtime__dialog\s*\{[^}]*scrollbar-width:\s*none/);
assert.match(ordersCss, /\.aksrt-order-detail-modal \.aksrt-modal-runtime__dialog::-webkit-scrollbar\s*\{\s*display:\s*none;\s*\}/);
assert.doesNotMatch(ordersCss, /aksrt-order-detail-modal[^{}]*mini-scrollbar/);
assert.doesNotMatch(userCenterJs, /\['order_status', 'order_type', 'order_number', 'order_page'\]/);
assert.match(userCenterJs, /initialTab === 'orders'[\s\S]*?window\.history\.replaceState/);
});