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:
@@ -6693,6 +6693,29 @@ body.aksrt-modal-runtime-open {
|
||||
.aksrt-after-sale-status.is-rejected,
|
||||
.aksrt-after-sale-status.is-cancelled { color: var(--color-error, #ef4444); }
|
||||
|
||||
/* 售后处理记录时间线:左列绳结状态条(节点 + 状态 + 时间),右列处理备注。
|
||||
售后详细弹窗与订单详情弹窗的「售后记录」共用同一套标记;竖线为 aside 的
|
||||
::before,末项不收口,避免出现悬空的线段。 */
|
||||
.aksrt-after-sale-log { display: grid; gap: 6px; }
|
||||
.aksrt-after-sale-log__empty { color: var(--modal-muted); font-size: 12px; }
|
||||
.aksrt-after-sale-timeline { display: grid; margin: 0; padding: 0; list-style: none; }
|
||||
.aksrt-after-sale-timeline__item { --as-accent: var(--modal-muted); display: grid; grid-template-columns: 88px minmax(0, 1fr); gap: 10px; }
|
||||
.aksrt-after-sale-timeline__item.is-pending { --as-accent: var(--modal-warning); }
|
||||
.aksrt-after-sale-timeline__item.is-processing { --as-accent: var(--modal-primary); }
|
||||
.aksrt-after-sale-timeline__item.is-completed { --as-accent: var(--modal-success); }
|
||||
.aksrt-after-sale-timeline__item.is-rejected,
|
||||
.aksrt-after-sale-timeline__item.is-cancelled { --as-accent: var(--modal-danger, #ef4444); }
|
||||
.aksrt-after-sale-timeline__aside { position: relative; display: grid; gap: 2px; justify-items: start; padding: 0 0 14px; }
|
||||
.aksrt-after-sale-timeline__item:last-child .aksrt-after-sale-timeline__aside { padding-bottom: 0; }
|
||||
.aksrt-after-sale-timeline__aside::before { content: ""; position: absolute; top: 12px; bottom: -2px; left: 4px; width: 2px; border-radius: 1px; background: color-mix(in srgb, var(--as-accent) 30%, var(--modal-line)); }
|
||||
.aksrt-after-sale-timeline__item:last-child .aksrt-after-sale-timeline__aside::before { display: none; }
|
||||
.aksrt-after-sale-timeline__knot { position: relative; z-index: 1; width: 10px; height: 10px; border: 2px solid var(--as-accent); border-radius: 50%; background: var(--modal-surface, #fff); }
|
||||
.aksrt-after-sale-timeline__status { color: var(--as-accent); font-size: 12px; font-weight: 700; line-height: 1.4; }
|
||||
.aksrt-after-sale-timeline__time { color: var(--modal-muted); font-size: 11px; line-height: 1.4; font-variant-numeric: tabular-nums; }
|
||||
.aksrt-after-sale-timeline__main { min-width: 0; padding: 0 0 14px; }
|
||||
.aksrt-after-sale-timeline__item:last-child .aksrt-after-sale-timeline__main { padding-bottom: 0; }
|
||||
.aksrt-after-sale-timeline__note { margin: 0; color: var(--modal-ink); font-size: 12.5px; line-height: 1.65; word-break: break-word; }
|
||||
|
||||
/* 状态行右侧单元格:状态徽章 + 「撤销售后」入口(仅待审核工单渲染)。 */
|
||||
.aksrt-after-sale-detail__statusCell { display: inline-flex; align-items: center; gap: 10px; }
|
||||
.aksrt-after-sale-withdraw { display: inline-flex; align-items: center; gap: 3px; padding: 0; border: 0; background: transparent; color: var(--modal-danger, #ef4444); font-size: 12px; font-weight: 600; line-height: 1.4; cursor: pointer; transition: color .15s ease; }
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -816,6 +816,29 @@ body.aksrt-modal-runtime-open {
|
||||
.aksrt-after-sale-status.is-rejected,
|
||||
.aksrt-after-sale-status.is-cancelled { color: var(--color-error, #ef4444); }
|
||||
|
||||
/* 售后处理记录时间线:左列绳结状态条(节点 + 状态 + 时间),右列处理备注。
|
||||
售后详细弹窗与订单详情弹窗的「售后记录」共用同一套标记;竖线为 aside 的
|
||||
::before,末项不收口,避免出现悬空的线段。 */
|
||||
.aksrt-after-sale-log { display: grid; gap: 6px; }
|
||||
.aksrt-after-sale-log__empty { color: var(--modal-muted); font-size: 12px; }
|
||||
.aksrt-after-sale-timeline { display: grid; margin: 0; padding: 0; list-style: none; }
|
||||
.aksrt-after-sale-timeline__item { --as-accent: var(--modal-muted); display: grid; grid-template-columns: 88px minmax(0, 1fr); gap: 10px; }
|
||||
.aksrt-after-sale-timeline__item.is-pending { --as-accent: var(--modal-warning); }
|
||||
.aksrt-after-sale-timeline__item.is-processing { --as-accent: var(--modal-primary); }
|
||||
.aksrt-after-sale-timeline__item.is-completed { --as-accent: var(--modal-success); }
|
||||
.aksrt-after-sale-timeline__item.is-rejected,
|
||||
.aksrt-after-sale-timeline__item.is-cancelled { --as-accent: var(--modal-danger, #ef4444); }
|
||||
.aksrt-after-sale-timeline__aside { position: relative; display: grid; gap: 2px; justify-items: start; padding: 0 0 14px; }
|
||||
.aksrt-after-sale-timeline__item:last-child .aksrt-after-sale-timeline__aside { padding-bottom: 0; }
|
||||
.aksrt-after-sale-timeline__aside::before { content: ""; position: absolute; top: 12px; bottom: -2px; left: 4px; width: 2px; border-radius: 1px; background: color-mix(in srgb, var(--as-accent) 30%, var(--modal-line)); }
|
||||
.aksrt-after-sale-timeline__item:last-child .aksrt-after-sale-timeline__aside::before { display: none; }
|
||||
.aksrt-after-sale-timeline__knot { position: relative; z-index: 1; width: 10px; height: 10px; border: 2px solid var(--as-accent); border-radius: 50%; background: var(--modal-surface, #fff); }
|
||||
.aksrt-after-sale-timeline__status { color: var(--as-accent); font-size: 12px; font-weight: 700; line-height: 1.4; }
|
||||
.aksrt-after-sale-timeline__time { color: var(--modal-muted); font-size: 11px; line-height: 1.4; font-variant-numeric: tabular-nums; }
|
||||
.aksrt-after-sale-timeline__main { min-width: 0; padding: 0 0 14px; }
|
||||
.aksrt-after-sale-timeline__item:last-child .aksrt-after-sale-timeline__main { padding-bottom: 0; }
|
||||
.aksrt-after-sale-timeline__note { margin: 0; color: var(--modal-ink); font-size: 12.5px; line-height: 1.65; word-break: break-word; }
|
||||
|
||||
/* 状态行右侧单元格:状态徽章 + 「撤销售后」入口(仅待审核工单渲染)。 */
|
||||
.aksrt-after-sale-detail__statusCell { display: inline-flex; align-items: center; gap: 10px; }
|
||||
.aksrt-after-sale-withdraw { display: inline-flex; align-items: center; gap: 3px; padding: 0; border: 0; background: transparent; color: var(--modal-danger, #ef4444); font-size: 12px; font-weight: 600; line-height: 1.4; cursor: pointer; transition: color .15s ease; }
|
||||
|
||||
@@ -169,6 +169,43 @@
|
||||
'<span class="aksrt-after-sale-detail__value">' + escapeHtml(value) + '</span></div>';
|
||||
}
|
||||
|
||||
// 处理记录节点标题:用户侧提交/撤脱离不开工单状态,单独措辞;
|
||||
// 后台提交直接用目标状态,与后台列表口径一致。
|
||||
function handlingEntryTitle(entry) {
|
||||
var status = String((entry && entry.status) || '');
|
||||
if (String((entry && entry.source) || '') === 'user') {
|
||||
return status === 'cancelled' ? '用户撤销售后' : '用户提交申请';
|
||||
}
|
||||
return STATUS_LABELS[status] || status;
|
||||
}
|
||||
|
||||
// 处理记录时间线:左列绳结状态条(节点 + 状态 + 时间),右列处理备注。
|
||||
// 数据源为 latest.handling_log(工单行内 JSON,按时间正序)。
|
||||
function handlingLogHtml(log) {
|
||||
var entries = Array.isArray(log) ? log : [];
|
||||
if (!entries.length) {
|
||||
return '<div class="aksrt-after-sale-log"><div class="aksrt-after-sale-log__empty">暂无处理记录</div></div>';
|
||||
}
|
||||
var html = '<div class="aksrt-after-sale-log"><div class="muted-2-color em09 mb6">处理记录</div>';
|
||||
html += '<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>';
|
||||
html += '</li>';
|
||||
});
|
||||
html += '</ol></div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function buildDetailHtml(order) {
|
||||
var afterSale = (order && order.after_sale) || {};
|
||||
var latest = afterSale.latest || {};
|
||||
@@ -192,9 +229,9 @@
|
||||
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>';
|
||||
// 处理记录:每次后台提交一条,替代原先单行的「处理备注 / 完成时间」。
|
||||
html += handlingLogHtml(latest.handling_log);
|
||||
|
||||
// 申请表单分行展示:预置原因 / 申请资料 / 申请说明各自独立成行,
|
||||
// 与上方「申请时间」等明细行同格式;老数据无 apply_form 时兜底展示融合串。
|
||||
|
||||
+33
-19
@@ -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 : [],
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user