feat(售后): 售后理由改配置下拉并支持备注

- 售后理由由文本输入改为下拉选择,选项读系统配置 stor_reason(空配置兜底「其它原因」)
- 新增备注字段(选填 200 字),申请/详情链路透传展示
- 移动端动态表单支持下拉类型,类型切换时回填退款金额默认值
This commit is contained in:
吴昊天 2026-10-13 18:07:14 +08:00
parent b09d029ef9
commit 51fbf6f096
4 changed files with 71 additions and 7 deletions

View File

@ -87,6 +87,7 @@ class AfterSaleController
['refund_price', 0.00],
['refund_integral', 0], // 积分退款标的:退还积分数
['reason', ''],
['remark', ''], // 备注(补充说明,选填)
['evidence_images', []],
['product_snapshot', []],
['items', []], // v8.1.1 新增:商品级售后范围 [{cart_id, refund_num}]

View File

@ -736,6 +736,7 @@ class AfterSaleServices extends BaseService
'refund_target' => $afterSale['refund_target'] ?? 'cash',
'refund_integral' => (int)($afterSale['refund_integral'] ?? 0),
'reason' => $afterSale['reason'] ?? '',
'remark' => $afterSale['remark'] ?? '',
'reject_reason' => $afterSale['reject_reason'] ?? '',
'evidence_images' => $afterSale['evidence_images'] ?? [],
'product_snapshot' => $afterSale['product_snapshot'] ?? [],
@ -1131,6 +1132,7 @@ class AfterSaleServices extends BaseService
? 'both'
: ($refundIntegral > 0 ? 'integral' : 'cash'),
'reason' => $data['reason'] ?? '',
'remark' => trim((string)($data['remark'] ?? '')),
'evidence_images' => !empty($data['evidence_images']) && is_array($data['evidence_images'])
? json_encode($data['evidence_images']) : '',
'product_snapshot'=> !empty($snapshot) && is_array($snapshot)

View File

@ -136,7 +136,7 @@ abstract class BaseAfterSaleHandler implements AfterSaleHandlerInterface
/**
* 默认售后申请表单配置
* 资金类售后(refund_only/return_refund)展示退款金额;其余类型不展示
* 所有类型统一展示"售后原因 + 凭证图片"
* 所有类型统一展示"售后理由(配置选择)+ 备注 + 凭证图片"
* @param string $type 售后类型
* @return array
*/
@ -153,14 +153,23 @@ abstract class BaseAfterSaleHandler implements AfterSaleHandlerInterface
'placeholder' => '请输入退款金额',
];
}
// 售后原因(所有类型必填)
// 售后理由(选项来自系统配置 stor_reason,所有类型必填)
$fields[] = [
'key' => 'reason',
'label' => '售后原因',
'type' => 'textarea',
'label' => '售后理由',
'type' => 'select',
'required' => true,
'maxlength' => 100,
'placeholder' => '请描述售后原因,100字以内',
'options' => $this->getReasonOptions(),
'placeholder' => '请选择售后理由',
];
// 备注(补充说明,选填)
$fields[] = [
'key' => 'remark',
'label' => '备注',
'type' => 'textarea',
'required' => false,
'maxlength' => 200,
'placeholder' => '选填,补充描述您的问题(200字以内)',
];
// 凭证图片(所有类型可选)
$fields[] = [
@ -173,6 +182,17 @@ abstract class BaseAfterSaleHandler implements AfterSaleHandlerInterface
return ['fields' => $fields];
}
/**
* 售后理由选项(系统配置 stor_reason,换行分隔;配置为空时兜底"其它原因"保证表单可用)
* @return array
*/
protected function getReasonOptions(): array
{
$reason = str_replace("\r\n", "\n", (string)sys_config('stor_reason'));
$options = array_values(array_filter(array_map('trim', explode("\n", $reason)), fn ($v) => $v !== ''));
return $options ?: ['其它原因'];
}
/**
* 默认用户可操作按钮
* 按售后单状态返回可操作动作,子类可覆写以增减按钮

View File

@ -123,6 +123,24 @@
:placeholder="$t(field.placeholder || '')"
/>
</view>
<!-- 下拉选择(选项由后端 options 下发) -->
<view class="item acea-row row-between-wrapper" v-else-if="field.type === 'select'">
<view>{{ $t(field.label) }}</view>
<picker
class="num"
mode="selector"
:range="field.options || []"
:value="(field.options || []).indexOf(formValues[field.key])"
@change="onSelectFieldChange(field, $event)"
>
<view class="picker acea-row row-between-wrapper">
<view class="reason" :class="{ 'picker-placeholder': !formValues[field.key] }">
{{ formValues[field.key] || $t(field.placeholder || '请选择') }}
</view>
<text class="iconfont icon-jiantou"></text>
</view>
</picker>
</view>
<!-- 数字 -->
<view class="item acea-row row-between-wrapper" v-else-if="field.type === 'number'">
<view>{{ $t(field.label) }}</view>
@ -206,6 +224,10 @@
<text class="label">{{ $t('申请原因') }}</text>
<text class="value reason-text">{{ detail.reason }}</text>
</view>
<view class="info-row" v-if="detail.remark">
<text class="label">{{ $t('备注') }}</text>
<text class="value reason-text">{{ detail.remark }}</text>
</view>
<view class="info-row" v-if="detail.reject_reason">
<text class="label">{{ $t('拒绝原因') }}</text>
<text class="value reason-text error">{{ detail.reject_reason }}</text>
@ -633,6 +655,9 @@ async function loadFormConfig(type) {
formValues.value[field.key] = [];
} else if (field.type === 'price') {
formValues.value[field.key] = (orderDetail.value.pay_price || '0.00').toString();
} else if (field.type === 'select') {
// 下拉字段默认选中第一个选项(无 options 时留空走必填校验)
formValues.value[field.key] = field.default || (field.options && field.options[0]) || '';
} else {
formValues.value[field.key] = field.default || '';
}
@ -649,6 +674,17 @@ async function onTypeChange(e) {
const type = availableTypes.value[typeIndex.value];
if (type) {
await loadFormConfig(type);
// 类型切换会重置表单值,与首次加载一致地回填退款金额/退还积分默认值
syncRefundPriceField();
}
}
/** 动态表单下拉字段变更(选项文本直接作为字段值提交) */
function onSelectFieldChange(field, e) {
const options = field.options || [];
const idx = Number(e.detail.value);
if (options[idx] !== undefined) {
formValues.value[field.key] = options[idx];
}
}
@ -741,7 +777,8 @@ async function submitApply() {
const val = formValues.value[field.key];
const empty = Array.isArray(val) ? val.length === 0 : !String(val || '').trim();
if (empty) {
return util.Tips({ title: i18n.global.t('请填写') + i18n.global.t(field.label) });
const prefix = field.type === 'select' ? i18n.global.t('请选择') : i18n.global.t('请填写');
return util.Tips({ title: prefix + i18n.global.t(field.label) });
}
}
}
@ -1232,6 +1269,10 @@ defineExpose({
white-space: normal;
word-break: break-word;
padding: 20rpx 12rpx 20rpx 0;
&.picker-placeholder {
color: #bbb;
}
}
.list .item .picker {