feat(payment): 新增「售后策略」配置与前台售后弹窗链路
- 后台「支付&付费 → 售后策略」:售后功能开关、申请时效、超时效提示(HTML)、 预置原因、申请售后说明、所需材料、申请说明占位文案、可上传图片开关; PHP schema 与 SPA schema 1:1 对齐,group 字段支持拖拽排序 - PaymentRuntime 新增售后设置解析(缺键回退 schema 默认、HTML 走 kses、 中文原因稳定 slug)与 after_sale_expired 时效判定(支付时间起算) - 售后工单支持举证图片(order_aftersale.images 列,最多 3 张, 复用评论图片上传校验)与上传路由;订单视图模型输出 after_sale 按钮状态 - 前台申请 / 超时效提示 / 售后详细三个独立弹窗(modal-mini + 蓝色炫彩头), 订单详情弹窗不再内嵌售后表单并清理相关死代码 - 新增 after-sale-policy 契约测试与运行时 harness,527 项契约测试通过
This commit is contained in:
@@ -507,15 +507,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Repeater (array of objects) — inline expand (default) -->
|
||||
<!-- Repeater (array of objects) — inline expand (default, draggable) -->
|
||||
<div v-else-if="(schema.type === 'repeater' || schema.type === 'group') && schema.ui !== 'list-modal'" class="aksrt-field__repeater">
|
||||
<div
|
||||
v-for="(item, index) in repeaterItems"
|
||||
:key="item._id || index"
|
||||
class="aksrt-field__repeater-item"
|
||||
<draggable
|
||||
v-model="repeaterDragList"
|
||||
:item-key="repeaterDragKey"
|
||||
handle=".aksrt-field__repeater-handle"
|
||||
ghost-class="aksrt-field__repeater-ghost"
|
||||
animation="180"
|
||||
>
|
||||
<template #item="{ element, index }">
|
||||
<div class="aksrt-field__repeater-item">
|
||||
<div class="aksrt-field__repeater-header">
|
||||
<div class="aksrt-field__repeater-title">
|
||||
<i class="fa fas fa-grip-vertical aksrt-field__repeater-handle" aria-hidden="true"></i>
|
||||
<span class="aksrt-field__repeater-index">#{{ index + 1 }}</span>
|
||||
</div>
|
||||
<n-button
|
||||
size="tiny"
|
||||
type="error"
|
||||
@@ -529,9 +536,9 @@
|
||||
<div class="aksrt-field__repeater-fields">
|
||||
<template v-for="field in (schema.fields || [])" :key="field.key">
|
||||
<AksField
|
||||
v-if="matchDependency(field.dependsOn, item)"
|
||||
v-if="matchDependency(field.dependsOn, element)"
|
||||
:schema="field"
|
||||
:model-value="item[field.key]"
|
||||
:model-value="element[field.key]"
|
||||
:form-data="formData"
|
||||
:disabled="schema.disabled"
|
||||
@update:model-value="updateRepeaterItem(index, field.key, $event)"
|
||||
@@ -539,6 +546,8 @@
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
<n-button
|
||||
size="small"
|
||||
dashed
|
||||
@@ -688,6 +697,7 @@ import FaIconPicker from './FaIconPicker.vue'
|
||||
import AksMediaField from './AksMediaField.vue'
|
||||
import AksGalleryField from './AksGalleryField.vue'
|
||||
import AksCodeEditor from './AksCodeEditor.vue'
|
||||
import draggable from 'vuedraggable'
|
||||
import api from '@/api'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -720,6 +730,31 @@ const repeaterItems = computed(() => Array.isArray(props.modelValue) ? props.mod
|
||||
const reachedRepeaterMinimum = computed(() => repeaterLength.value <= Number(props.schema.min || 0))
|
||||
const reachedRepeaterMaximum = computed(() => Number(props.schema.max || 0) > 0 && repeaterLength.value >= Number(props.schema.max))
|
||||
|
||||
/** 拖拽视图:vuedraggable 以 modelValue 模式工作(内部复制后回传 update:modelValue)。 */
|
||||
const repeaterDragList = computed({
|
||||
get: () => (Array.isArray(props.modelValue) ? props.modelValue : []),
|
||||
set: (value) => emit('update:modelValue', Array.isArray(value) ? value : []),
|
||||
})
|
||||
|
||||
/**
|
||||
* 行 key:优先用行自身 _id;服务端历史数据没有 _id 时按对象引用派生稳定 key,
|
||||
* 避免每次输入都重算 key 导致子字段重挂载、输入焦点丢失。
|
||||
*/
|
||||
const dragRowKeys = new WeakMap()
|
||||
let dragRowKeySeed = 0
|
||||
function repeaterDragKey(item) {
|
||||
if (item && typeof item === 'object') {
|
||||
if (item._id) return item._id
|
||||
let key = dragRowKeys.get(item)
|
||||
if (!key) {
|
||||
key = `rpt_drag_${++dragRowKeySeed}`
|
||||
dragRowKeys.set(item, key)
|
||||
}
|
||||
return key
|
||||
}
|
||||
return String(item)
|
||||
}
|
||||
|
||||
function fieldsetValue(field) {
|
||||
const current = props.modelValue && typeof props.modelValue === 'object' && !Array.isArray(props.modelValue)
|
||||
? props.modelValue
|
||||
@@ -1431,9 +1466,27 @@ function confirmRepeaterModal() {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.aksrt-field__repeater-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.aksrt-field__repeater-handle {
|
||||
cursor: grab;
|
||||
color: var(--aksrt-color-text-tertiary);
|
||||
}
|
||||
|
||||
.aksrt-field__repeater-ghost {
|
||||
opacity: 0.45;
|
||||
border-color: var(--aksrt-color-primary);
|
||||
}
|
||||
|
||||
.aksrt-field__repeater-index {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -88,5 +88,16 @@ export const paymentSection = {
|
||||
canonicalDomain: 'payment',
|
||||
section: 'withdrawal',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'payment-after-sale',
|
||||
name: 'PaymentAfterSale',
|
||||
label: '售后策略',
|
||||
route: '/payment/after-sale',
|
||||
status: 'available',
|
||||
title: '售后策略',
|
||||
desc: '售后功能开关、申请时效、超时效提示与申请表单(预置原因、所需材料、图片上传)配置。',
|
||||
canonicalDomain: 'payment',
|
||||
section: 'after_sale',
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 售后策略 — 售后开关、申请时效、超时效提示与申请表单配置。
|
||||
*
|
||||
* 字段键、顺序、类型与默认值需与 src/Admin/Schema/SchemaPayment.php 的
|
||||
* after_sale 分区保持 1:1(tests/widget-system/after-sale-policy-contract.test.cjs 守护)。
|
||||
*/
|
||||
export const afterSaleSchema = {
|
||||
key: 'payment.after_sale',
|
||||
title: '售后策略',
|
||||
icon: 'fa fas fa-undo-alt',
|
||||
description: '售后功能开关、申请时效、超时效提示与申请表单(预置原因、申请说明、所需材料、图片上传)配置。',
|
||||
defaults: {
|
||||
after_sale_s: true,
|
||||
after_sale_time_limit: 7,
|
||||
after_sale_expired_text: '<div>抱歉,您的订单已超过售后申请时效</div>',
|
||||
after_sale_reasons: [
|
||||
{ t: '质量问题' },
|
||||
{ t: '假冒品牌' },
|
||||
{ t: '其他原因' },
|
||||
],
|
||||
after_sale_desc: '',
|
||||
after_sale_materials: [
|
||||
{ t: '联系邮箱' },
|
||||
{ t: '联系电话' },
|
||||
],
|
||||
after_sale_reason_placeholder: '必填,请您详细填写申请原因',
|
||||
after_sale_img_s: true,
|
||||
},
|
||||
groups: [
|
||||
{
|
||||
key: 'main',
|
||||
title: '售后功能',
|
||||
icon: 'fa fas fa-undo-alt',
|
||||
fields: [
|
||||
{ key: 'after_sale_s', label: '售后功能', type: 'switch', default: true, help: '开启后用户可以申请售后;关闭后不再处理售后,前台「我的订单」不再显示申请售后按钮' },
|
||||
{ key: 'after_sale_time_limit', label: '申请售后时效(天)', type: 'number', default: 7, min: 0, max: 3650, help: '订单支付后多少天内可以申请售后,超过时效将无法申请;0 表示不限制' },
|
||||
{ key: 'after_sale_expired_text', label: '超过时效提示', type: 'textarea', sanitize_type: 'html', default: '<div>抱歉,您的订单已超过售后申请时效</div>', rows: 3, help: '订单超过售后时效后,用户点击申请售后时弹窗提示的内容(支持HTML代码,请注意代码规范)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'form',
|
||||
title: '申请表单',
|
||||
icon: 'fa fas fa-file-alt',
|
||||
fields: [
|
||||
{
|
||||
key: 'after_sale_reasons',
|
||||
label: '预置原因',
|
||||
type: 'group',
|
||||
button_title: '添加原因',
|
||||
help: '用户申请售后时可选择的原因',
|
||||
default: [
|
||||
{ t: '质量问题' },
|
||||
{ t: '假冒品牌' },
|
||||
{ t: '其他原因' },
|
||||
],
|
||||
fields: [
|
||||
{ key: 't', label: '原因', type: 'input', default: '' },
|
||||
],
|
||||
},
|
||||
{ key: 'after_sale_desc', label: '申请售后说明', type: 'textarea', sanitize_type: 'html', default: '', rows: 5, help: '用户申请售后时显示的内容(支持HTML代码,请注意代码规范)' },
|
||||
{
|
||||
key: 'after_sale_materials',
|
||||
label: '申请所需材料',
|
||||
type: 'group',
|
||||
button_title: '添加材料',
|
||||
help: '用户提交售后申请时需要提供的资料,如联系邮箱、联系电话;此处的信息收集,仅用于售后联系与审核',
|
||||
default: [
|
||||
{ t: '联系邮箱' },
|
||||
{ t: '联系电话' },
|
||||
],
|
||||
fields: [
|
||||
{ key: 't', label: '资料项', type: 'input', default: '' },
|
||||
],
|
||||
},
|
||||
{ key: 'after_sale_reason_placeholder', label: '申请说明占位文案', type: 'input', default: '必填,请您详细填写申请原因', help: '前台申请售后弹窗中「申请说明」输入框的提示文字' },
|
||||
{ key: 'after_sale_img_s', label: '申请售后可上传图片', type: 'switch', default: true, help: '开启后申请售后弹窗中出现图片上传入口(最多上传3张)' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { revenueSchema } from './revenue.js'
|
||||
import { affiliateSchema } from './affiliate.js'
|
||||
import { gatewaySchema } from './gateway.js'
|
||||
import { withdrawalSchema } from './withdrawal.js'
|
||||
import { afterSaleSchema } from './afterSale.js'
|
||||
|
||||
export const paymentSchemas = {
|
||||
knowledge: knowledgeSchema,
|
||||
@@ -18,4 +19,5 @@ export const paymentSchemas = {
|
||||
affiliate: affiliateSchema,
|
||||
gateway: gatewaySchema,
|
||||
withdrawal: withdrawalSchema,
|
||||
after_sale: afterSaleSchema,
|
||||
}
|
||||
|
||||
+184
-5
@@ -6429,14 +6429,15 @@ body.aksrt-modal-runtime-open {
|
||||
color: var(--color-ink, #111);
|
||||
}
|
||||
|
||||
:is(.order-comment-from, .aksrt-report-modal) .form-upload .preview {
|
||||
:is(.order-comment-from, .aksrt-report-modal, .aksrt-after-sale-apply-modal) .form-upload .preview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
:is(.order-comment-from, .aksrt-report-modal) .form-upload .preview .add,
|
||||
:is(.order-comment-from, .aksrt-report-modal) .form-upload .preview .preview-item {
|
||||
:is(.order-comment-from, .aksrt-report-modal, .aksrt-after-sale-apply-modal) .form-upload .preview .add,
|
||||
:is(.order-comment-from, .aksrt-report-modal, .aksrt-after-sale-apply-modal) .form-upload .preview .preview-item {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 10px;
|
||||
@@ -6452,14 +6453,14 @@ body.aksrt-modal-runtime-open {
|
||||
此前这里的 left/top:50% + translate 版本与该规则叠加导致过约束,
|
||||
加号被推到 75%/75% 只剩“L”形,故整体删除、统一走主样式包。 */
|
||||
|
||||
:is(.order-comment-from, .aksrt-report-modal) .form-upload .preview .preview-item img {
|
||||
:is(.order-comment-from, .aksrt-report-modal, .aksrt-after-sale-apply-modal) .form-upload .preview .preview-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
:is(.order-comment-from, .aksrt-report-modal) .form-upload .preview .preview-item .remove {
|
||||
:is(.order-comment-from, .aksrt-report-modal, .aksrt-after-sale-apply-modal) .form-upload .preview .preview-item .remove {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
@@ -6474,7 +6475,7 @@ body.aksrt-modal-runtime-open {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:is(.order-comment-from, .aksrt-report-modal) .form-upload .hide {
|
||||
:is(.order-comment-from, .aksrt-report-modal, .aksrt-after-sale-apply-modal) .form-upload .hide {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -6490,6 +6491,184 @@ body.aksrt-modal-runtime-open {
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 售后弹窗(申请 / 售后详细 / 超时效提示,参考主题 modal-mini 几何 1:1)----
|
||||
三个弹窗与禁封申诉弹窗同几何:data_class='modal-mini'(宽 400px),圆角与
|
||||
底色走 --main-radius / --main-bg-color;移动端由 --bottom 底部抽屉接管。 */
|
||||
.aksrt-after-sale-apply-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog,
|
||||
.aksrt-after-sale-detail-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog,
|
||||
.aksrt-after-sale-expired-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog {
|
||||
width: min(400px, calc(100vw - 32px));
|
||||
border-radius: var(--main-radius, 8px);
|
||||
background: var(--main-bg-color, #fff);
|
||||
}
|
||||
|
||||
.aksrt-after-sale-apply-modal .aksrt-modal-runtime__skeleton,
|
||||
.aksrt-after-sale-detail-modal .aksrt-modal-runtime__skeleton,
|
||||
.aksrt-after-sale-expired-modal .aksrt-modal-runtime__skeleton {
|
||||
padding: 24px 18px;
|
||||
}
|
||||
|
||||
/* 蓝色炫彩标题区(参考主题 zib_get_modal_colorful_header('jb-blue', ...)):120px
|
||||
绝对定位横幅 + 旋转彩斑,后续内容整体下移 120px,与禁封记录弹窗同构。 */
|
||||
.aksrt-after-sale-apply-modal .modal-colorful-header,
|
||||
.aksrt-after-sale-detail-modal .modal-colorful-header,
|
||||
.aksrt-after-sale-expired-modal .modal-colorful-header,
|
||||
.aksrt-after-sale-apply-modal .colorful-bg,
|
||||
.aksrt-after-sale-detail-modal .colorful-bg,
|
||||
.aksrt-after-sale-expired-modal .colorful-bg {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
color: var(--this-color);
|
||||
background: var(--this-bg);
|
||||
background-size: 120%;
|
||||
}
|
||||
|
||||
.aksrt-after-sale-apply-modal .modal-colorful-header,
|
||||
.aksrt-after-sale-detail-modal .modal-colorful-header,
|
||||
.aksrt-after-sale-expired-modal .modal-colorful-header {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 120px;
|
||||
border-radius: var(--main-radius, 8px) var(--main-radius, 8px) 0 0;
|
||||
}
|
||||
|
||||
.aksrt-after-sale-apply-modal .modal-colorful-header + *,
|
||||
.aksrt-after-sale-detail-modal .modal-colorful-header + *,
|
||||
.aksrt-after-sale-expired-modal .modal-colorful-header + * {
|
||||
margin-top: 120px;
|
||||
}
|
||||
|
||||
.aksrt-after-sale-apply-modal .modal-colorful-header > .close,
|
||||
.aksrt-after-sale-detail-modal .modal-colorful-header > .close,
|
||||
.aksrt-after-sale-expired-modal .modal-colorful-header > .close {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 9;
|
||||
color: var(--this-color);
|
||||
font-size: 18px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.aksrt-after-sale-apply-modal .colorful-bg > .colorful-make,
|
||||
.aksrt-after-sale-apply-modal .colorful-bg > .colorful-make::before,
|
||||
.aksrt-after-sale-apply-modal .colorful-bg > .colorful-make::after,
|
||||
.aksrt-after-sale-detail-modal .colorful-bg > .colorful-make,
|
||||
.aksrt-after-sale-detail-modal .colorful-bg > .colorful-make::before,
|
||||
.aksrt-after-sale-detail-modal .colorful-bg > .colorful-make::after,
|
||||
.aksrt-after-sale-expired-modal .colorful-bg > .colorful-make,
|
||||
.aksrt-after-sale-expired-modal .colorful-bg > .colorful-make::before,
|
||||
.aksrt-after-sale-expired-modal .colorful-bg > .colorful-make::after {
|
||||
position: absolute;
|
||||
color: var(--this-color);
|
||||
background: var(--this-bg);
|
||||
border-radius: 100%;
|
||||
content: '';
|
||||
}
|
||||
|
||||
.aksrt-after-sale-apply-modal .colorful-bg > .colorful-make,
|
||||
.aksrt-after-sale-detail-modal .colorful-bg > .colorful-make,
|
||||
.aksrt-after-sale-expired-modal .colorful-bg > .colorful-make {
|
||||
top: 60%;
|
||||
bottom: 0;
|
||||
width: 323px;
|
||||
height: 290px;
|
||||
animation: aksrt-ban-colorful 120s linear infinite;
|
||||
}
|
||||
|
||||
.aksrt-after-sale-apply-modal .colorful-bg > .colorful-make::before,
|
||||
.aksrt-after-sale-detail-modal .colorful-bg > .colorful-make::before,
|
||||
.aksrt-after-sale-expired-modal .colorful-bg > .colorful-make::before {
|
||||
top: -325px;
|
||||
right: -558px;
|
||||
bottom: 0;
|
||||
width: 677px;
|
||||
height: 300px;
|
||||
opacity: 0.6;
|
||||
transform: rotate(341deg);
|
||||
}
|
||||
|
||||
.aksrt-after-sale-apply-modal .colorful-bg > .colorful-make::after,
|
||||
.aksrt-after-sale-detail-modal .colorful-bg > .colorful-make::after,
|
||||
.aksrt-after-sale-expired-modal .colorful-bg > .colorful-make::after {
|
||||
top: -170px;
|
||||
bottom: 0;
|
||||
left: -262px;
|
||||
width: 402px;
|
||||
height: 214px;
|
||||
opacity: 0.4;
|
||||
transform: rotate(326deg);
|
||||
}
|
||||
|
||||
/* 售后原因徽章:与举报弹窗一致,可换行且间距走 mr6/mb6 工具类。 */
|
||||
.aksrt-after-sale-apply-modal .form-but-radio .badg {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 订单摘要与售后明细行 */
|
||||
.aksrt-after-sale-summary__row,
|
||||
.aksrt-after-sale-detail__row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.aksrt-after-sale-summary__row:last-child,
|
||||
.aksrt-after-sale-detail__row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.aksrt-after-sale-summary__value,
|
||||
.aksrt-after-sale-detail__value {
|
||||
min-width: 0;
|
||||
text-align: right;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.aksrt-after-sale-detail__statusRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.aksrt-after-sale-reason-text {
|
||||
color: var(--color-ink-2, #4e5358);
|
||||
background: var(--surface-2, #eee);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.aksrt-after-sale-images {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.aksrt-after-sale-images img {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-line, #e5e7eb);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.aksrt-after-sale-status.is-pending { color: var(--color-warning, #f59e0b); }
|
||||
.aksrt-after-sale-status.is-processing { color: var(--color-info, #3b82f6); }
|
||||
.aksrt-after-sale-status.is-approved,
|
||||
.aksrt-after-sale-status.is-completed { color: var(--color-success, #10b981); }
|
||||
.aksrt-after-sale-status.is-rejected,
|
||||
.aksrt-after-sale-status.is-cancelled { color: var(--color-error, #ef4444); }
|
||||
|
||||
/* source: assets/css/frontend/notyf.css */
|
||||
/**
|
||||
* legacy-compatible frontend notifications.
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -552,14 +552,15 @@ body.aksrt-modal-runtime-open {
|
||||
color: var(--color-ink, #111);
|
||||
}
|
||||
|
||||
:is(.order-comment-from, .aksrt-report-modal) .form-upload .preview {
|
||||
:is(.order-comment-from, .aksrt-report-modal, .aksrt-after-sale-apply-modal) .form-upload .preview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
:is(.order-comment-from, .aksrt-report-modal) .form-upload .preview .add,
|
||||
:is(.order-comment-from, .aksrt-report-modal) .form-upload .preview .preview-item {
|
||||
:is(.order-comment-from, .aksrt-report-modal, .aksrt-after-sale-apply-modal) .form-upload .preview .add,
|
||||
:is(.order-comment-from, .aksrt-report-modal, .aksrt-after-sale-apply-modal) .form-upload .preview .preview-item {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 10px;
|
||||
@@ -575,14 +576,14 @@ body.aksrt-modal-runtime-open {
|
||||
此前这里的 left/top:50% + translate 版本与该规则叠加导致过约束,
|
||||
加号被推到 75%/75% 只剩“L”形,故整体删除、统一走主样式包。 */
|
||||
|
||||
:is(.order-comment-from, .aksrt-report-modal) .form-upload .preview .preview-item img {
|
||||
:is(.order-comment-from, .aksrt-report-modal, .aksrt-after-sale-apply-modal) .form-upload .preview .preview-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
:is(.order-comment-from, .aksrt-report-modal) .form-upload .preview .preview-item .remove {
|
||||
:is(.order-comment-from, .aksrt-report-modal, .aksrt-after-sale-apply-modal) .form-upload .preview .preview-item .remove {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
@@ -597,7 +598,7 @@ body.aksrt-modal-runtime-open {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:is(.order-comment-from, .aksrt-report-modal) .form-upload .hide {
|
||||
:is(.order-comment-from, .aksrt-report-modal, .aksrt-after-sale-apply-modal) .form-upload .hide {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -612,3 +613,181 @@ body.aksrt-modal-runtime-open {
|
||||
width: calc(100vw - 20px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 售后弹窗(申请 / 售后详细 / 超时效提示,参考主题 modal-mini 几何 1:1)----
|
||||
三个弹窗与禁封申诉弹窗同几何:data_class='modal-mini'(宽 400px),圆角与
|
||||
底色走 --main-radius / --main-bg-color;移动端由 --bottom 底部抽屉接管。 */
|
||||
.aksrt-after-sale-apply-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog,
|
||||
.aksrt-after-sale-detail-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog,
|
||||
.aksrt-after-sale-expired-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog {
|
||||
width: min(400px, calc(100vw - 32px));
|
||||
border-radius: var(--main-radius, 8px);
|
||||
background: var(--main-bg-color, #fff);
|
||||
}
|
||||
|
||||
.aksrt-after-sale-apply-modal .aksrt-modal-runtime__skeleton,
|
||||
.aksrt-after-sale-detail-modal .aksrt-modal-runtime__skeleton,
|
||||
.aksrt-after-sale-expired-modal .aksrt-modal-runtime__skeleton {
|
||||
padding: 24px 18px;
|
||||
}
|
||||
|
||||
/* 蓝色炫彩标题区(参考主题 zib_get_modal_colorful_header('jb-blue', ...)):120px
|
||||
绝对定位横幅 + 旋转彩斑,后续内容整体下移 120px,与禁封记录弹窗同构。 */
|
||||
.aksrt-after-sale-apply-modal .modal-colorful-header,
|
||||
.aksrt-after-sale-detail-modal .modal-colorful-header,
|
||||
.aksrt-after-sale-expired-modal .modal-colorful-header,
|
||||
.aksrt-after-sale-apply-modal .colorful-bg,
|
||||
.aksrt-after-sale-detail-modal .colorful-bg,
|
||||
.aksrt-after-sale-expired-modal .colorful-bg {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
color: var(--this-color);
|
||||
background: var(--this-bg);
|
||||
background-size: 120%;
|
||||
}
|
||||
|
||||
.aksrt-after-sale-apply-modal .modal-colorful-header,
|
||||
.aksrt-after-sale-detail-modal .modal-colorful-header,
|
||||
.aksrt-after-sale-expired-modal .modal-colorful-header {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 120px;
|
||||
border-radius: var(--main-radius, 8px) var(--main-radius, 8px) 0 0;
|
||||
}
|
||||
|
||||
.aksrt-after-sale-apply-modal .modal-colorful-header + *,
|
||||
.aksrt-after-sale-detail-modal .modal-colorful-header + *,
|
||||
.aksrt-after-sale-expired-modal .modal-colorful-header + * {
|
||||
margin-top: 120px;
|
||||
}
|
||||
|
||||
.aksrt-after-sale-apply-modal .modal-colorful-header > .close,
|
||||
.aksrt-after-sale-detail-modal .modal-colorful-header > .close,
|
||||
.aksrt-after-sale-expired-modal .modal-colorful-header > .close {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 9;
|
||||
color: var(--this-color);
|
||||
font-size: 18px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.aksrt-after-sale-apply-modal .colorful-bg > .colorful-make,
|
||||
.aksrt-after-sale-apply-modal .colorful-bg > .colorful-make::before,
|
||||
.aksrt-after-sale-apply-modal .colorful-bg > .colorful-make::after,
|
||||
.aksrt-after-sale-detail-modal .colorful-bg > .colorful-make,
|
||||
.aksrt-after-sale-detail-modal .colorful-bg > .colorful-make::before,
|
||||
.aksrt-after-sale-detail-modal .colorful-bg > .colorful-make::after,
|
||||
.aksrt-after-sale-expired-modal .colorful-bg > .colorful-make,
|
||||
.aksrt-after-sale-expired-modal .colorful-bg > .colorful-make::before,
|
||||
.aksrt-after-sale-expired-modal .colorful-bg > .colorful-make::after {
|
||||
position: absolute;
|
||||
color: var(--this-color);
|
||||
background: var(--this-bg);
|
||||
border-radius: 100%;
|
||||
content: '';
|
||||
}
|
||||
|
||||
.aksrt-after-sale-apply-modal .colorful-bg > .colorful-make,
|
||||
.aksrt-after-sale-detail-modal .colorful-bg > .colorful-make,
|
||||
.aksrt-after-sale-expired-modal .colorful-bg > .colorful-make {
|
||||
top: 60%;
|
||||
bottom: 0;
|
||||
width: 323px;
|
||||
height: 290px;
|
||||
animation: aksrt-ban-colorful 120s linear infinite;
|
||||
}
|
||||
|
||||
.aksrt-after-sale-apply-modal .colorful-bg > .colorful-make::before,
|
||||
.aksrt-after-sale-detail-modal .colorful-bg > .colorful-make::before,
|
||||
.aksrt-after-sale-expired-modal .colorful-bg > .colorful-make::before {
|
||||
top: -325px;
|
||||
right: -558px;
|
||||
bottom: 0;
|
||||
width: 677px;
|
||||
height: 300px;
|
||||
opacity: 0.6;
|
||||
transform: rotate(341deg);
|
||||
}
|
||||
|
||||
.aksrt-after-sale-apply-modal .colorful-bg > .colorful-make::after,
|
||||
.aksrt-after-sale-detail-modal .colorful-bg > .colorful-make::after,
|
||||
.aksrt-after-sale-expired-modal .colorful-bg > .colorful-make::after {
|
||||
top: -170px;
|
||||
bottom: 0;
|
||||
left: -262px;
|
||||
width: 402px;
|
||||
height: 214px;
|
||||
opacity: 0.4;
|
||||
transform: rotate(326deg);
|
||||
}
|
||||
|
||||
/* 售后原因徽章:与举报弹窗一致,可换行且间距走 mr6/mb6 工具类。 */
|
||||
.aksrt-after-sale-apply-modal .form-but-radio .badg {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 订单摘要与售后明细行 */
|
||||
.aksrt-after-sale-summary__row,
|
||||
.aksrt-after-sale-detail__row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.aksrt-after-sale-summary__row:last-child,
|
||||
.aksrt-after-sale-detail__row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.aksrt-after-sale-summary__value,
|
||||
.aksrt-after-sale-detail__value {
|
||||
min-width: 0;
|
||||
text-align: right;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.aksrt-after-sale-detail__statusRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.aksrt-after-sale-reason-text {
|
||||
color: var(--color-ink-2, #4e5358);
|
||||
background: var(--surface-2, #eee);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.aksrt-after-sale-images {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.aksrt-after-sale-images img {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-line, #e5e7eb);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.aksrt-after-sale-status.is-pending { color: var(--color-warning, #f59e0b); }
|
||||
.aksrt-after-sale-status.is-processing { color: var(--color-info, #3b82f6); }
|
||||
.aksrt-after-sale-status.is-approved,
|
||||
.aksrt-after-sale-status.is-completed { color: var(--color-success, #10b981); }
|
||||
.aksrt-after-sale-status.is-rejected,
|
||||
.aksrt-after-sale-status.is-cancelled { color: var(--color-error, #ef4444); }
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
/**
|
||||
* AKSRT After-Sale — 前台售后弹窗(申请 / 超时效提示 / 售后详细)
|
||||
*
|
||||
* 与「禁封/举报」链路同构(report.js / user-ban.js):
|
||||
* - [data-aksrt-after-sale-apply] → 申请售后弹窗(超时效则改为超时效提示弹窗)
|
||||
* - [data-aksrt-after-sale-detail] → 售后详细弹窗(可再次申请 / 不可再申请提示)
|
||||
* - 提交走 REST /aksrt/v1/orders/{order_number}/after-sale
|
||||
* - 举证图片走 /orders/{order_number}/after-sale/upload-image(复用评论图片上传校验)
|
||||
*
|
||||
* 按钮形态由订单视图模型的 after_sale 状态决定(UserOrders::after_sale_state):
|
||||
* 已申请 → 售后详细;未申请且在时效内 → 申请售后;超时效 → 申请售后(点击弹超时效提示)。
|
||||
* 弹窗统一经 AksrtModalRuntime.open 直出 HTML:桌面为主题 modal-mini 几何
|
||||
* (400px)+ 蓝色炫彩标题区(modal-colorful-header colorful-bg jb-blue),
|
||||
* 移动端由 mobileBottom 底部抽屉接管。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var API_ROOT = (function () {
|
||||
var d = window.aksrtData || {};
|
||||
var raw = d.apiUrl || d.restUrl || d.rest_url || '/index.php?rest_route=/aksrt/v1';
|
||||
return String(raw).replace(/\/$/, '');
|
||||
})();
|
||||
var NONCE = (window.aksrtData && window.aksrtData.restNonce) || '';
|
||||
var MAX_IMAGES = 3;
|
||||
// 不可再申请售后(工单未结束 / 已完结 / 超过时效)时的固定提示。
|
||||
var REAPPLY_BLOCKED_TEXT = '该无法再次申请售后,该订单已售后,暂不支持再次申请售后。';
|
||||
|
||||
var STATUS_LABELS = {
|
||||
pending: '待审核',
|
||||
processing: '处理中',
|
||||
approved: '已通过',
|
||||
rejected: '已拒绝',
|
||||
cancelled: '已取消',
|
||||
completed: '已完成',
|
||||
};
|
||||
|
||||
function settings() {
|
||||
var d = window.aksrtData && window.aksrtData.afterSale;
|
||||
return d && typeof d === 'object' ? d : {};
|
||||
}
|
||||
|
||||
function reasonOptions() {
|
||||
var list = settings().reasons;
|
||||
if (!Array.isArray(list)) return [];
|
||||
return list
|
||||
.map(function (item) {
|
||||
return {
|
||||
slug: String((item && (item.slug || item.value)) || ''),
|
||||
name: String((item && (item.name || item.label || item.t)) || ''),
|
||||
};
|
||||
})
|
||||
.filter(function (item) { return item.name !== ''; });
|
||||
}
|
||||
|
||||
function materialOptions() {
|
||||
var list = settings().materials;
|
||||
if (!Array.isArray(list)) return [];
|
||||
return list.map(function (item) { return String(item == null ? '' : item); })
|
||||
.filter(function (text) { return text !== ''; });
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value == null ? '' : value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function colorfulHeader(title, icon) {
|
||||
return '<div class="modal-colorful-header colorful-bg jb-blue">' +
|
||||
'<button type="button" class="close" data-aksrt-modal-close aria-label="关闭"><svg class="ic-close" aria-hidden="true"><use xlink:href="#icon-close"></use></svg></button>' +
|
||||
'<div class="colorful-make"></div>' +
|
||||
'<div class="text-center"><div class="em2x"><i class="fa fas ' + escapeHtml(icon) + '" aria-hidden="true"></i></div>' +
|
||||
'<div class="mt10 em12 padding-w10">' + escapeHtml(title) + '</div></div></div>';
|
||||
}
|
||||
|
||||
function orderAmountText(order) {
|
||||
var raw = String(order && order.amount != null ? order.amount : '0');
|
||||
if (order && order.is_points) {
|
||||
return escapeHtml(String(order.currency_symbol || '积分')) + ' ' + (/^\d+$/.test(raw) ? raw : '0');
|
||||
}
|
||||
return '¥' + raw;
|
||||
}
|
||||
|
||||
function aftersaleAmountText(value, order) {
|
||||
var amount = String(value == null ? '' : value);
|
||||
return order && order.is_points ? amount + ' ' + String((order && order.currency_symbol) || '积分') : '¥' + amount;
|
||||
}
|
||||
|
||||
function orderSummaryHtml(order) {
|
||||
if (!order) return '';
|
||||
return '<div class="muted-box padding-10 mb20 em09 aksrt-after-sale-summary">' +
|
||||
'<div class="aksrt-after-sale-summary__row"><span class="muted-2-color">商品</span>' +
|
||||
'<span class="aksrt-after-sale-summary__value">' + escapeHtml(order.product_name || '订单') + '</span></div>' +
|
||||
'<div class="aksrt-after-sale-summary__row"><span class="muted-2-color">订单号</span>' +
|
||||
'<span class="aksrt-after-sale-summary__value">' + escapeHtml(order.order_number || '') + '</span></div>' +
|
||||
'<div class="aksrt-after-sale-summary__row"><span class="muted-2-color">实付</span>' +
|
||||
'<span class="aksrt-after-sale-summary__value">' + orderAmountText(order) + '</span></div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function buildApplyHtml(order) {
|
||||
var cfg = settings();
|
||||
var reasons = reasonOptions();
|
||||
var materials = materialOptions();
|
||||
|
||||
var html = '<div class="aksrt-after-sale-apply-modal">';
|
||||
html += colorfulHeader('申请售后', 'fa-undo-alt');
|
||||
html += '<form class="aksrt-after-sale-form" data-aksrt-after-sale-order="' + escapeHtml((order && order.order_number) || '') + '" novalidate>';
|
||||
html += '<div class="scroll-y mini-scrollbar max-vh5">';
|
||||
html += orderSummaryHtml(order);
|
||||
|
||||
if (reasons.length) {
|
||||
html += '<div class="mb20">';
|
||||
html += '<div class="em09 muted-2-color mb6">售后原因</div>';
|
||||
html += '<div class="form-radio form-but-radio">';
|
||||
reasons.forEach(function (reason) {
|
||||
html += '<span class="mr6 mb6 badg p2-10 pointer" data-aksrt-after-sale-reason="' + escapeHtml(reason.name) + '" data-reason-slug="' + escapeHtml(reason.slug) + '">' + escapeHtml(reason.name) + '</span>';
|
||||
});
|
||||
html += '</div></div>';
|
||||
}
|
||||
|
||||
materials.forEach(function (label) {
|
||||
html += '<div class="mb10"><div class="muted-2-color mb6">' + escapeHtml(label) + '</div>' +
|
||||
'<input class="form-control" type="text" maxlength="120" data-aksrt-after-sale-material="' + escapeHtml(label) + '" placeholder="请输入' + escapeHtml(label) + '"></div>';
|
||||
});
|
||||
|
||||
var placeholder = String(cfg.reasonPlaceholder || '必填,请您详细填写申请原因');
|
||||
html += '<div class="mb10"><div class="muted-2-color mb6">申请说明</div>' +
|
||||
'<textarea class="form-control" name="reason" rows="3" maxlength="2000" required placeholder="' + escapeHtml(placeholder) + '"></textarea></div>';
|
||||
|
||||
if (cfg.imgEnabled) {
|
||||
html += '<div class="form-upload mb10">' +
|
||||
'<div class="em09 muted-2-color mb6">上传图片</div>' +
|
||||
'<div class="preview"><div class="add" data-aksrt-after-sale-add role="button" tabindex="0" aria-label="上传图片"></div></div>' +
|
||||
'<input class="hide" type="file" accept="image/gif,image/jpeg,image/jpg,image/png,image/webp" data-aksrt-after-sale-file>' +
|
||||
'<div class="muted-2-color em09 mt6">最多上传 ' + MAX_IMAGES + ' 张图片</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// 申请售后说明(后台 after_sale_desc,服务端已 wp_kses_post)。
|
||||
var desc = String(cfg.desc || '');
|
||||
if (desc) {
|
||||
html += '<div class="em09 padding-h10 muted-box mb10">' + desc + '</div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
html += '<div class="but-average modal-buts">' +
|
||||
'<button type="button" class="but" data-aksrt-modal-close>取消</button>' +
|
||||
'<button type="button" class="but c-blue" data-aksrt-after-sale-submit><i class="fa fas fa-check" aria-hidden="true"></i>确认提交</button>' +
|
||||
'</div>';
|
||||
html += '</form></div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function detailRow(label, value) {
|
||||
if (value === '' || value == null) return '';
|
||||
return '<div class="aksrt-after-sale-detail__row"><span class="muted-2-color">' + escapeHtml(label) + '</span>' +
|
||||
'<span class="aksrt-after-sale-detail__value">' + escapeHtml(value) + '</span></div>';
|
||||
}
|
||||
|
||||
function buildDetailHtml(order) {
|
||||
var afterSale = (order && order.after_sale) || {};
|
||||
var latest = afterSale.latest || {};
|
||||
var status = String(latest.status || '');
|
||||
|
||||
var html = '<div class="aksrt-after-sale-detail-modal">';
|
||||
html += colorfulHeader('售后详细', 'fa-clipboard-list');
|
||||
html += '<div class="scroll-y mini-scrollbar max-vh5 aksrt-after-sale-detail__body">';
|
||||
html += orderSummaryHtml(order);
|
||||
|
||||
html += '<div class="aksrt-after-sale-detail__statusRow">' +
|
||||
'<span class="muted-2-color em09">售后状态</span>' +
|
||||
'<span class="badg p2-10 aksrt-after-sale-status is-' + escapeHtml(status) + '">' + escapeHtml(STATUS_LABELS[status] || status) + '</span>' +
|
||||
'</div>';
|
||||
|
||||
html += '<div class="aksrt-after-sale-detail__rows">';
|
||||
html += detailRow('申请时间', String(latest.created_at || ''));
|
||||
html += detailRow('退款金额', aftersaleAmountText(latest.refund_amount, order));
|
||||
html += detailRow('处理备注', String(latest.admin_note || ''));
|
||||
html += detailRow('完成时间', String(latest.completed_at || ''));
|
||||
html += '</div>';
|
||||
|
||||
if (latest.reason) {
|
||||
html += '<div class="mb10"><div class="muted-2-color em09 mb6">申请原因</div>' +
|
||||
'<div class="em09 aksrt-after-sale-reason-text">' + escapeHtml(latest.reason).replace(/\r\n|\r|\n/g, '<br>') + '</div></div>';
|
||||
}
|
||||
|
||||
var images = Array.isArray(latest.images) ? latest.images : [];
|
||||
if (images.length) {
|
||||
html += '<div class="mb10"><div class="muted-2-color em09 mb6">举证图片</div><div class="aksrt-after-sale-images">';
|
||||
images.forEach(function (url) {
|
||||
html += '<a href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer"><img src="' + escapeHtml(url) + '" alt=""></a>';
|
||||
});
|
||||
html += '</div></div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
|
||||
if (afterSale.can_reapply) {
|
||||
html += '<div class="but-average modal-buts">' +
|
||||
'<button type="button" class="but" data-aksrt-modal-close>关闭</button>' +
|
||||
'<button type="button" class="but c-blue" data-aksrt-after-sale-reapply="' + escapeHtml((order && order.order_number) || '') + '"><i class="fa fas fa-undo-alt" aria-hidden="true"></i>再次申请售后</button>' +
|
||||
'</div>';
|
||||
} else {
|
||||
// 不可再申请售后(工单未结束 / 已完结 / 超过时效):仅提示 + 我知道了。
|
||||
html += '<div class="aksrt-after-sale-detail__blocked">' +
|
||||
'<div class="c-yellow box-body">' + escapeHtml(REAPPLY_BLOCKED_TEXT) + '</div>' +
|
||||
'<div class="but-average modal-buts mt10"><button type="button" class="but c-blue" data-aksrt-modal-close>我知道了</button></div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function buildExpiredHtml() {
|
||||
var text = String(settings().expiredText || '抱歉,您的订单已超过售后申请时效');
|
||||
var html = '<div class="aksrt-after-sale-expired-modal">';
|
||||
html += colorfulHeader('售后提示', 'fa-clock');
|
||||
// 超过时效提示(后台 after_sale_expired_text,服务端已 wp_kses_post,支持 HTML)。
|
||||
html += '<div class="aksrt-after-sale-expired__body em09">' + text + '</div>';
|
||||
html += '<div class="but-average modal-buts"><button type="button" class="but c-blue" data-aksrt-modal-close>我知道了</button></div>';
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function openModal(options) {
|
||||
if (!window.AksrtModalRuntime || typeof window.AksrtModalRuntime.open !== 'function') {
|
||||
notyf('售后组件加载失败,请刷新页面后重试', 'danger');
|
||||
return null;
|
||||
}
|
||||
var shell = window.AksrtModalRuntime.open({
|
||||
html: options.html,
|
||||
className: options.className,
|
||||
kind: options.kind,
|
||||
mobileBottom: true,
|
||||
});
|
||||
bindShell(shell);
|
||||
return shell;
|
||||
}
|
||||
|
||||
function openApply(order) {
|
||||
return openModal({
|
||||
html: buildApplyHtml(order),
|
||||
className: 'aksrt-after-sale-apply-modal',
|
||||
kind: 'after-sale-apply',
|
||||
});
|
||||
}
|
||||
|
||||
function openDetail(order) {
|
||||
return openModal({
|
||||
html: buildDetailHtml(order),
|
||||
className: 'aksrt-after-sale-detail-modal',
|
||||
kind: 'after-sale-detail',
|
||||
});
|
||||
}
|
||||
|
||||
function openExpired() {
|
||||
return openModal({
|
||||
html: buildExpiredHtml(),
|
||||
className: 'aksrt-after-sale-expired-modal',
|
||||
kind: 'after-sale-expired',
|
||||
});
|
||||
}
|
||||
|
||||
function loadOrder(number) {
|
||||
if (!window.AksrtRequest || typeof window.AksrtRequest.request !== 'function') {
|
||||
return Promise.reject(new Error('请求组件加载失败,请刷新页面后重试'));
|
||||
}
|
||||
return window.AksrtRequest.request('/orders/' + encodeURIComponent(number), { method: 'GET' })
|
||||
.then(function (result) { return (result && result.data) || null; });
|
||||
}
|
||||
|
||||
function openApplyFlow(number) {
|
||||
if (!number) return;
|
||||
loadOrder(number).then(function (order) {
|
||||
var afterSale = order && order.after_sale ? order.after_sale : null;
|
||||
if (!order || !afterSale || !afterSale.enabled) {
|
||||
notyf('售后申请暂不可用', 'danger');
|
||||
return;
|
||||
}
|
||||
if (afterSale.expired) { openExpired(); return; }
|
||||
if (afterSale.has_application) { openDetail(order); return; }
|
||||
openApply(order);
|
||||
}).catch(function (error) {
|
||||
notyf((error && error.message) || '订单加载失败,请刷新后重试', 'danger');
|
||||
});
|
||||
}
|
||||
|
||||
function openDetailFlow(number) {
|
||||
if (!number) return;
|
||||
loadOrder(number).then(function (order) {
|
||||
var afterSale = order && order.after_sale ? order.after_sale : null;
|
||||
if (!order || !afterSale || !afterSale.enabled || !afterSale.has_application) {
|
||||
notyf('暂无售后记录', 'danger');
|
||||
return;
|
||||
}
|
||||
openDetail(order);
|
||||
}).catch(function (error) {
|
||||
notyf((error && error.message) || '订单加载失败,请刷新后重试', 'danger');
|
||||
});
|
||||
}
|
||||
|
||||
function collectImages(shell) {
|
||||
return Array.prototype.map.call(
|
||||
shell.querySelectorAll('.preview .preview-item img'),
|
||||
function (img) { return img.getAttribute('src') || ''; }
|
||||
).filter(function (url) { return url !== ''; });
|
||||
}
|
||||
|
||||
function uploadImage(shell, file) {
|
||||
var form = shell.querySelector('[data-aksrt-after-sale-order]');
|
||||
var preview = shell.querySelector('.form-upload .preview');
|
||||
var orderNumber = form ? form.getAttribute('data-aksrt-after-sale-order') : '';
|
||||
if (!preview || !orderNumber) return Promise.resolve();
|
||||
if (collectImages(shell).length >= MAX_IMAGES) {
|
||||
notyf('最多上传 ' + MAX_IMAGES + ' 张图片', 'danger');
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
var body = new FormData();
|
||||
body.append('image', file);
|
||||
|
||||
return window.fetch(API_ROOT + '/orders/' + encodeURIComponent(orderNumber) + '/after-sale/upload-image', {
|
||||
method: 'POST',
|
||||
headers: { 'X-WP-Nonce': NONCE },
|
||||
body: body,
|
||||
})
|
||||
.then(function (res) { return res.json(); })
|
||||
.then(function (result) {
|
||||
var url = result && result.success && result.data && result.data.url;
|
||||
if (!url) {
|
||||
notyf((result && result.message) || '图片上传失败', 'danger');
|
||||
return;
|
||||
}
|
||||
if (collectImages(shell).length >= MAX_IMAGES) {
|
||||
notyf('最多上传 ' + MAX_IMAGES + ' 张图片', 'danger');
|
||||
return;
|
||||
}
|
||||
var item = document.createElement('div');
|
||||
item.className = 'preview-item';
|
||||
var img = document.createElement('img');
|
||||
img.src = url;
|
||||
img.alt = '';
|
||||
var remove = document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.className = 'remove';
|
||||
remove.setAttribute('data-aksrt-after-sale-image-remove', '');
|
||||
remove.setAttribute('aria-label', '删除图片');
|
||||
remove.innerHTML = '×';
|
||||
remove.addEventListener('click', function () { item.remove(); });
|
||||
item.appendChild(img);
|
||||
item.appendChild(remove);
|
||||
preview.insertBefore(item, preview.querySelector('.add'));
|
||||
})
|
||||
.catch(function () {
|
||||
notyf('图片上传失败,请重试', 'danger');
|
||||
});
|
||||
}
|
||||
|
||||
function composeReason(shell) {
|
||||
var parts = [];
|
||||
var active = shell.querySelector('[data-aksrt-after-sale-reason].active');
|
||||
if (active) {
|
||||
var label = active.getAttribute('data-aksrt-after-sale-reason') || '';
|
||||
if (label) parts.push(label);
|
||||
}
|
||||
var materials = [];
|
||||
Array.prototype.forEach.call(shell.querySelectorAll('[data-aksrt-after-sale-material]'), function (input) {
|
||||
var value = String(input.value || '').trim();
|
||||
if (value === '') return;
|
||||
materials.push(String(input.getAttribute('data-aksrt-after-sale-material') || '') + ':' + value);
|
||||
});
|
||||
if (materials.length) parts.push(materials.join('\n'));
|
||||
var reasonInput = shell.querySelector('[name="reason"]');
|
||||
var reasonText = reasonInput ? String(reasonInput.value || '').trim() : '';
|
||||
if (reasonText) parts.push(reasonText);
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
function submitApply(shell) {
|
||||
var form = shell.querySelector('[data-aksrt-after-sale-order]');
|
||||
var orderNumber = form ? form.getAttribute('data-aksrt-after-sale-order') : '';
|
||||
var reasonInput = shell.querySelector('[name="reason"]');
|
||||
var reasonText = reasonInput ? String(reasonInput.value || '').trim() : '';
|
||||
if (!reasonText) {
|
||||
notyf('请填写申请说明', 'danger');
|
||||
if (reasonInput) reasonInput.focus();
|
||||
return;
|
||||
}
|
||||
// 预置原因被后台清空时不强制选择;有配置则必须选中一项(与举报弹窗一致)。
|
||||
if (shell.querySelector('[data-aksrt-after-sale-reason]') && !shell.querySelector('[data-aksrt-after-sale-reason].active')) {
|
||||
notyf('请选择售后原因', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
var submitBtn = shell.querySelector('[data-aksrt-after-sale-submit]');
|
||||
if (submitBtn) submitBtn.disabled = true;
|
||||
notyf('正在提交售后申请…', 'load', 3000, 'aksrt-after-sale-submit');
|
||||
|
||||
window.fetch(API_ROOT + '/orders/' + encodeURIComponent(orderNumber) + '/after-sale', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': NONCE,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
reason: composeReason(shell),
|
||||
images: collectImages(shell),
|
||||
}),
|
||||
})
|
||||
.then(function (res) { return res.json(); })
|
||||
.then(function (result) {
|
||||
if (result && result.success) {
|
||||
window.AksrtModalRuntime.close('submit');
|
||||
notyf('售后申请已提交', 'success');
|
||||
if (window.AKSRTOrderCenter && typeof window.AKSRTOrderCenter.refresh === 'function') {
|
||||
window.AKSRTOrderCenter.refresh(orderNumber);
|
||||
}
|
||||
} else {
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
notyf((result && result.message) || '售后申请提交失败', 'danger');
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
notyf('网络错误,请重试', 'danger');
|
||||
})
|
||||
.then(function () {
|
||||
var toast = document.getElementById('aksrt-after-sale-submit');
|
||||
if (toast) toast.remove();
|
||||
});
|
||||
}
|
||||
|
||||
function bindShell(shell) {
|
||||
if (!shell) return;
|
||||
|
||||
shell.addEventListener('click', function (event) {
|
||||
// 原因徽章:单选 active(与举报弹窗同语义,重复点击已选徽章保持选中)。
|
||||
var chip = event.target.closest('[data-aksrt-after-sale-reason]');
|
||||
if (chip) {
|
||||
event.preventDefault();
|
||||
Array.prototype.forEach.call(shell.querySelectorAll('[data-aksrt-after-sale-reason].active'), function (el) {
|
||||
el.classList.remove('active');
|
||||
});
|
||||
chip.classList.add('active');
|
||||
return;
|
||||
}
|
||||
|
||||
var addBtn = event.target.closest('[data-aksrt-after-sale-add]');
|
||||
if (addBtn) {
|
||||
event.preventDefault();
|
||||
var fileInput = shell.querySelector('[data-aksrt-after-sale-file]');
|
||||
if (fileInput) fileInput.click();
|
||||
return;
|
||||
}
|
||||
|
||||
var removeBtn = event.target.closest('[data-aksrt-after-sale-image-remove]');
|
||||
if (removeBtn) {
|
||||
event.preventDefault();
|
||||
var item = removeBtn.closest('.preview-item');
|
||||
if (item) item.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
var submitBtn = event.target.closest('[data-aksrt-after-sale-submit]');
|
||||
if (submitBtn) {
|
||||
event.preventDefault();
|
||||
submitApply(shell);
|
||||
return;
|
||||
}
|
||||
|
||||
// 再次申请售后:重新拉取订单状态,时效/工单口径以最新为准。
|
||||
var reapplyBtn = event.target.closest('[data-aksrt-after-sale-reapply]');
|
||||
if (reapplyBtn) {
|
||||
event.preventDefault();
|
||||
var number = reapplyBtn.getAttribute('data-aksrt-after-sale-reapply') || '';
|
||||
window.AksrtModalRuntime.close('replace');
|
||||
openApplyFlow(number);
|
||||
}
|
||||
});
|
||||
|
||||
shell.addEventListener('change', function (event) {
|
||||
var fileInput = event.target.closest('[data-aksrt-after-sale-file]');
|
||||
if (!fileInput || !fileInput.files || !fileInput.files.length) return;
|
||||
Array.prototype.forEach.call(fileInput.files, function (file) {
|
||||
uploadImage(shell, file);
|
||||
});
|
||||
fileInput.value = '';
|
||||
});
|
||||
}
|
||||
|
||||
function initAfterSaleButtons() {
|
||||
if (document.documentElement.dataset.aksrtAfterSaleInitialized === '1') return;
|
||||
document.documentElement.dataset.aksrtAfterSaleInitialized = '1';
|
||||
document.addEventListener('click', function (event) {
|
||||
var apply = event.target.closest('[data-aksrt-after-sale-apply]');
|
||||
if (apply) {
|
||||
event.preventDefault();
|
||||
openApplyFlow(apply.getAttribute('data-aksrt-after-sale-apply') || '');
|
||||
return;
|
||||
}
|
||||
var detail = event.target.closest('[data-aksrt-after-sale-detail]');
|
||||
if (detail) {
|
||||
event.preventDefault();
|
||||
openDetailFlow(detail.getAttribute('data-aksrt-after-sale-detail') || '');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
jQuery(document).off('auto_fun.aksrt-after-sale').on('auto_fun.aksrt-after-sale', initAfterSaleButtons);
|
||||
})();
|
||||
+29
-35
@@ -7,6 +7,7 @@
|
||||
"use strict";
|
||||
|
||||
var instances = new WeakMap();
|
||||
var liveStates = [];
|
||||
|
||||
function request(path, body, method, operationKey) {
|
||||
var options = { method: method || "POST" };
|
||||
@@ -199,7 +200,8 @@
|
||||
html += '<button class="btn-outline" type="button" data-aksrt-modal-close>关闭</button>';
|
||||
if (allowedActions.can_cancel) html += '<button class="btn-ghost" type="button" data-aksrt-order-cancel="' + escapeHtml(order.order_number) + '">关闭订单</button>';
|
||||
if (allowedActions.can_pay) html += '<button class="btn-primary" type="button" data-aksrt-order-pay="' + escapeHtml(order.order_number) + '" data-aksrt-order-method="' + escapeHtml(paymentMethod.value || "") + '">继续支付</button>';
|
||||
if (allowedActions.can_after_sale) html += '<form class="aksrt-order-detail__after-sale-form" data-aksrt-after-sale-form="' + escapeHtml(order.order_number) + '"><label>申请售后原因<textarea name="reason" required maxlength="2000" rows="3"></textarea></label><button class="btn-ghost" type="submit">提交售后申请</button></form>';
|
||||
// 售后申请为独立弹窗,不再占用订单详情(order-after-sale.js)。
|
||||
html += afterSaleButtons(order.order_number, order.after_sale || {});
|
||||
html += "</footer>";
|
||||
|
||||
session.content.innerHTML = html;
|
||||
@@ -275,10 +277,24 @@
|
||||
|
||||
function actionHtml(order) {
|
||||
var allowedActions = order.allowed_actions || {};
|
||||
var afterSale = order.after_sale || {};
|
||||
var html = '<button class="btn-outline" type="button" data-aksrt-order-detail="' + escapeHtml(order.order_number) + '">订单详情</button>';
|
||||
if (allowedActions.can_cancel) html += '<button class="btn-ghost" type="button" data-aksrt-order-cancel="' + escapeHtml(order.order_number) + '">关闭订单</button>';
|
||||
if (allowedActions.can_pay) html += '<button class="btn-primary" type="button" data-aksrt-order-pay="' + escapeHtml(order.order_number) + '" data-aksrt-order-method="' + escapeHtml((order.payment_method || {}).value || "") + '">继续支付</button>';
|
||||
if (allowedActions.can_after_sale) html += '<button class="btn-outline" type="button" data-aksrt-order-detail="' + escapeHtml(order.order_number) + '">申请售后</button>';
|
||||
// 售后入口:申请售后 / 售后详细走 order-after-sale.js 的独立弹窗。
|
||||
if (afterSale.enabled) html += afterSaleButtons(order.order_number, afterSale);
|
||||
return html;
|
||||
}
|
||||
|
||||
// 售后按钮(列表卡片与订单详情弹窗共用):已申请 → 售后详细,否则 → 申请售后。
|
||||
function afterSaleButtons(orderNumber, afterSale) {
|
||||
var html = "";
|
||||
if (!afterSale || !afterSale.enabled) return html;
|
||||
if (afterSale.has_application) {
|
||||
html += '<button class="btn-outline" type="button" data-aksrt-after-sale-detail="' + escapeHtml(orderNumber) + '">售后详细</button>';
|
||||
return html;
|
||||
}
|
||||
html += '<button class="btn-outline" type="button" data-aksrt-after-sale-apply="' + escapeHtml(orderNumber) + '">申请售后</button>';
|
||||
return html;
|
||||
}
|
||||
|
||||
@@ -555,42 +571,12 @@
|
||||
if (cancel && root.contains(cancel)) { event.preventDefault(); closeOrder(state, cancel); }
|
||||
};
|
||||
state.onRootSubmit = function (event) {
|
||||
var afterSaleForm = closest(event.target, "[data-aksrt-after-sale-form]");
|
||||
if (afterSaleForm) {
|
||||
event.preventDefault();
|
||||
var number = afterSaleForm.getAttribute("data-aksrt-after-sale-form") || "";
|
||||
var reason = afterSaleForm.querySelector("[name=reason]");
|
||||
if (!number || !reason || !String(reason.value || "").trim()) return;
|
||||
request(orderPath(number, "/after-sale"), { reason: String(reason.value).trim() }, "POST", window.AksrtRequest.createIdempotencyKey()).then(function () {
|
||||
notyf("售后申请已提交", "success");
|
||||
invalidateOrder(number);
|
||||
openDetail(state, number);
|
||||
refreshView(state);
|
||||
}).catch(function (error) { notyf(errorMessage(error, "售后申请提交失败"), "danger"); });
|
||||
return;
|
||||
}
|
||||
var form = closest(event.target, "[data-aksrt-orders-search]");
|
||||
if (!form || !root.contains(form)) return;
|
||||
event.preventDefault();
|
||||
var input = form.querySelector("#aksrt-order-number");
|
||||
loadView(state, { orderNumber: input ? String(input.value || "").trim() : "", page: 1 });
|
||||
};
|
||||
state.onDocumentSubmit = function (event) {
|
||||
var afterSaleForm = closest(event.target, "[data-aksrt-after-sale-form]");
|
||||
if (!afterSaleForm || root.contains(afterSaleForm) || event.__aksrtAfterSaleHandled) return;
|
||||
var number = afterSaleForm.getAttribute("data-aksrt-after-sale-form") || "";
|
||||
if (!activeDetail(state, number)) return;
|
||||
event.__aksrtAfterSaleHandled = true;
|
||||
event.preventDefault();
|
||||
var reason = afterSaleForm.querySelector("[name=reason]");
|
||||
if (!number || !reason || !String(reason.value || "").trim()) return;
|
||||
request(orderPath(number, "/after-sale"), { reason: String(reason.value).trim() }, "POST", window.AksrtRequest.createIdempotencyKey()).then(function () {
|
||||
notyf("售后申请已提交", "success");
|
||||
invalidateOrder(number);
|
||||
openDetail(state, number);
|
||||
refreshView(state);
|
||||
}).catch(function (error) { notyf(errorMessage(error, "售后申请提交失败"), "danger"); });
|
||||
};
|
||||
state.onModalClose = function (event) {
|
||||
var detail = event && event.detail ? event.detail : {};
|
||||
if (!state.detailSession || detail.session !== state.detailSession) return;
|
||||
@@ -648,11 +634,11 @@
|
||||
|
||||
root.addEventListener("click", state.onRootClick);
|
||||
root.addEventListener("submit", state.onRootSubmit);
|
||||
document.addEventListener("submit", state.onDocumentSubmit);
|
||||
document.addEventListener("aksrt:modal:close", state.onModalClose);
|
||||
document.addEventListener("click", state.onDocumentClick);
|
||||
state.countdownTimer = window.setInterval(function () { updateCountdowns(state); }, 1000);
|
||||
instances.set(root, state);
|
||||
liveStates.push(state);
|
||||
}
|
||||
|
||||
function rootsIn(root) {
|
||||
@@ -674,14 +660,22 @@
|
||||
if (activeDetail(state, state.detailOrderNumber)) window.AksrtModalRuntime.close("replace");
|
||||
item.removeEventListener("click", state.onRootClick);
|
||||
item.removeEventListener("submit", state.onRootSubmit);
|
||||
document.removeEventListener("submit", state.onDocumentSubmit);
|
||||
document.removeEventListener("aksrt:modal:close", state.onModalClose);
|
||||
document.removeEventListener("click", state.onDocumentClick);
|
||||
window.clearInterval(state.countdownTimer);
|
||||
instances.delete(item);
|
||||
liveStates = liveStates.filter(function (entry) { return entry !== state; });
|
||||
});
|
||||
}
|
||||
|
||||
window.AKSRTOrderCenter = { init: init, destroy: destroy };
|
||||
// refresh 供售后弹窗(order-after-sale.js)提交/取消后刷新列表与统计。
|
||||
function refresh(orderNumber) {
|
||||
invalidateOrder(orderNumber);
|
||||
liveStates.forEach(function (state) {
|
||||
refreshView(state);
|
||||
});
|
||||
}
|
||||
|
||||
window.AKSRTOrderCenter = { init: init, destroy: destroy, refresh: refresh };
|
||||
jQuery(document).off("auto_fun.aksrt-order-center").on("auto_fun.aksrt-order-center", function () { init(document); });
|
||||
}());
|
||||
|
||||
@@ -121,7 +121,16 @@ $status_options = [
|
||||
<button class="btn-outline" type="button" data-aksrt-order-detail="<?php echo esc_attr($number); ?>"><?php esc_html_e('订单详情', 'aksrt'); ?></button>
|
||||
<?php if (!empty($actions['can_cancel'])) : ?><button class="btn-ghost" type="button" data-aksrt-order-cancel="<?php echo esc_attr($number); ?>"><?php esc_html_e('关闭订单', 'aksrt'); ?></button><?php endif; ?>
|
||||
<?php if (!empty($actions['can_pay'])) : ?><button class="btn-primary" type="button" data-aksrt-order-pay="<?php echo esc_attr($number); ?>" data-aksrt-order-method="<?php echo esc_attr((string) ($order['payment_method']['value'] ?? '')); ?>"><?php esc_html_e('继续支付', 'aksrt'); ?></button><?php endif; ?>
|
||||
<?php if (!empty($actions['can_after_sale'])) : ?><button class="btn-outline" type="button" data-aksrt-order-detail="<?php echo esc_attr($number); ?>"><?php esc_html_e('申请售后', 'aksrt'); ?></button><?php endif; ?>
|
||||
<?php
|
||||
$after_sale = is_array($order['after_sale'] ?? null) ? $order['after_sale'] : [];
|
||||
?>
|
||||
<?php if (!empty($after_sale['enabled'])) : ?>
|
||||
<?php if (!empty($after_sale['has_application'])) : ?>
|
||||
<button class="btn-outline" type="button" data-aksrt-after-sale-detail="<?php echo esc_attr($number); ?>"><?php esc_html_e('售后详细', 'aksrt'); ?></button>
|
||||
<?php else : ?>
|
||||
<button class="btn-outline" type="button" data-aksrt-after-sale-apply="<?php echo esc_attr($number); ?>"><?php esc_html_e('申请售后', 'aksrt'); ?></button>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</footer>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
|
||||
@@ -504,6 +504,34 @@ class SchemaPayment {
|
||||
]],
|
||||
],
|
||||
],
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* 售后策略
|
||||
* ------------------------------------------------------------------ */
|
||||
'after_sale' => [
|
||||
'key' => 'after_sale',
|
||||
'title' => '售后策略',
|
||||
'version' => '1.0.0',
|
||||
'option_key' => 'aksrt_settings',
|
||||
'groups' => [
|
||||
['key' => 'main', 'title' => '售后功能', 'icon' => 'fa fas fa-undo-alt', 'fields' => [
|
||||
['key' => 'after_sale_s', 'label' => '售后功能', 'type' => 'switch', 'default' => true, 'help' => '开启后用户可以申请售后;关闭后不再处理售后,前台「我的订单」不再显示申请售后按钮'],
|
||||
['key' => 'after_sale_time_limit', 'label' => '申请售后时效(天)', 'type' => 'number', 'default' => 7, 'min' => 0, 'max' => 3650, 'help' => '订单支付后多少天内可以申请售后,超过时效将无法申请;0 表示不限制'],
|
||||
['key' => 'after_sale_expired_text', 'label' => '超过时效提示', 'type' => 'textarea', 'sanitize_type' => 'html', 'default' => '<div>抱歉,您的订单已超过售后申请时效</div>', 'rows' => 3, 'help' => '订单超过售后时效后,用户点击申请售后时弹窗提示的内容(支持HTML代码,请注意代码规范)'],
|
||||
]],
|
||||
['key' => 'form', 'title' => '申请表单', 'icon' => 'fa fas fa-file-alt', 'fields' => [
|
||||
['key' => 'after_sale_reasons', 'label' => '预置原因', 'type' => 'group', 'button_title' => '添加原因', 'help' => '用户申请售后时可选择的原因', 'default' => [['t' => '质量问题'], ['t' => '假冒品牌'], ['t' => '其他原因']], 'fields' => [
|
||||
['key' => 't', 'label' => '原因', 'type' => 'input', 'default' => ''],
|
||||
]],
|
||||
['key' => 'after_sale_desc', 'label' => '申请售后说明', 'type' => 'textarea', 'sanitize_type' => 'html', 'default' => '', 'rows' => 5, 'help' => '用户申请售后时显示的内容(支持HTML代码,请注意代码规范)'],
|
||||
['key' => 'after_sale_materials', 'label' => '申请所需材料', 'type' => 'group', 'button_title' => '添加材料', 'help' => '用户提交售后申请时需要提供的资料,如联系邮箱、联系电话;此处的信息收集,仅用于售后联系与审核', 'default' => [['t' => '联系邮箱'], ['t' => '联系电话']], 'fields' => [
|
||||
['key' => 't', 'label' => '资料项', 'type' => 'input', 'default' => ''],
|
||||
]],
|
||||
['key' => 'after_sale_reason_placeholder', 'label' => '申请说明占位文案', 'type' => 'input', 'default' => '必填,请您详细填写申请原因', 'help' => '前台申请售后弹窗中「申请说明」输入框的提示文字'],
|
||||
['key' => 'after_sale_img_s', 'label' => '申请售后可上传图片', 'type' => 'switch', 'default' => true, 'help' => '开启后申请售后弹窗中出现图片上传入口(最多上传3张)'],
|
||||
]],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -211,6 +211,7 @@ trait DBMigratorPart2 {
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
aftersale_type VARCHAR(24) NOT NULL DEFAULT 'refund',
|
||||
reason TEXT NOT NULL,
|
||||
images LONGTEXT DEFAULT NULL,
|
||||
refund_amount VARCHAR(32) NOT NULL DEFAULT '0',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
admin_note TEXT DEFAULT NULL,
|
||||
|
||||
@@ -407,6 +407,18 @@ trait ScriptsPart1b {
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
// 售后弹窗(申请 / 超时效提示 / 售后详细):与订单中心同面板、同条件入队。
|
||||
$order_after_sale_js = get_template_directory() . '/assets/js/order-after-sale.js';
|
||||
if (file_exists($order_after_sale_js)) {
|
||||
wp_enqueue_script(
|
||||
'aksrt-order-after-sale',
|
||||
$base_uri . '/assets/js/order-after-sale.js',
|
||||
['loader_js'],
|
||||
(string) filemtime($order_after_sale_js),
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$identity_js = get_template_directory() . '/assets/js/routes/user-center-auth.js';
|
||||
|
||||
@@ -498,6 +498,24 @@ trait ScriptsPart1c {
|
||||
'desc' => (string) ($report['desc'] ?? ''),
|
||||
];
|
||||
})(),
|
||||
// 售后策略数据(order-after-sale.js):预置原因、所需材料、申请说明与
|
||||
// 占位文案、图片开关、超时效提示均读取后台「支付&付费 → 售后策略」。
|
||||
'afterSale' => (function () {
|
||||
if (!class_exists('\AKSRT\Settings\PaymentRuntime')) {
|
||||
return ['enabled' => false, 'reasons' => [], 'materials' => [], 'desc' => '', 'imgEnabled' => false, 'maxImages' => 3, 'reasonPlaceholder' => '', 'expiredText' => ''];
|
||||
}
|
||||
$after_sale = \AKSRT\Settings\PaymentRuntime::get_after_sale_settings();
|
||||
return [
|
||||
'enabled' => !empty($after_sale['enabled']),
|
||||
'reasons' => array_values((array) ($after_sale['reasons'] ?? [])),
|
||||
'materials' => array_values((array) ($after_sale['materials'] ?? [])),
|
||||
'desc' => (string) ($after_sale['desc'] ?? ''),
|
||||
'imgEnabled' => !empty($after_sale['img_enabled']),
|
||||
'maxImages' => 3,
|
||||
'reasonPlaceholder' => (string) ($after_sale['reason_placeholder'] ?? ''),
|
||||
'expiredText' => (string) ($after_sale['expired_text'] ?? ''),
|
||||
];
|
||||
})(),
|
||||
'stickyEnabled' => class_exists('\AKSRT\Settings\Settings') ? !empty(\AKSRT\Settings\Settings::get_post_list_style_settings()['sticky_s']) : true,
|
||||
'stickyPages' => class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_post_list_style_settings()['sticky_s'] : ['home', 'cat'],
|
||||
'badgeShow' => class_exists('\AKSRT\Settings\Settings') ? !empty(\AKSRT\Settings\Settings::get_post_list_style_settings()['list_badge_show']) : true,
|
||||
|
||||
@@ -30,6 +30,9 @@ final class AfterSaleService {
|
||||
['methods' => \WP_REST_Server::CREATABLE, 'callback' => [$this, 'rest_submit'], 'permission_callback' => [$this, 'require_user'], 'args' => [
|
||||
'order_number' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'reason' => ['required' => true, 'sanitize_callback' => 'sanitize_textarea_field'],
|
||||
'images' => ['required' => false, 'type' => 'array', 'validate_callback' => static function ($value): bool {
|
||||
return is_array($value) && count($value) <= 3;
|
||||
}],
|
||||
]],
|
||||
['methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'rest_list'], 'permission_callback' => [$this, 'require_user'], 'args' => [
|
||||
'order_number' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
@@ -38,6 +41,37 @@ final class AfterSaleService {
|
||||
'order_number' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
]],
|
||||
]);
|
||||
// 售后举证图片上传:复用评论图片上传链路(大小 / 类型 / 内容审核同一套校验)。
|
||||
register_rest_route('aksrt/v1', '/orders/' . $number . '/after-sale/upload-image', [
|
||||
['methods' => \WP_REST_Server::CREATABLE, 'callback' => [$this, 'rest_upload_image'], 'permission_callback' => [$this, 'require_after_sale_image']],
|
||||
]);
|
||||
}
|
||||
|
||||
/** 售后举证图片上传权限:登录 + nonce + 售后开启 + 图片开关开启 + 订单归属本人。 */
|
||||
public function require_after_sale_image(\WP_REST_Request $request) {
|
||||
$user = $this->require_user($request);
|
||||
if (is_wp_error($user)) {
|
||||
return $user;
|
||||
}
|
||||
if (!class_exists('\AKSRT\Settings\PaymentRuntime')) {
|
||||
return new \WP_Error('aksrt_after_sale_forbidden', __('售后功能暂未开启', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
$settings = \AKSRT\Settings\PaymentRuntime::get_after_sale_settings();
|
||||
if (empty($settings['enabled']) || empty($settings['img_enabled'])) {
|
||||
return new \WP_Error('aksrt_after_sale_forbidden', __('售后图片上传未开启', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
$number = sanitize_text_field((string) $request->get_param('order_number'));
|
||||
if (!OrderRepository::get_instance()->get_user_order(get_current_user_id(), $number)) {
|
||||
return new \WP_Error('aksrt_order_missing', __('订单不存在', 'aksrt'), ['status' => 404]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rest_upload_image(\WP_REST_Request $request) {
|
||||
if (!class_exists('\AKSRT\Admin\UserController')) {
|
||||
return new \WP_Error('aksrt_after_sale_upload', __('上传服务不可用', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
return \AKSRT\Admin\UserController::get_instance()->rest_upload_comment_image($request);
|
||||
}
|
||||
|
||||
public function require_user(\WP_REST_Request $request) {
|
||||
@@ -47,7 +81,8 @@ final class AfterSaleService {
|
||||
}
|
||||
|
||||
public function rest_submit(\WP_REST_Request $request) {
|
||||
$result = $this->create(get_current_user_id(), (string) $request->get_param('order_number'), (string) $request->get_param('reason'));
|
||||
$images = $request->get_param('images');
|
||||
$result = $this->create(get_current_user_id(), (string) $request->get_param('order_number'), (string) $request->get_param('reason'), is_array($images) ? $images : []);
|
||||
return is_wp_error($result) ? $result : rest_ensure_response(['success' => true, 'data' => $result]);
|
||||
}
|
||||
|
||||
@@ -62,8 +97,9 @@ final class AfterSaleService {
|
||||
return is_wp_error($result) ? $result : rest_ensure_response(['success' => true, 'data' => $result]);
|
||||
}
|
||||
|
||||
public function create(int $user_id, string $order_number, string $reason) {
|
||||
public function create(int $user_id, string $order_number, string $reason, array $images = []) {
|
||||
$reason = sanitize_textarea_field($reason);
|
||||
$images = $this->sanitize_images($images);
|
||||
if ($user_id <= 0 || !preg_match('/^(?:\d{20}|\d{22})$/', $order_number) || $reason === '' || mb_strlen($reason) > 2000) {
|
||||
return new \WP_Error('aksrt_after_sale_invalid', __('请填写有效的售后原因', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
@@ -84,15 +120,37 @@ final class AfterSaleService {
|
||||
if ($amount === null) return new \WP_Error('aksrt_after_sale_amount', __('订单退款金额无效', 'aksrt'), ['status' => 400]);
|
||||
$inserted = $wpdb->insert($this->table(), [
|
||||
'order_number' => $order_number, 'user_id' => $user_id, 'aftersale_type' => 'refund',
|
||||
'reason' => $reason, 'refund_amount' => $amount, 'status' => self::STATUS_PENDING,
|
||||
'created_at' => current_time('mysql'),
|
||||
], ['%s', '%d', '%s', '%s', '%s', '%s', '%s']);
|
||||
'reason' => $reason, 'images' => wp_json_encode($images), 'refund_amount' => $amount,
|
||||
'status' => self::STATUS_PENDING, 'created_at' => current_time('mysql'),
|
||||
], ['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s']);
|
||||
return $inserted === 1 ? $this->get((int) $wpdb->insert_id) : new \WP_Error('aksrt_after_sale_db', __('售后申请保存失败', 'aksrt'), ['status' => 500]);
|
||||
} finally {
|
||||
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock));
|
||||
}
|
||||
}
|
||||
|
||||
/** 售后举证图片:最多 3 条,统一走 esc_url_raw;非字符串与空值直接丢弃。 */
|
||||
private function sanitize_images(array $images): array {
|
||||
$clean = [];
|
||||
foreach ($images as $url) {
|
||||
if (!is_string($url) || count($clean) >= 3) {
|
||||
continue;
|
||||
}
|
||||
$url = esc_url_raw(trim($url));
|
||||
if ($url !== '') {
|
||||
$clean[] = $url;
|
||||
}
|
||||
}
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/** 最新一条售后工单(列表视图只取最新态,避免整表拉取)。 */
|
||||
public function latest_for_order(string $order_number): ?array {
|
||||
global $wpdb;
|
||||
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE order_number = %s ORDER BY id DESC LIMIT 1", $order_number), ARRAY_A);
|
||||
return is_array($row) ? $this->normalize($row) : null;
|
||||
}
|
||||
|
||||
public function cancel_for_user(int $user_id, string $order_number) {
|
||||
if ($user_id <= 0 || !preg_match('/^(?:\d{20}|\d{22})$/', $order_number)) return new \WP_Error('aksrt_after_sale_invalid', __('售后申请无效', 'aksrt'), ['status' => 400]);
|
||||
global $wpdb;
|
||||
@@ -550,5 +608,13 @@ final class AfterSaleService {
|
||||
private function table(): string { return \AKSRT\Core\DBMigrator::table('order_aftersale'); }
|
||||
|
||||
/** @param array<string,mixed> $row */
|
||||
private function normalize(array $row): array { $row['id'] = (int) ($row['id'] ?? 0); $row['user_id'] = (int) ($row['user_id'] ?? 0); return $row; }
|
||||
private function normalize(array $row): array {
|
||||
$row['id'] = (int) ($row['id'] ?? 0);
|
||||
$row['user_id'] = (int) ($row['user_id'] ?? 0);
|
||||
$images = json_decode((string) ($row['images'] ?? ''), true);
|
||||
$row['images'] = is_array($images)
|
||||
? array_values(array_filter($images, static fn($url): bool => is_string($url) && $url !== ''))
|
||||
: [];
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,8 +195,9 @@ class UserOrders {
|
||||
$can_pay = $group === OrderCenterQuery::STATUS_PENDING;
|
||||
$can_cancel = $group === OrderCenterQuery::STATUS_PENDING;
|
||||
$order_number = (string) ($order['order_number'] ?? '');
|
||||
$after_sales = $detail && class_exists(AfterSaleService::class) ? AfterSaleService::get_instance()->list_for_order($order_number) : [];
|
||||
$has_open_after_sale = (bool) array_filter($after_sales, static fn(array $item): bool => in_array((string) ($item['status'] ?? ''), ['pending', 'processing', 'approved'], true));
|
||||
$after_sale_service = class_exists(AfterSaleService::class) ? AfterSaleService::get_instance() : null;
|
||||
$after_sales = $detail && $after_sale_service ? $after_sale_service->list_for_order($order_number) : [];
|
||||
$after_sale_state = $this->after_sale_state($group, $order_number, (string) ($order['paid_at'] ?? ''), $after_sale_service, $after_sales);
|
||||
$updated_at = $this->resolve_updated_at($order);
|
||||
|
||||
$view = [
|
||||
@@ -224,8 +225,8 @@ class UserOrders {
|
||||
'allowed_actions' => [
|
||||
'can_pay' => $can_pay,
|
||||
'can_cancel' => $can_cancel,
|
||||
'can_after_sale' => $group === OrderCenterQuery::STATUS_PAID && !$has_open_after_sale,
|
||||
],
|
||||
'after_sale' => $after_sale_state,
|
||||
'time_left' => $this->get_order_countdown((string) ($order['expires_at'] ?? ''), $status),
|
||||
'updated_at' => $updated_at,
|
||||
'status_version' => $this->resolve_status_version($order, $updated_at),
|
||||
@@ -245,6 +246,66 @@ class UserOrders {
|
||||
return $view;
|
||||
}
|
||||
|
||||
/**
|
||||
* 售后入口状态——前台「申请售后 / 售后详细」按钮与三个弹窗的唯一数据源。
|
||||
*
|
||||
* 列表模式只拉最新一条工单(latest_for_order);详情模式直接复用已取到的
|
||||
* 全量工单(list_for_order 按 id DESC,首条即最新)。
|
||||
*
|
||||
* @param array<int,array<string,mixed>> $records
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function after_sale_state(string $group, string $order_number, string $paid_at, ?AfterSaleService $service, array $records = []): array {
|
||||
$settings = class_exists('\AKSRT\Settings\PaymentRuntime')
|
||||
? \AKSRT\Settings\PaymentRuntime::get_after_sale_settings()
|
||||
: [];
|
||||
$settings = is_array($settings) ? $settings : [];
|
||||
|
||||
$open_statuses = ['pending', 'processing', 'approved'];
|
||||
$latest = null;
|
||||
$open = false;
|
||||
if ($service !== null) {
|
||||
if ($records) {
|
||||
foreach ($records as $record) {
|
||||
if ($latest === null) {
|
||||
$latest = $record;
|
||||
}
|
||||
if (in_array((string) ($record['status'] ?? ''), $open_statuses, true)) {
|
||||
$open = true;
|
||||
}
|
||||
}
|
||||
} elseif ($order_number !== '') {
|
||||
$latest = $service->latest_for_order($order_number);
|
||||
$open = $latest !== null && in_array((string) ($latest['status'] ?? ''), $open_statuses, true);
|
||||
}
|
||||
}
|
||||
|
||||
// 售后只对已支付订单开放;开关关闭时整体不处理售后。
|
||||
$enabled = $group === OrderCenterQuery::STATUS_PAID && !empty($settings['enabled']);
|
||||
$expired = $enabled && class_exists('\AKSRT\Settings\PaymentRuntime')
|
||||
&& \AKSRT\Settings\PaymentRuntime::after_sale_expired($paid_at);
|
||||
$has_application = $latest !== null;
|
||||
|
||||
return [
|
||||
'enabled' => $enabled,
|
||||
'expired' => (bool) $expired,
|
||||
'has_application' => $has_application,
|
||||
'open' => $open,
|
||||
'can_apply' => $enabled && !$expired && !$open,
|
||||
'can_reapply' => $enabled && !$expired && !$open && $has_application,
|
||||
'latest' => $has_application ? [
|
||||
'status' => (string) ($latest['status'] ?? ''),
|
||||
'reason' => (string) ($latest['reason'] ?? ''),
|
||||
'images' => array_values((array) ($latest['images'] ?? [])),
|
||||
'refund_amount' => (string) ($latest['refund_amount'] ?? '0'),
|
||||
'created_at' => (string) ($latest['created_at'] ?? ''),
|
||||
'reviewed_at' => (string) ($latest['reviewed_at'] ?? ''),
|
||||
'completed_at' => (string) ($latest['completed_at'] ?? ''),
|
||||
'admin_note' => (string) ($latest['admin_note'] ?? ''),
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function empty_view(OrderCenterQuery $query): array {
|
||||
return [
|
||||
|
||||
@@ -553,6 +553,7 @@ class PaymentRuntime {
|
||||
$wechat = 'null';
|
||||
}
|
||||
$alipay = sanitize_key((string) ($settings['pay_alipay_sdk_options'] ?? 'null'));
|
||||
|
||||
if (!in_array($alipay, ['official_alipay', 'epay', 'null'], true)) {
|
||||
$alipay = 'null';
|
||||
}
|
||||
@@ -583,6 +584,87 @@ class PaymentRuntime {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 售后策略(后台「支付&付费 → 售后策略」分区)的运行时读取。
|
||||
*
|
||||
* read_domain 走 SchemaProvider 的带默认读取:用户从未保存过该分区时,
|
||||
* 预置原因 / 所需材料 / 文案等键仍取 schema 默认值(语义对齐
|
||||
* UserRuntime::ban_settings 对禁封/举报分区的读取)。
|
||||
*
|
||||
* @return array{
|
||||
* enabled:bool,time_limit:int,expired_text:string,
|
||||
* reasons:array<int,array{slug:string,name:string}>,materials:string[],
|
||||
* desc:string,img_enabled:bool,reason_placeholder:string
|
||||
* }
|
||||
*/
|
||||
public static function get_after_sale_settings(): array {
|
||||
$s = class_exists('\AKSRT\Admin\SchemaProvider')
|
||||
? Settings::read_domain('payment', 'after_sale')
|
||||
: Settings::get_all();
|
||||
if (!is_array($s)) {
|
||||
$s = [];
|
||||
}
|
||||
|
||||
$reasons = [];
|
||||
foreach ((array) ($s['after_sale_reasons'] ?? []) as $row) {
|
||||
$text = trim((string) (is_array($row) ? ($row['t'] ?? '') : $row));
|
||||
if ($text === '') {
|
||||
continue;
|
||||
}
|
||||
$reasons[] = ['slug' => self::after_sale_slug($text), 'name' => sanitize_text_field($text)];
|
||||
}
|
||||
|
||||
$materials = [];
|
||||
foreach ((array) ($s['after_sale_materials'] ?? []) as $row) {
|
||||
$text = trim((string) (is_array($row) ? ($row['t'] ?? '') : $row));
|
||||
if ($text !== '') {
|
||||
$materials[] = sanitize_text_field($text);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => !isset($s['after_sale_s']) || !empty($s['after_sale_s']),
|
||||
'time_limit' => max(0, min(3650, (int) ($s['after_sale_time_limit'] ?? 7))),
|
||||
'expired_text' => wp_kses_post((string) ($s['after_sale_expired_text'] ?? '')),
|
||||
'reasons' => $reasons,
|
||||
'materials' => $materials,
|
||||
'desc' => wp_kses_post((string) ($s['after_sale_desc'] ?? '')),
|
||||
'img_enabled' => !empty($s['after_sale_img_s']),
|
||||
'reason_placeholder' => sanitize_text_field((string) ($s['after_sale_reason_placeholder'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单是否已超过售后申请时效(自支付时间起算;时效 <=0 或缺支付时间视为未过期)。
|
||||
*
|
||||
* 时区口径与 UserOrders::get_order_countdown 保持一致:均以站点本地时间
|
||||
* 比较存储的 DATETIME 与当前时间。
|
||||
*/
|
||||
public static function after_sale_expired(string $paid_at): bool {
|
||||
$limit = (int) (self::get_after_sale_settings()['time_limit'] ?? 0);
|
||||
if ($limit <= 0) {
|
||||
return false;
|
||||
}
|
||||
$paid_at = trim($paid_at);
|
||||
if ($paid_at === '') {
|
||||
return false;
|
||||
}
|
||||
$paid_timestamp = strtotime($paid_at);
|
||||
if ($paid_timestamp === false) {
|
||||
return false;
|
||||
}
|
||||
return current_time('timestamp') > $paid_timestamp + $limit * DAY_IN_SECONDS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预置原因 slug:sanitize_key 无法处理中文标签(会被清洗成空串),
|
||||
* 与 UserRuntime::ban_settings 同策略——ASCII 标签取 key,其余取稳定短哈希。
|
||||
*/
|
||||
private static function after_sale_slug(string $text): string {
|
||||
$key = sanitize_key($text);
|
||||
return $key !== '' ? $key : substr(md5($text), 0, 12);
|
||||
}
|
||||
|
||||
private static function count_structured_lines(string $raw): int {
|
||||
$lines = preg_split('/[\r\n]+/', $raw, -1, PREG_SPLIT_NO_EMPTY);
|
||||
$count = 0;
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* 「售后策略」功能契约:
|
||||
* 1) 后台 schema(PHP SchemaPayment after_sale 分区)与 SPA JS schema 的键、顺序、
|
||||
* 类型、默认值 1:1 对齐,且 schema 与侧边页均已注册(/payment/after-sale 可达);
|
||||
* 2) 运行时(PaymentRuntime)缺键回退 schema 默认、HTML 走 kses、时效边界正确;
|
||||
* 3) 售后工单:images 列、create 图片清洗与上限、上传路由权限、latest_for_order;
|
||||
* 4) 订单视图模型输出 after_sale 状态(按钮矩阵的唯一数据源);
|
||||
* 5) 前台:申请/超时效/售后详细三个独立弹窗、modal-mini + 蓝色炫彩头、placeholder 与
|
||||
* 图片上传配置化、不可再申请提示原文、订单详情弹窗不再内嵌售后表单(死代码清理)。
|
||||
*/
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { spawnSync } = require('node:child_process')
|
||||
const test = require('node:test')
|
||||
|
||||
const root = path.resolve(__dirname, '..', '..')
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8')
|
||||
|
||||
const FIELD_ORDER = [
|
||||
'after_sale_s',
|
||||
'after_sale_time_limit',
|
||||
'after_sale_expired_text',
|
||||
'after_sale_reasons',
|
||||
'after_sale_desc',
|
||||
'after_sale_materials',
|
||||
'after_sale_reason_placeholder',
|
||||
'after_sale_img_s',
|
||||
]
|
||||
|
||||
/** Extract the balanced [...] block assigned to a PHP array key inside a section body. */
|
||||
function phpSection(src, sectionKey) {
|
||||
const start = src.indexOf(`'${sectionKey}' => [`)
|
||||
assert.notEqual(start, -1, `PHP schema has a ${sectionKey} section`)
|
||||
const open = src.indexOf('[', start)
|
||||
let depth = 0
|
||||
for (let i = open; i < src.length; i++) {
|
||||
const ch = src[i]
|
||||
if (ch === "'" || ch === '"') {
|
||||
const quote = ch
|
||||
i++
|
||||
while (i < src.length) {
|
||||
if (src[i] === '\\') { i += 2; continue }
|
||||
if (src[i] === quote) break
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (ch === '[') depth++
|
||||
else if (ch === ']') {
|
||||
depth--
|
||||
if (depth === 0) return src.slice(open, i + 1)
|
||||
}
|
||||
}
|
||||
throw new Error(`unbalanced PHP section: ${sectionKey}`)
|
||||
}
|
||||
|
||||
test('after-sale schema is 1:1 between PHP and SPA', () => {
|
||||
const php = phpSection(read('src/Admin/Schema/SchemaPayment.php'), 'after_sale')
|
||||
const js = read('assets/admin/src/schema/sections/payment/afterSale.js')
|
||||
|
||||
assert.match(php, /'key'\s*=>\s*'after_sale'/)
|
||||
assert.match(php, /'title'\s*=>\s*'售后策略'/)
|
||||
assert.match(php, /'option_key'\s*=>\s*'aksrt_settings'/)
|
||||
|
||||
// 字段键、顺序、类型一致(JS 侧 defaults 与 groups 同序,按首次出现去重)
|
||||
const phpKeys = [...php.matchAll(/'key'\s*=>\s*'([a-z0-9_]+)'/g)].map((m) => m[1])
|
||||
const jsKeys = [...js.matchAll(/key:\s*'([a-z0-9_]+)'/g)].map((m) => m[1])
|
||||
const sectionKeys = (keys) => [...new Set(keys.filter((key) => FIELD_ORDER.includes(key)))]
|
||||
assert.deepEqual(sectionKeys(phpKeys), FIELD_ORDER)
|
||||
assert.deepEqual(sectionKeys(jsKeys), FIELD_ORDER)
|
||||
|
||||
for (const [phpType, jsType] of [
|
||||
['switch', 'switch'],
|
||||
['number', 'number'],
|
||||
['textarea', 'textarea'],
|
||||
['group', 'group'],
|
||||
['input', 'input'],
|
||||
]) {
|
||||
assert.ok(php.includes(`'type' => '${phpType}'`), `missing PHP field type ${phpType}`)
|
||||
assert.ok(js.includes(`type: '${jsType}'`), `missing JS field type ${jsType}`)
|
||||
}
|
||||
|
||||
// 默认值一致(开关/时效/占位文案/预置原因/所需材料)
|
||||
assert.match(php, /'after_sale_s', 'label' => '售后功能', 'type' => 'switch', 'default' => true/)
|
||||
assert.match(js, /after_sale_s:\s*true/)
|
||||
assert.match(js, /key: 'after_sale_s', label: '售后功能', type: 'switch', default: true/)
|
||||
assert.match(php, /'after_sale_time_limit', 'label' => '申请售后时效\(天\)', 'type' => 'number', 'default' => 7, 'min' => 0, 'max' => 3650/)
|
||||
assert.match(js, /key: 'after_sale_time_limit', label: '申请售后时效\(天\)', type: 'number', default: 7, min: 0, max: 3650/)
|
||||
assert.match(php, /'after_sale_reason_placeholder', 'label' => '申请说明占位文案', 'type' => 'input', 'default' => '必填,请您详细填写申请原因'/)
|
||||
assert.match(js, /key: 'after_sale_reason_placeholder', label: '申请说明占位文案', type: 'input', default: '必填,请您详细填写申请原因'/)
|
||||
assert.match(php, /'after_sale_img_s', 'label' => '申请售后可上传图片', 'type' => 'switch', 'default' => true/)
|
||||
assert.match(js, /key: 'after_sale_img_s', label: '申请售后可上传图片', type: 'switch', default: true/)
|
||||
|
||||
for (const reason of ['质量问题', '假冒品牌', '其他原因']) {
|
||||
assert.ok(php.includes(`'t' => '${reason}'`), `missing PHP preset reason ${reason}`)
|
||||
assert.ok(js.includes(`{ t: '${reason}' }`), `missing JS preset reason ${reason}`)
|
||||
}
|
||||
for (const material of ['联系邮箱', '联系电话']) {
|
||||
assert.ok(php.includes(`'t' => '${material}'`), `missing PHP material ${material}`)
|
||||
assert.ok(js.includes(`{ t: '${material}' }`), `missing JS material ${material}`)
|
||||
}
|
||||
|
||||
// HTML 配置项(超时效提示 / 申请说明)走 kses 通道
|
||||
assert.match(php, /'after_sale_expired_text', 'label' => '超过时效提示', 'type' => 'textarea', 'sanitize_type' => 'html'/)
|
||||
assert.match(php, /'after_sale_desc', 'label' => '申请售后说明', 'type' => 'textarea', 'sanitize_type' => 'html'/)
|
||||
assert.match(js, /key: 'after_sale_expired_text', label: '超过时效提示', type: 'textarea', sanitize_type: 'html'/)
|
||||
assert.match(js, /key: 'after_sale_desc', label: '申请售后说明', type: 'textarea', sanitize_type: 'html'/)
|
||||
})
|
||||
|
||||
test('after-sale schema and sidebar page are registered under 支付&付费', () => {
|
||||
const schemaIndex = read('assets/admin/src/schema/sections/payment/index.js')
|
||||
assert.match(schemaIndex, /import \{ afterSaleSchema \} from '\.\/afterSale\.js'/)
|
||||
assert.match(schemaIndex, /after_sale:\s*afterSaleSchema/)
|
||||
|
||||
const section = read('assets/admin/src/constants/sections/payment.js')
|
||||
assert.match(section, /key: 'payment-after-sale'/)
|
||||
assert.match(section, /name: 'PaymentAfterSale'/)
|
||||
assert.match(section, /label: '售后策略'/)
|
||||
assert.match(section, /route: '\/payment\/after-sale'/)
|
||||
assert.match(section, /canonicalDomain: 'payment'/)
|
||||
assert.match(section, /section: 'after_sale'/)
|
||||
|
||||
// 侧栏条目位于提现策略之后(支付&付费末位)
|
||||
assert.ok(section.indexOf("key: 'payment-after-sale'") > section.indexOf("key: 'payment-withdrawal'"))
|
||||
|
||||
const schemaRegistry = read('assets/admin/src/schema/index.js')
|
||||
assert.match(schemaRegistry, /paymentSchemas/)
|
||||
})
|
||||
|
||||
test('after-sale runtime resolves defaults, overrides and the expiry boundary matrix', () => {
|
||||
const result = spawnSync(process.env.PHP_BINARY || 'php', [path.join(__dirname, 'after-sale-policy-harness.php')], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout)
|
||||
const payload = JSON.parse(result.stdout)
|
||||
|
||||
assert.deepEqual(payload.defaults, {
|
||||
enabled: true,
|
||||
time_limit: 7,
|
||||
expired_text: '<div>抱歉,您的订单已超过售后申请时效</div>',
|
||||
reasons: [
|
||||
{ slug: 'c440cdf71825', name: '质量问题' },
|
||||
{ slug: '2ead35a456a0', name: '假冒品牌' },
|
||||
{ slug: 'fba26eab6b2b', name: '其他原因' },
|
||||
],
|
||||
materials: ['联系邮箱', '联系电话'],
|
||||
desc: '',
|
||||
img_enabled: true,
|
||||
reason_placeholder: '必填,请您详细填写申请原因',
|
||||
})
|
||||
|
||||
assert.equal(payload.overridden.enabled, false)
|
||||
assert.equal(payload.overridden.time_limit, 30)
|
||||
assert.equal(payload.overridden.img_enabled, false)
|
||||
assert.equal(payload.overridden.reason_placeholder, '请描述问题')
|
||||
assert.deepEqual(payload.overridden.reasons, [
|
||||
{ slug: 'c440cdf71825', name: '质量问题' },
|
||||
{ slug: '327e0ab68567', name: '发错货' },
|
||||
])
|
||||
assert.deepEqual(payload.overridden.materials, ['联系邮箱'])
|
||||
// HTML 配置项剥离 script,保留 div
|
||||
assert.equal(payload.overridden.expired_text, '<div>已超过时效</div>')
|
||||
assert.equal(payload.overridden.desc, '<div>请保持商品完好</div>')
|
||||
|
||||
assert.deepEqual(payload.expired_ok, {
|
||||
within: true,
|
||||
exact_boundary: true,
|
||||
just_expired: true,
|
||||
long_expired: true,
|
||||
no_limit: true,
|
||||
missing_paid: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('after-sale tickets persist evidence images and expose the latest row', () => {
|
||||
const schema = read('src/Core/DBMigratorPart2.php')
|
||||
assert.match(schema, /CREATE TABLE \{\$prefix\}aksrt_order_aftersale[\s\S]*?images LONGTEXT DEFAULT NULL[\s\S]*?refund_amount/)
|
||||
|
||||
const service = read('src/Payment/AfterSaleService.php')
|
||||
assert.match(service, /public function create\(int \$user_id, string \$order_number, string \$reason, array \$images = \[\]\)/)
|
||||
assert.match(service, /\$images = \$this->sanitize_images\(\$images\);/)
|
||||
assert.match(service, /'images' => wp_json_encode\(\$images\)/)
|
||||
assert.match(service, /private function sanitize_images\(array \$images\): array \{[\s\S]*?count\(\$clean\) >= 3[\s\S]*?esc_url_raw/)
|
||||
assert.match(service, /public function latest_for_order\(string \$order_number\): \?array \{[\s\S]*?ORDER BY id DESC LIMIT 1/)
|
||||
// normalize 输出 images 数组(坏 JSON / 空串回落空数组)
|
||||
assert.match(service, /json_decode\(\(string\) \(\$row\['images'\] \?\? ''\), true\)/)
|
||||
|
||||
// 图片上传路由:登录 + nonce + 售后/图片开关 + 订单归属;回调委托评论图片上传
|
||||
assert.match(service, /\/orders\/' \. \$number \. '\/after-sale\/upload-image'/)
|
||||
assert.match(service, /public function require_after_sale_image\(\\WP_REST_Request \$request\)/)
|
||||
assert.match(service, /get_after_sale_settings\(\)[\s\S]*?empty\(\$settings\['enabled'\]\)[\s\S]*?empty\(\$settings\['img_enabled'\]\)/)
|
||||
assert.match(service, /get_user_order\(get_current_user_id\(\), \$number\)/)
|
||||
assert.match(service, /UserController::get_instance\(\)->rest_upload_comment_image\(\$request\)/)
|
||||
assert.match(service, /'images' => \['required' => false, 'type' => 'array'[\s\S]*?count\(\$value\) <= 3/)
|
||||
|
||||
// 提交 REST 透传 images
|
||||
assert.match(service, /is_array\(\$images\) \? \$images : \[\]\)/)
|
||||
|
||||
// 既有售后资产契约(锁 / 退款 / 完成)不受影响
|
||||
assert.match(service, /GET_LOCK\(%s, 5\)/)
|
||||
assert.match(service, /Order::STATUS_PAID/)
|
||||
})
|
||||
|
||||
test('order view model exposes the after-sale button matrix', () => {
|
||||
const userOrders = read('src/Payment/UserOrders.php')
|
||||
assert.match(userOrders, /private function after_sale_state\(string \$group, string \$order_number, string \$paid_at, \?AfterSaleService \$service, array \$records = \[\]\): array \{/)
|
||||
|
||||
assert.match(userOrders, /'after_sale' => \$after_sale_state,/)
|
||||
assert.match(userOrders, /'enabled'\s*=> \$enabled,/)
|
||||
assert.match(userOrders, /'expired'\s*=> \(bool\) \$expired,/)
|
||||
assert.match(userOrders, /'has_application' => \$has_application,/)
|
||||
assert.match(userOrders, /'open'\s*=> \$open,/)
|
||||
assert.match(userOrders, /'can_apply'\s*=> \$enabled && !\$expired && !\$open,/)
|
||||
assert.match(userOrders, /'can_reapply'\s*=> \$enabled && !\$expired && !\$open && \$has_application,/)
|
||||
assert.match(userOrders, /'latest'\s*=> \$has_application \? \[/)
|
||||
|
||||
// 仅已支付订单开放;开关关闭整体不处理售后
|
||||
assert.match(userOrders, /\$enabled = \$group === OrderCenterQuery::STATUS_PAID && !empty\(\$settings\['enabled'\]\);/)
|
||||
// 时效以支付时间为准
|
||||
assert.match(userOrders, /\$this->after_sale_state\(\$group, \$order_number, \(string\) \(\$order\['paid_at'\] \?\? ''\), \$after_sale_service, \$after_sales\)/)
|
||||
// 列表模式单行查询,详情模式复用全量工单
|
||||
assert.match(userOrders, /\$after_sales = \$detail && \$after_sale_service \? \$after_sale_service->list_for_order\(\$order_number\) : \[\];/)
|
||||
assert.match(userOrders, /\$latest = \$service->latest_for_order\(\$order_number\);/)
|
||||
assert.match(userOrders, /\$view\['after_sales'\] = \$after_sales;/)
|
||||
|
||||
// 旧 can_after_sale 双路已移除
|
||||
assert.ok(!userOrders.includes('can_after_sale'), 'can_after_sale must be replaced by the after_sale state')
|
||||
})
|
||||
|
||||
test('frontend after-sale modals are standalone and config-driven', () => {
|
||||
const js = read('assets/js/order-after-sale.js')
|
||||
|
||||
// 三个独立弹窗 + 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, /modal-colorful-header colorful-bg jb-blue/)
|
||||
assert.match(js, /AksrtModalRuntime\.open\(\{/)
|
||||
|
||||
// 申请表单:预置原因徽章、所需资料、必填申请说明(placeholder 取后台配置)
|
||||
assert.match(js, /reasonOptions\(\)/)
|
||||
assert.match(js, /materialOptions\(\)/)
|
||||
assert.match(js, /data-aksrt-after-sale-reason=/)
|
||||
assert.match(js, /data-aksrt-after-sale-material=/)
|
||||
assert.match(js, /var placeholder = String\(cfg\.reasonPlaceholder \|\| '必填,请您详细填写申请原因'\)/)
|
||||
assert.match(js, /name="reason"/)
|
||||
assert.match(js, /notyf\('请填写申请说明', 'danger'\)/)
|
||||
assert.match(js, /notyf\('请选择售后原因', 'danger'\)/)
|
||||
|
||||
// 图片上传:开关控制渲染,最多 3 张,上传/删除/提交
|
||||
assert.match(js, /if \(cfg\.imgEnabled\) \{/)
|
||||
assert.match(js, /MAX_IMAGES = 3/)
|
||||
assert.match(js, /\/after-sale\/upload-image'/)
|
||||
assert.match(js, /data-aksrt-after-sale-image-remove/)
|
||||
assert.match(js, /images: collectImages\(shell\)/)
|
||||
|
||||
// 申请说明(HTML)+ 超时效提示(HTML)由后台配置直出
|
||||
assert.match(js, /var desc = String\(cfg\.desc \|\| ''\)/)
|
||||
assert.match(js, /var text = String\(settings\(\)\.expiredText \|\| '抱歉,您的订单已超过售后申请时效'\)/)
|
||||
|
||||
// 售后详细:状态徽章 / 明细行 / 图片缩略图;不可再申请提示原文 + 我知道了
|
||||
assert.match(js, /STATUS_LABELS = \{[\s\S]*?pending: '待审核'[\s\S]*?completed: '已完成',/)
|
||||
assert.match(js, /REAPPLY_BLOCKED_TEXT = '该无法再次申请售后,该订单已售后,暂不支持再次申请售后。'/)
|
||||
assert.match(js, /escapeHtml\(REAPPLY_BLOCKED_TEXT\)/)
|
||||
assert.match(js, /data-aksrt-modal-close>我知道了</)
|
||||
assert.match(js, /data-aksrt-after-sale-reapply=/)
|
||||
|
||||
// 提交后关闭弹窗并刷新订单列表
|
||||
assert.match(js, /AKSRTOrderCenter\.refresh\(orderNumber\)/)
|
||||
assert.match(js, /window\.AksrtModalRuntime\.close\('submit'\)/)
|
||||
|
||||
// 入口委托:申请 / 售后详细
|
||||
assert.match(js, /data-aksrt-after-sale-apply/)
|
||||
assert.match(js, /data-aksrt-after-sale-detail/)
|
||||
assert.match(js, /auto_fun\.aksrt-after-sale/)
|
||||
})
|
||||
|
||||
test('order center drops the embedded after-sale form and exposes refresh()', () => {
|
||||
const js = read('assets/js/order-center.js')
|
||||
|
||||
// 内嵌售后表单与两个 submit 处理器已彻底移除(死代码清理)
|
||||
assert.ok(!js.includes('data-aksrt-after-sale-form'), 'embedded after-sale form must be removed')
|
||||
assert.ok(!js.includes('提交售后申请'), 'embedded after-sale submit copy must be removed')
|
||||
assert.ok(!js.includes('onDocumentSubmit'), 'document submit handler for the after-sale form must be removed')
|
||||
|
||||
// 列表卡片与订单详情弹窗底部共用新的售后按钮
|
||||
assert.match(js, /function afterSaleButtons\(orderNumber, afterSale\)/)
|
||||
assert.match(js, /if \(afterSale\.enabled\) html \+= afterSaleButtons\(order\.order_number, afterSale\);/)
|
||||
assert.match(js, /html \+= afterSaleButtons\(order\.order_number, order\.after_sale \|\| \{\}\);/)
|
||||
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/)
|
||||
|
||||
// refresh 供售后弹窗提交后刷新列表与统计
|
||||
assert.match(js, /function refresh\(orderNumber\) \{/)
|
||||
assert.match(js, /window\.AKSRTOrderCenter = \{ init: init, destroy: destroy, refresh: refresh \}/)
|
||||
assert.match(js, /liveStates\.forEach\(function \(state\) \{/)
|
||||
})
|
||||
|
||||
test('after-sale assets, template buttons and runtime data are wired', () => {
|
||||
const scripts = read('src/Core/ScriptsPart1b.php')
|
||||
assert.match(scripts, /'aksrt-order-after-sale'/)
|
||||
assert.match(scripts, /\/assets\/js\/order-after-sale\.js'/)
|
||||
|
||||
const scripts1c = read('src/Core/ScriptsPart1c.php')
|
||||
assert.match(scripts1c, /'afterSale'\s*=> \(function \(\) \{/)
|
||||
assert.match(scripts1c, /PaymentRuntime::get_after_sale_settings\(\)/)
|
||||
assert.match(scripts1c, /'maxImages'\s*=> 3/)
|
||||
|
||||
const template = read('inc/helpers/user-center-renderers/profile-orders.php')
|
||||
assert.match(template, /\$after_sale = is_array\(\$order\['after_sale'\] \?\? null\) \? \$order\['after_sale'\] : \[\];/)
|
||||
assert.match(template, /data-aksrt-after-sale-detail="/)
|
||||
assert.match(template, /data-aksrt-after-sale-apply="/)
|
||||
assert.match(template, /esc_html_e\('售后详细', 'aksrt'\)/)
|
||||
assert.ok(!template.includes('can_after_sale'), 'template must not use the removed can_after_sale flag')
|
||||
|
||||
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, /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/)
|
||||
|
||||
const field = read('assets/admin/src/components/form/AksField.vue')
|
||||
assert.match(field, /import draggable from 'vuedraggable'/)
|
||||
assert.match(field, /v-model="repeaterDragList"/)
|
||||
assert.match(field, /handle="\.aksrt-field__repeater-handle"/)
|
||||
assert.match(field, /function repeaterDragKey\(item\)/)
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
/**
|
||||
* After-sale policy runtime harness.
|
||||
*
|
||||
* Covers the 「支付&付费 → 售后策略」 runtime resolution: schema defaults when the
|
||||
* section was never saved, stored overrides, HTML sanitisation, preset-reason
|
||||
* slugs, and the after_sale_expired boundary matrix.
|
||||
*/
|
||||
|
||||
namespace {
|
||||
define('ABSPATH', __DIR__);
|
||||
define('DAY_IN_SECONDS', 86400);
|
||||
|
||||
$GLOBALS['aksrt_test_options'] = [];
|
||||
$GLOBALS['aksrt_test_now'] = 0;
|
||||
|
||||
function sanitize_key($value): string {
|
||||
return preg_replace('/[^a-z0-9_\-]/', '', strtolower((string) $value)) ?? '';
|
||||
}
|
||||
|
||||
function sanitize_text_field($value): string {
|
||||
return trim(preg_replace('/[\r\n\t]+/', ' ', strip_tags((string) $value)) ?? '');
|
||||
}
|
||||
|
||||
function sanitize_textarea_field($value): string {
|
||||
return trim(strip_tags((string) $value));
|
||||
}
|
||||
|
||||
function wp_kses_post($value): string {
|
||||
$value = preg_replace('#<script\b[^>]*>.*?</script>#is', '', (string) $value);
|
||||
return strip_tags($value, '<a><b><strong><em><i><span><div><p><br><ul><ol><li>');
|
||||
}
|
||||
|
||||
function esc_url_raw($value): string {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
function wp_json_encode($value): string {
|
||||
return (string) json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
function get_option(string $name, $default = false) {
|
||||
return $GLOBALS['aksrt_test_options'][$name] ?? $default;
|
||||
}
|
||||
|
||||
function update_option(string $name, $value): bool {
|
||||
$GLOBALS['aksrt_test_options'][$name] = $value;
|
||||
return true;
|
||||
}
|
||||
|
||||
function current_time(string $type, $gmt = false) {
|
||||
return $GLOBALS['aksrt_test_now'];
|
||||
}
|
||||
}
|
||||
|
||||
namespace AKSRT\Settings {
|
||||
class Settings {
|
||||
public static function get_all(): array {
|
||||
$stored = get_option('aksrt_settings', []);
|
||||
return is_array($stored) ? $stored : [];
|
||||
}
|
||||
|
||||
public static function read_domain(string $domain, string $section = ''): array {
|
||||
return \AKSRT\Admin\SchemaProvider::get_instance()->read_settings($domain, $section);
|
||||
}
|
||||
|
||||
public static function flush_settings_cache(): void {}
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
require_once dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaPayment.php';
|
||||
require_once dirname(__DIR__, 2) . '/src/Admin/SchemaProvider.php';
|
||||
require_once dirname(__DIR__, 2) . '/src/Settings/PaymentRuntime.php';
|
||||
|
||||
$GLOBALS['aksrt_test_now'] = strtotime('2026-03-01 12:00:00');
|
||||
|
||||
// 1) Section never saved: the runtime still resolves schema defaults.
|
||||
$defaults = \AKSRT\Settings\PaymentRuntime::get_after_sale_settings();
|
||||
|
||||
// 2) Stored values override defaults; HTML fields are kses'ed.
|
||||
update_option('aksrt_settings', [
|
||||
'after_sale_s' => false,
|
||||
'after_sale_time_limit' => 30,
|
||||
'after_sale_expired_text' => '<div>已超过时效</div><script>alert(1)</script>',
|
||||
'after_sale_reasons' => [['t' => '质量问题'], ['t' => '发错货']],
|
||||
'after_sale_desc' => '<div>请保持商品完好</div><script>alert(2)</script>',
|
||||
'after_sale_materials' => [['t' => '联系邮箱']],
|
||||
'after_sale_reason_placeholder' => '请描述问题',
|
||||
'after_sale_img_s' => false,
|
||||
]);
|
||||
$overridden = \AKSRT\Settings\PaymentRuntime::get_after_sale_settings();
|
||||
|
||||
// 3) Expiry boundary matrix: measured from paid_at, strictly beyond the limit.
|
||||
$matrix = [];
|
||||
$boundary_cases = [
|
||||
'within' => ['2026-02-25 00:00:00', 7, false],
|
||||
'exact_boundary' => ['2026-02-22 12:00:00', 7, false],
|
||||
'just_expired' => ['2026-02-22 11:59:59', 7, true],
|
||||
'long_expired' => ['2026-01-01 00:00:00', 7, true],
|
||||
'no_limit' => ['2020-01-01 00:00:00', 0, false],
|
||||
'missing_paid' => ['', 7, false],
|
||||
];
|
||||
$stored = get_option('aksrt_settings', []);
|
||||
foreach ($boundary_cases as $name => $case) {
|
||||
[$paid_at, $limit, $expected] = $case;
|
||||
$stored['after_sale_time_limit'] = $limit;
|
||||
update_option('aksrt_settings', $stored);
|
||||
$matrix[$name] = \AKSRT\Settings\PaymentRuntime::after_sale_expired($paid_at) === $expected;
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'defaults' => $defaults,
|
||||
'overridden' => $overridden,
|
||||
'expired_ok' => $matrix,
|
||||
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
Reference in New Issue
Block a user