feat(after-sale): 售后处理记录改为每次提交留痕并新增时间线

工单行内新增 handling_log 列,用户申请、用户撤销与后台每次改状态都落一条
「状态 + 备注 + 来源 + 时间」,按时间正序存放、上限 50 条(对齐
MetaAssetService::MAX_RECORDS)。set_status 因此变成读-改-写,补上
GET_LOCK/RELEASE_LOCK 包裹,并在锁内重读工单快照——锁外取快照仍会被并发
追加覆盖。

三处界面由「只显示最后一条」改为回放整条处理链路:

- 后台订单明细「售后记录」按工单渲染头部(状态/金额/申请时间)+ 原因 +
  完整处理记录
- 售后面板手风琴「处理备注」单独占整行并置于「申请说明」上方,删掉已无
  意义的「审核时间」
- 前台售后详细弹窗与订单详情弹窗的售后记录改为时间线:左列绳结状态条
  (节点 + 状态 + 时间),右列处理备注;样式只在 modal-runtime.css 定义
  一份,两个弹窗共用

清理随之失效的死代码:工单的 admin_note / reviewed_at / completed_at,以及
全仓库无写入方的 gateway_request_id / gateway_refund_id / gateway_result;
连带删掉 gatewayResultText()、uninstall 里从未存在过的 aksrt_after_sale
表项,并把两处后台页面重复的状态标签数组收成 after_sale_status_labels()、
重复的 .aksrt-as-log 样式收成 after_sale_log_css()。

顺带修复 88669f69 重构退款流水线时误删 refund_amount()、却仍在 create()
里调用的问题——此前任何用户提交售后都会直接 fatal。

验证:tests/widget-system/ 462 项、tests/admin/ 76 项通过;新增
after-sale-log-harness.php 驱动真实 AfterSaleService,覆盖首条日志、空备注
也留痕、撤销记录、50 条上限与畸形行规范化;两个后台页面经 dev harness
渲染校验「处理备注」宽行位于「申请说明」之前;未做浏览器视觉验收。
This commit is contained in:
2026-10-09 13:59:50 +08:00
parent 31e6ba00af
commit 26ce9a5904
15 changed files with 644 additions and 57 deletions
+33 -19
View File
@@ -106,26 +106,41 @@
return "<div><dt>" + escapeHtml(label) + "</dt><dd>" + cell + "</dd></div>";
}
function gatewayResultText(value) {
if (!value) return "";
var result = value;
if (typeof value === "string") {
try { result = JSON.parse(value); } catch (_) { return String(value); }
}
if (!result || typeof result !== "object") return String(result);
var labels = { succeeded: "退款成功", failed: "渠道明确拒绝", pending: "渠道处理中", unknown: "结果待确认" };
var parts = [];
if (result.status) parts.push(labels[String(result.status)] || String(result.status));
if (result.message) parts.push(String(result.message));
if (result.refund_id) parts.push("退款单号 " + String(result.refund_id));
return parts.join(" · ") || "渠道暂未提供可读结果";
}
function afterSaleAmount(value, order) {
var amount = String(value == null ? "" : value);
return order && order.is_points ? amount + " " + String(order.currency_symbol || "积分") : "¥" + amount;
}
// 处理记录时间线:与售后详细弹窗(order-after-sale.js)同款标记与样式,
// 由 modal-runtime.css 的 .aksrt-after-sale-timeline 规则统一呈现。
var AFTER_SALE_STATUS_LABELS = { pending: "待审核", processing: "处理中", rejected: "已拒绝", cancelled: "已取消", completed: "已完成" };
function handlingEntryTitle(entry) {
var status = String((entry && entry.status) || "");
if (String((entry && entry.source) || "") === "user") {
return status === "cancelled" ? "用户撤销售后" : "用户提交申请";
}
return AFTER_SALE_STATUS_LABELS[status] || status;
}
function orderDetailHandlingLogHtml(log) {
var entries = Array.isArray(log) ? log : [];
if (!entries.length) return "";
var html = '<div class="aksrt-after-sale-log"><ol class="aksrt-after-sale-timeline">';
entries.forEach(function (entry) {
var status = String((entry && entry.status) || "");
var note = String((entry && entry.note) || "");
html += '<li class="aksrt-after-sale-timeline__item is-' + escapeHtml(status) + '">';
html += '<div class="aksrt-after-sale-timeline__aside">' +
'<span class="aksrt-after-sale-timeline__knot"></span>' +
'<span class="aksrt-after-sale-timeline__status">' + escapeHtml(handlingEntryTitle(entry)) + '</span>' +
'<time class="aksrt-after-sale-timeline__time">' + escapeHtml(String((entry && entry.created_at) || "")) + '</time>' +
'</div>';
html += '<div class="aksrt-after-sale-timeline__main">' +
(note !== "" ? '<p class="aksrt-after-sale-timeline__note">' + escapeHtml(note).replace(/\r\n|\r|\n/g, "<br>") + '</p>' : "") +
'</div></li>';
});
return html + '</ol></div>';
}
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>' +
@@ -207,9 +222,9 @@
}
html += '<dl class="aksrt-order-detail__after-sale-meta">';
html += "<div><dt>退款金额</dt><dd>" + escapeHtml(afterSaleAmount(item.refund_amount, order)) + "</dd></div>";
if (item.admin_note) html += "<div><dt>处理备注</dt><dd>" + escapeHtml(item.admin_note) + "</dd></div>";
if (item.gateway_result) html += "<div><dt>渠道结果</dt><dd>" + escapeHtml(gatewayResultText(item.gateway_result)) + "</dd></div>";
html += "</dl>";
// 处理记录时间线:左列绳结状态条与时间,右列处理备注;与售后详细弹窗同款结构。
html += orderDetailHandlingLogHtml(item.handling_log);
// 查看详细:逐记录构造售后详细弹窗载荷(after_sale.latest 指向本条记录);
// 再次申请仅对最新一条且订单级可再申请时开放(after_sales 按 id 倒序,首条即最新)。
var recordPayload = escapeHtml(JSON.stringify({
@@ -230,8 +245,7 @@
images: Array.isArray(item.images) ? item.images : [],
refund_amount: String(item.refund_amount || "0"),
created_at: String(item.created_at || ""),
completed_at: String(item.completed_at || ""),
admin_note: String(item.admin_note || ""),
handling_log: Array.isArray(item.handling_log) ? item.handling_log : [],
},
},
}));