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:
kate522 committed 2026-10-06 22:21:47 +08:00
1 parent b447c41f13
commit 1a9a2762f3
19 files changed
+1835 -86

No files matched your search

+83 -30
View File
@@ -507,38 +507,47 @@
</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"
>
<div class="aksrt-field__repeater-header">
<span class="aksrt-field__repeater-index">#{{ index + 1 }}</span>
<n-button
size="tiny"
type="error"
text
:disabled="schema.disabled || reachedRepeaterMinimum"
@click="removeRepeaterItem(index)"
>
<FaIcon icon="fa fas fa-trash-alt" :size="12" />
</n-button>
</div>
<div class="aksrt-field__repeater-fields">
<template v-for="field in (schema.fields || [])" :key="field.key">
<AksField
v-if="matchDependency(field.dependsOn, item)"
:schema="field"
:model-value="item[field.key]"
:form-data="formData"
:disabled="schema.disabled"
@update:model-value="updateRepeaterItem(index, field.key, $event)"
/>
</template>
</div>
</div>
<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"
text
:disabled="schema.disabled || reachedRepeaterMinimum"
@click="removeRepeaterItem(index)"
>
<FaIcon icon="fa fas fa-trash-alt" :size="12" />
</n-button>
</div>
<div class="aksrt-field__repeater-fields">
<template v-for="field in (schema.fields || [])" :key="field.key">
<AksField
v-if="matchDependency(field.dependsOn, element)"
:schema="field"
:model-value="element[field.key]"
:form-data="formData"
:disabled="schema.disabled"
@update:model-value="updateRepeaterItem(index, field.key, $event)"
/>
</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;