feat(ticket): replace the after-sale flow with a general ticket system
The old 售后 flow was an order-refund appendage: tickets were hard-bound to an order number, users could only file them from 「我的订单」against a paid order, and the admin action bottomed out at 「标记订单已退款」. It could not carry account questions or feature requests that have nothing to do with an order. Replace it with a general ticket system modelled on 魔方财务云's idcsmart_ticket: title + department + description are required, linking an order is optional (user's own paid orders only). Add departments, custom statuses, preset replies and internal admin notes as four configuration surfaces. Conversation history moves off the ticket row's JSON log into dedicated ticket_reply rows, which unlocks quoted replies. Front end gets a 工单中心 panel under 我的服务; back end moves from 支付中心 → 售后服务 to users.php → 工单管理. Per CLAUDE.md's rewrite-over-compatibility rule: drop the old table, build new ones, delete the old code. No dual paths, no data migration. The upstream plugin's upstream/downstream delivery and internal-ticket conversion are deliberately left out — both are multi-provider platform concerns with no meaning on a single WordPress site. Data: order_aftersale becomes six aksrt_ticket* tables (ticket, ticket_reply, ticket_status, ticket_type, ticket_prereply, ticket_note). SCHEMA_VERSION 2.6.0 -> 3.0.0. Status and department seeds are idempotent; the default status 「待接单」is flagged as non-editable. Domain: new src/Ticket/ (13 files, all under 460 lines). Attachments reuse UserController::rest_upload_comment_image with a ticket-specific permission callback, mirroring the existing /report/upload-image route rather than growing a second upload path. Notifications go through AKSRT\Messages\Message::send() and deep-link to the right surface; email/SMS stay out of scope since the theme's mail and SMS stacks are separate implementations. Refund gate opens up: marking an order refunded no longer requires an in-flight ticket, only that the order is paid. Also generalise a hardcoded special case in tools/audit-admin-contracts.php — schema domain inference now reads the parent directory instead of matching "PostList" by name, so new leaf schema files need no per-name exception. Verification: php tools/audit-admin-contracts.php exit 0, errors=0 node --test (widget-system + admin) 664 pass / 0 fail php -l over all new/changed files clean node --check over all JS clean npm run build + build:public clean Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
761c84d70f
commit
dbb7de41e9
81 files changed
+5812
-4179
No files matched your search
@@ -124,7 +124,7 @@
|
||||
var meta = statusMeta(status);
|
||||
var timeLeft = Number(order.time_left || 0);
|
||||
var html = "";
|
||||
// 炫彩标题区:与售后弹窗统一(modal-mini + jb-blue),运行时不再渲染默认标题栏。
|
||||
// 炫彩标题区(modal-mini + jb-blue),运行时不再渲染默认标题栏。
|
||||
html += colorfulHeader("订单详情", "fa-receipt");
|
||||
|
||||
// 状态横幅:整块配色只由 --hero-accent 驱动,换状态只改一处变量。
|
||||
@@ -164,59 +164,6 @@
|
||||
});
|
||||
html += "</dl></section>";
|
||||
|
||||
var afterSales = Array.isArray(order.after_sales) ? order.after_sales : [];
|
||||
if (afterSales.length) {
|
||||
html += '<section class="aksrt-order-detail__group"><h4 class="aksrt-order-detail__group-title">售后记录</h4><ul class="aksrt-order-detail__after-sales">';
|
||||
afterSales.forEach(function (item, index) {
|
||||
var labels = { pending: "待审核", processing: "处理中", approved: "已通过", rejected: "已拒绝", cancelled: "已取消", completed: "已完成" };
|
||||
var asStatus = String(item.status || "");
|
||||
var asLabel = labels[asStatus] || asStatus;
|
||||
html += '<li class="aksrt-order-detail__after-sale is-' + escapeHtml(asStatus) + '">';
|
||||
html += '<div class="aksrt-order-detail__after-sale-head"><span class="aksrt-order-detail__after-sale-badge is-' + escapeHtml(asStatus) + '">' + escapeHtml(asLabel) + "</span>";
|
||||
if (item.created_at) html += "<time>" + escapeHtml(item.created_at) + "</time>";
|
||||
html += "</div>";
|
||||
// 原因 / 申请理由:新数据走 apply_form 结构化字段;老数据无 apply_form 时兜底展示融合串。
|
||||
var applyForm = item.apply_form && typeof item.apply_form === "object" ? item.apply_form : null;
|
||||
if (applyForm) {
|
||||
var reasonLabel = String(applyForm.reason_label || "");
|
||||
var applyDesc = String(applyForm.description || "");
|
||||
if (reasonLabel !== "") html += '<p class="aksrt-order-detail__after-sale-reason"><span class="aksrt-order-detail__after-sale-field">原因:</span>' + escapeHtml(reasonLabel) + "</p>";
|
||||
if (applyDesc !== "") html += '<p class="aksrt-order-detail__after-sale-reason"><span class="aksrt-order-detail__after-sale-field">申请理由:</span>' + escapeHtml(applyDesc).replace(/\r\n|\r|\n/g, "<br>") + "</p>";
|
||||
} else if (item.reason) {
|
||||
html += '<p class="aksrt-order-detail__after-sale-reason">' + escapeHtml(item.reason).replace(/\r\n|\r|\n/g, "<br>") + "</p>";
|
||||
}
|
||||
// 查看详细:逐记录构造售后详细弹窗载荷(after_sale.latest 指向本条记录);
|
||||
// 再次申请仅对最新一条且订单级可再申请时开放(after_sales 按 id 倒序,首条即最新)。
|
||||
var recordPayload = escapeHtml(JSON.stringify({
|
||||
order_number: order.order_number || "",
|
||||
product_name: order.product_name || "",
|
||||
amount: order.amount || "0",
|
||||
is_points: !!order.is_points,
|
||||
currency_symbol: order.currency_symbol || "",
|
||||
status_label: order.status_label || "",
|
||||
after_sale: {
|
||||
enabled: true,
|
||||
has_application: true,
|
||||
can_reapply: index === 0 && !!((order.after_sale || {}).can_reapply),
|
||||
latest: {
|
||||
status: asStatus,
|
||||
reason: String(item.reason || ""),
|
||||
apply_form: applyForm,
|
||||
images: Array.isArray(item.images) ? item.images : [],
|
||||
refund_amount: String(item.refund_amount || "0"),
|
||||
created_at: String(item.created_at || ""),
|
||||
handling_log: Array.isArray(item.handling_log) ? item.handling_log : [],
|
||||
},
|
||||
},
|
||||
}));
|
||||
html += '<div class="aksrt-order-detail__after-sale-foot">';
|
||||
html += '<button class="btn-ghost" type="button" data-aksrt-after-sale-detail="' + recordPayload + '">查看详细</button>';
|
||||
html += "</div>";
|
||||
html += "</li>";
|
||||
});
|
||||
html += "</ul></section>";
|
||||
}
|
||||
|
||||
if (reconciliation.state === "refresh_available") {
|
||||
html += '<div class="aksrt-order-detail__refresh-state" role="status">';
|
||||
html += '<i class="fa fas fa-info-circle" aria-hidden="true"></i>';
|
||||
@@ -225,17 +172,12 @@
|
||||
html += "</div>";
|
||||
}
|
||||
|
||||
// 底部按钮与售后详细弹窗同式:but-average modal-buts;「关闭」与「我知道了」
|
||||
// 底部按钮:but-average modal-buts;「关闭」与「我知道了」
|
||||
// 同款(but c-blue),平铺观感由 orders.css 的 footer 按钮规则统一呈现。
|
||||
html += '<footer class="aksrt-order-detail__actions but-average modal-buts">';
|
||||
html += '<button type="button" class="but c-blue" data-aksrt-modal-close>关闭</button>';
|
||||
if (allowedActions.can_cancel) html += '<button type="button" class="but" data-aksrt-order-cancel="' + escapeHtml(order.order_number) + '">关闭订单</button>';
|
||||
if (allowedActions.can_pay) html += '<button type="button" class="but c-blue" data-aksrt-order-pay="' + escapeHtml(order.order_number) + '" data-aksrt-order-method="' + escapeHtml(paymentMethod.value || "") + '">继续支付</button>';
|
||||
// 售后入口:未申请时底部保留「申请售后」;已有申请时入口收拢到售后记录卡片的「查看详细」。
|
||||
var detailAfterSale = order.after_sale || {};
|
||||
if (detailAfterSale.enabled && !detailAfterSale.has_application) {
|
||||
html += '<button type="button" class="but c-blue" data-aksrt-after-sale-apply="' + afterSalePayload(order) + '">申请售后</button>';
|
||||
}
|
||||
html += "</footer>";
|
||||
|
||||
session.content.innerHTML = html;
|
||||
@@ -310,42 +252,12 @@
|
||||
|
||||
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>';
|
||||
// 售后入口:申请售后 / 售后详细走 order-after-sale.js 的独立弹窗。
|
||||
if (afterSale.enabled) html += afterSaleButtons(order);
|
||||
return html;
|
||||
}
|
||||
|
||||
// 售后按钮(列表卡片与订单详情弹窗共用):已申请 → 售后详细,否则 → 申请售后。
|
||||
// 载荷与 PHP 模板同构(order-after-sale.js 同步消费),点击即弹窗、无前置请求。
|
||||
function afterSalePayload(order) {
|
||||
var afterSale = order.after_sale || {};
|
||||
return escapeHtml(JSON.stringify({
|
||||
order_number: order.order_number || "",
|
||||
product_name: order.product_name || "",
|
||||
amount: order.amount || "0",
|
||||
is_points: !!order.is_points,
|
||||
currency_symbol: order.currency_symbol || "",
|
||||
status_label: order.status_label || "",
|
||||
after_sale: afterSale,
|
||||
}));
|
||||
}
|
||||
|
||||
function afterSaleButtons(order) {
|
||||
var afterSale = order.after_sale || {};
|
||||
var html = "";
|
||||
if (!afterSale.enabled) return html;
|
||||
var payload = afterSalePayload(order);
|
||||
if (afterSale.has_application) {
|
||||
html += '<button class="btn-outline" type="button" data-aksrt-after-sale-detail="' + payload + '">售后详细</button>';
|
||||
return html;
|
||||
}
|
||||
html += '<button class="btn-outline" type="button" data-aksrt-after-sale-apply="' + payload + '">申请售后</button>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function cardHtml(order) {
|
||||
var image = order.image
|
||||
@@ -634,7 +546,6 @@
|
||||
state.detailReplacementSession = null;
|
||||
};
|
||||
state.onDocumentClick = function (event) {
|
||||
// 取消/撤销售后已移入售后详细弹窗(order-after-sale.js 的「撤销售后」入口)。
|
||||
if (!state.detailSession || window.AksrtModalRuntime.getActive() !== state.detailSession.shell) return;
|
||||
var copy = closest(event.target, "[data-aksrt-order-copy]");
|
||||
if (copy) {
|
||||
@@ -697,7 +608,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
// refresh 供售后弹窗(order-after-sale.js)提交/取消后刷新列表与统计。
|
||||
// refresh 供弹窗提交/取消后刷新列表与统计。
|
||||
function refresh(orderNumber) {
|
||||
invalidateOrder(orderNumber);
|
||||
liveStates.forEach(function (state) {
|
||||
|
||||
Reference in new issue
Block a user