Merge pull request 'fix(orders): wire detail progress timeline and two-step cancel' (#312) from worktree-order-detail-progress-close into main
Reviewed-on: CYL/wordpress-theme-aksrt#312
This commit is contained in:
@@ -637,7 +637,8 @@
|
||||
var footer = "";
|
||||
// Status-aware footer. Top-right ✕ is the only generic dismiss control.
|
||||
if (actions.can_cancel) {
|
||||
footer += '<button type="button" class="btn-ghost" data-aksrt-order-cancel="' + escapeHtml(order.order_number) + '">关闭订单</button>';
|
||||
// Two-step cancel inside the modal — no window.confirm.
|
||||
footer += '<button type="button" class="btn-ghost" data-aksrt-order-cancel="' + escapeHtml(order.order_number) + '" data-aksrt-cancel-step="1">关闭订单</button>';
|
||||
}
|
||||
if (actions.can_receipt) {
|
||||
footer += '<button type="button" class="btn-primary" data-aksrt-order-receipt="' + escapeHtml(order.order_number) + '">确认收货</button>';
|
||||
@@ -919,21 +920,51 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel
|
||||
// Cancel — two-step in detail modal (关闭订单 → 确认取消), list card same.
|
||||
var cancelButton = event.target.closest("[data-aksrt-order-cancel]");
|
||||
if (cancelButton) {
|
||||
var cancelOrderNumber = cancelButton.getAttribute("data-aksrt-order-cancel");
|
||||
if (!window.confirm("确认关闭该订单?关闭后不可再支付。")) {
|
||||
if (!cancelOrderNumber) return;
|
||||
|
||||
// First click: transform into "确认取消" (no browser confirm dialog).
|
||||
var step = cancelButton.getAttribute("data-aksrt-cancel-step") || "1";
|
||||
if (step !== "2") {
|
||||
cancelButton.setAttribute("data-aksrt-cancel-step", "2");
|
||||
cancelButton.classList.remove("btn-ghost");
|
||||
cancelButton.classList.add("btn-primary", "is-cancel-confirm");
|
||||
cancelButton.textContent = "确认取消";
|
||||
// Auto-reset if user doesn't confirm within a few seconds.
|
||||
if (cancelButton._aksrtCancelReset) {
|
||||
window.clearTimeout(cancelButton._aksrtCancelReset);
|
||||
}
|
||||
cancelButton._aksrtCancelReset = window.setTimeout(function () {
|
||||
if (!cancelButton.isConnected) return;
|
||||
if (cancelButton.getAttribute("data-aksrt-cancel-step") !== "2") return;
|
||||
cancelButton.setAttribute("data-aksrt-cancel-step", "1");
|
||||
cancelButton.classList.add("btn-ghost");
|
||||
cancelButton.classList.remove("btn-primary", "is-cancel-confirm");
|
||||
// Detail modal uses "关闭订单"; list cards use "取消".
|
||||
var inDetail = !!(detailModal && detailModal.contains(cancelButton));
|
||||
cancelButton.textContent = inDetail ? "关闭订单" : "取消";
|
||||
}, 5000);
|
||||
return;
|
||||
}
|
||||
var cancelLabel = cancelButton.textContent || "取消";
|
||||
|
||||
if (cancelButton._aksrtCancelReset) {
|
||||
window.clearTimeout(cancelButton._aksrtCancelReset);
|
||||
cancelButton._aksrtCancelReset = null;
|
||||
}
|
||||
cancelButton.disabled = true;
|
||||
cancelButton.textContent = "取消中...";
|
||||
request(orderPath(cancelOrderNumber, "/cancel"))
|
||||
.then(function (result) {
|
||||
if (!result.success) {
|
||||
cancelButton.disabled = false;
|
||||
cancelButton.textContent = cancelLabel;
|
||||
cancelButton.setAttribute("data-aksrt-cancel-step", "1");
|
||||
cancelButton.classList.add("btn-ghost");
|
||||
cancelButton.classList.remove("btn-primary", "is-cancel-confirm");
|
||||
var inDetailFail = !!(detailModal && detailModal.contains(cancelButton));
|
||||
cancelButton.textContent = inDetailFail ? "关闭订单" : "取消";
|
||||
showToast(result.message || "取消失败。");
|
||||
return;
|
||||
}
|
||||
@@ -941,12 +972,22 @@
|
||||
var card = findOrderCard(cancelOrderNumber);
|
||||
updateCardStatus(card, "cancelled", "已取消");
|
||||
if (detailOrderNumber === cancelOrderNumber) {
|
||||
closeOrderDetailModal();
|
||||
// Refresh modal body with cancelled timeline, then close shortly.
|
||||
if (result.data && result.data.order) {
|
||||
renderOrderDetail(result.data.order);
|
||||
}
|
||||
window.setTimeout(function () {
|
||||
closeOrderDetailModal();
|
||||
}, 600);
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
cancelButton.disabled = false;
|
||||
cancelButton.textContent = cancelLabel;
|
||||
cancelButton.setAttribute("data-aksrt-cancel-step", "1");
|
||||
cancelButton.classList.add("btn-ghost");
|
||||
cancelButton.classList.remove("btn-primary", "is-cancel-confirm");
|
||||
var inDetailErr = !!(detailModal && detailModal.contains(cancelButton));
|
||||
cancelButton.textContent = inDetailErr ? "关闭订单" : "取消";
|
||||
showToast("网络错误,请稍后重试。");
|
||||
});
|
||||
return;
|
||||
|
||||
@@ -204,21 +204,48 @@ trait OrderRepositoryCore {
|
||||
$update_data = ['status' => $new_status];
|
||||
$format = ['%s'];
|
||||
|
||||
// Stamp lifecycle timestamps. Some statuses share a column (e.g. expired → cancelled_at;
|
||||
// pending_review still counts as a completion-like milestone for the timeline).
|
||||
$time_fields = [
|
||||
'paid' => 'paid_at',
|
||||
'delivered' => 'delivered_at',
|
||||
'completed' => 'completed_at',
|
||||
'cancelled' => 'cancelled_at',
|
||||
'refunded' => 'refunded_at',
|
||||
\AKSRT\Core\DomainModel::ORDER_STATUS_PAID => 'paid_at',
|
||||
\AKSRT\Core\DomainModel::ORDER_STATUS_DELIVERED => 'delivered_at',
|
||||
\AKSRT\Core\DomainModel::ORDER_STATUS_COMPLETED => 'completed_at',
|
||||
\AKSRT\Core\DomainModel::ORDER_STATUS_PENDING_REVIEW => 'completed_at',
|
||||
\AKSRT\Core\DomainModel::ORDER_STATUS_CANCELLED => 'cancelled_at',
|
||||
\AKSRT\Core\DomainModel::ORDER_STATUS_EXPIRED => 'cancelled_at',
|
||||
\AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED => 'refunded_at',
|
||||
];
|
||||
|
||||
foreach ($time_fields as $status_key => $field) {
|
||||
if ($new_status === $status_key) {
|
||||
if (isset($time_fields[$new_status])) {
|
||||
$field = $time_fields[$new_status];
|
||||
// Do not overwrite an existing timestamp (idempotent callbacks / force updates).
|
||||
$existing = is_string($order[$field] ?? null) ? trim((string) $order[$field]) : '';
|
||||
if ($existing === '' || str_starts_with($existing, '0000-00-00')) {
|
||||
$update_data[$field] = current_time('mysql');
|
||||
$format[] = '%s';
|
||||
}
|
||||
}
|
||||
|
||||
// When jumping past paid (force admin transitions), backfill paid_at so the
|
||||
// progress chain is never stuck on "订单创建" alone.
|
||||
if (
|
||||
in_array($new_status, [
|
||||
\AKSRT\Core\DomainModel::ORDER_STATUS_DELIVERED,
|
||||
\AKSRT\Core\DomainModel::ORDER_STATUS_COMPLETED,
|
||||
\AKSRT\Core\DomainModel::ORDER_STATUS_PENDING_REVIEW,
|
||||
\AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDING,
|
||||
\AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED,
|
||||
], true)
|
||||
) {
|
||||
$existing_paid = is_string($order['paid_at'] ?? null) ? trim((string) $order['paid_at']) : '';
|
||||
if ($existing_paid === '' || str_starts_with($existing_paid, '0000-00-00')) {
|
||||
if (!isset($update_data['paid_at'])) {
|
||||
$update_data['paid_at'] = current_time('mysql');
|
||||
$format[] = '%s';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($extra['payment_method'])) {
|
||||
$update_data['payment_method'] = sanitize_key($extra['payment_method']);
|
||||
$format[] = '%s';
|
||||
|
||||
@@ -490,47 +490,228 @@ class UserOrders {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build chronological order progress for the detail modal.
|
||||
*
|
||||
* Driven by current status + available timestamps / related records so the
|
||||
* chain advances even when historical rows lack paid_at/delivered_at.
|
||||
*
|
||||
* @param array<string, mixed> $order
|
||||
* @param array<int, array> $shipping_records
|
||||
* @param array<int, array> $after_sale_records
|
||||
* @return array<int, array{time:string,label:string}>
|
||||
* @return array<int, array{time:string,label:string,key?:string}>
|
||||
*/
|
||||
private function build_order_timeline(array $order, array $shipping_records, array $after_sale_records): array {
|
||||
$timeline = [];
|
||||
$date_fields = [
|
||||
'created_at' => __('订单创建', 'aksrt'),
|
||||
'paid_at' => __('完成支付', 'aksrt'),
|
||||
'delivered_at' => __('订单发货', 'aksrt'),
|
||||
'completed_at' => __('交易完成', 'aksrt'),
|
||||
'cancelled_at' => __('订单取消', 'aksrt'),
|
||||
'refunded_at' => __('完成退款', 'aksrt'),
|
||||
];
|
||||
foreach ($date_fields as $key => $label) {
|
||||
if (!empty($order[$key])) {
|
||||
$timeline[] = [
|
||||
'time' => (string) $order[$key],
|
||||
'label' => $label,
|
||||
];
|
||||
$status = sanitize_key((string) ($order['status'] ?? Order::STATUS_PENDING));
|
||||
$created_at = $this->timeline_time((string) ($order['created_at'] ?? ''));
|
||||
$paid_at = $this->timeline_time((string) ($order['paid_at'] ?? ''));
|
||||
$delivered_at = $this->timeline_time((string) ($order['delivered_at'] ?? ''));
|
||||
$completed_at = $this->timeline_time((string) ($order['completed_at'] ?? ''));
|
||||
$cancelled_at = $this->timeline_time((string) ($order['cancelled_at'] ?? ''));
|
||||
$refunded_at = $this->timeline_time((string) ($order['refunded_at'] ?? ''));
|
||||
|
||||
$shipping_created = '';
|
||||
foreach ($shipping_records as $record) {
|
||||
$t = $this->timeline_time((string) ($record['created_at'] ?? ''));
|
||||
if ($t !== '' && ($shipping_created === '' || strcmp($t, $shipping_created) < 0)) {
|
||||
$shipping_created = $t;
|
||||
}
|
||||
}
|
||||
foreach ($shipping_records as $record) {
|
||||
$timeline[] = [
|
||||
'time' => (string) ($record['created_at'] ?? ''),
|
||||
'label' => sprintf(__('物流更新:%s', 'aksrt'), $record['tracking_no'] ?? ''),
|
||||
if ($delivered_at === '' && $shipping_created !== '') {
|
||||
$delivered_at = $shipping_created;
|
||||
}
|
||||
|
||||
$paid_like = [
|
||||
Order::STATUS_PAID,
|
||||
Order::STATUS_DELIVERED,
|
||||
Order::STATUS_COMPLETED,
|
||||
Order::STATUS_PENDING_REVIEW,
|
||||
Order::STATUS_REFUNDING,
|
||||
Order::STATUS_REFUNDED,
|
||||
];
|
||||
$ship_like = [
|
||||
Order::STATUS_DELIVERED,
|
||||
Order::STATUS_COMPLETED,
|
||||
Order::STATUS_PENDING_REVIEW,
|
||||
];
|
||||
$done_like = [
|
||||
Order::STATUS_COMPLETED,
|
||||
Order::STATUS_PENDING_REVIEW,
|
||||
];
|
||||
|
||||
$timeline = [];
|
||||
$push = static function (array &$out, string $key, string $label, string $time): void {
|
||||
if ($label === '') {
|
||||
return;
|
||||
}
|
||||
$out[] = [
|
||||
'key' => $key,
|
||||
'label' => $label,
|
||||
'time' => $time,
|
||||
];
|
||||
};
|
||||
|
||||
// 1) Always: order created.
|
||||
$push(
|
||||
$timeline,
|
||||
'created',
|
||||
__('订单创建', 'aksrt'),
|
||||
$created_at !== '' ? $created_at : ''
|
||||
);
|
||||
|
||||
// 2) Paid (status-driven; fall back to created_at when paid_at missing).
|
||||
if ($paid_at !== '' || in_array($status, $paid_like, true)) {
|
||||
$push(
|
||||
$timeline,
|
||||
'paid',
|
||||
__('完成支付', 'aksrt'),
|
||||
$paid_at !== '' ? $paid_at : $created_at
|
||||
);
|
||||
}
|
||||
|
||||
// 3) Shipped / delivered — only when logistics exists or status reached it.
|
||||
$has_shipping = $shipping_records !== [];
|
||||
if (
|
||||
$delivered_at !== ''
|
||||
|| $has_shipping
|
||||
|| in_array($status, $ship_like, true)
|
||||
) {
|
||||
// Virtual / content flows often jump paid → completed without logistics;
|
||||
// only show the ship step when we have evidence of shipping.
|
||||
if ($delivered_at !== '' || $has_shipping || $status === Order::STATUS_DELIVERED) {
|
||||
$push(
|
||||
$timeline,
|
||||
'delivered',
|
||||
__('订单发货', 'aksrt'),
|
||||
$delivered_at !== '' ? $delivered_at : ($paid_at !== '' ? $paid_at : $created_at)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Completed / pending review.
|
||||
if ($completed_at !== '' || in_array($status, $done_like, true)) {
|
||||
$done_label = $status === Order::STATUS_PENDING_REVIEW
|
||||
? __('待评价', 'aksrt')
|
||||
: __('交易完成', 'aksrt');
|
||||
$push(
|
||||
$timeline,
|
||||
'completed',
|
||||
$done_label,
|
||||
$completed_at !== ''
|
||||
? $completed_at
|
||||
: ($delivered_at !== '' ? $delivered_at : ($paid_at !== '' ? $paid_at : $created_at))
|
||||
);
|
||||
}
|
||||
|
||||
// 5) Terminal cancel / expire / refund.
|
||||
if ($status === Order::STATUS_CANCELLED || $cancelled_at !== '') {
|
||||
$push(
|
||||
$timeline,
|
||||
'cancelled',
|
||||
__('订单取消', 'aksrt'),
|
||||
$cancelled_at !== '' ? $cancelled_at : $created_at
|
||||
);
|
||||
}
|
||||
if ($status === Order::STATUS_EXPIRED) {
|
||||
$push(
|
||||
$timeline,
|
||||
'expired',
|
||||
__('订单过期', 'aksrt'),
|
||||
$cancelled_at !== '' ? $cancelled_at : $created_at
|
||||
);
|
||||
}
|
||||
if ($status === Order::STATUS_REFUNDING) {
|
||||
$push(
|
||||
$timeline,
|
||||
'refunding',
|
||||
__('退款处理中', 'aksrt'),
|
||||
$refunded_at !== '' ? $refunded_at : ($paid_at !== '' ? $paid_at : $created_at)
|
||||
);
|
||||
}
|
||||
if ($status === Order::STATUS_REFUNDED || $refunded_at !== '') {
|
||||
$push(
|
||||
$timeline,
|
||||
'refunded',
|
||||
__('完成退款', 'aksrt'),
|
||||
$refunded_at !== '' ? $refunded_at : ($paid_at !== '' ? $paid_at : $created_at)
|
||||
);
|
||||
}
|
||||
|
||||
// Extra logistics / after-sale events (detail lines).
|
||||
foreach ($shipping_records as $record) {
|
||||
$tracking = trim((string) ($record['tracking_no'] ?? ''));
|
||||
$carrier = trim((string) ($record['carrier_label'] ?? $record['carrier'] ?? ''));
|
||||
$status_l = trim((string) ($record['status_label'] ?? $record['status'] ?? ''));
|
||||
$parts = array_filter([$carrier, $tracking, $status_l], static fn($v) => $v !== '');
|
||||
$label = $parts !== []
|
||||
? sprintf(__('物流更新:%s', 'aksrt'), implode(' / ', $parts))
|
||||
: __('物流更新', 'aksrt');
|
||||
$push(
|
||||
$timeline,
|
||||
'shipping',
|
||||
$label,
|
||||
$this->timeline_time((string) ($record['created_at'] ?? $record['updated_at'] ?? ''))
|
||||
);
|
||||
}
|
||||
foreach ($after_sale_records as $record) {
|
||||
$timeline[] = [
|
||||
'time' => (string) ($record['created_at'] ?? ''),
|
||||
'label' => sprintf(__('售后申请:%s', 'aksrt'), $record['reason'] ?? ''),
|
||||
];
|
||||
$reason = trim((string) ($record['reason'] ?? ''));
|
||||
$type_l = trim((string) ($record['type_label'] ?? $record['type'] ?? ''));
|
||||
$as_status = trim((string) ($record['status_label'] ?? $record['status'] ?? ''));
|
||||
$parts = array_filter([$type_l, $as_status, $reason], static fn($v) => $v !== '');
|
||||
$label = $parts !== []
|
||||
? sprintf(__('售后:%s', 'aksrt'), implode(' — ', $parts))
|
||||
: __('售后申请', 'aksrt');
|
||||
$push(
|
||||
$timeline,
|
||||
'after_sale',
|
||||
$label,
|
||||
$this->timeline_time((string) ($record['created_at'] ?? $record['updated_at'] ?? ''))
|
||||
);
|
||||
}
|
||||
|
||||
usort($timeline, static function ($a, $b) {
|
||||
return strcmp($a['time'] ?? '', $b['time'] ?? '');
|
||||
$ta = (string) ($a['time'] ?? '');
|
||||
$tb = (string) ($b['time'] ?? '');
|
||||
if ($ta === $tb) {
|
||||
// Keep canonical pipeline order when times collide / missing.
|
||||
$rank = [
|
||||
'created' => 10,
|
||||
'paid' => 20,
|
||||
'delivered' => 30,
|
||||
'shipping' => 35,
|
||||
'completed' => 40,
|
||||
'refunding' => 50,
|
||||
'refunded' => 60,
|
||||
'cancelled' => 70,
|
||||
'expired' => 80,
|
||||
'after_sale'=> 90,
|
||||
];
|
||||
$ra = $rank[(string) ($a['key'] ?? '')] ?? 99;
|
||||
$rb = $rank[(string) ($b['key'] ?? '')] ?? 99;
|
||||
return $ra <=> $rb;
|
||||
}
|
||||
if ($ta === '') {
|
||||
return 1;
|
||||
}
|
||||
if ($tb === '') {
|
||||
return -1;
|
||||
}
|
||||
return strcmp($ta, $tb);
|
||||
});
|
||||
|
||||
return $timeline;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a mysql datetime for timeline display (empty when invalid/zero).
|
||||
*/
|
||||
private function timeline_time(string $value): string {
|
||||
$value = trim($value);
|
||||
if ($value === '' || str_starts_with($value, '0000-00-00')) {
|
||||
return '';
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function get_order_countdown(string $expires_at, string $status): int {
|
||||
if ($expires_at === '' || !in_array($status, [Order::STATUS_PENDING, Order::STATUS_EXPIRED, Order::STATUS_CREATED], true)) {
|
||||
return 0;
|
||||
|
||||
+39
-22
@@ -556,44 +556,61 @@ trait Part1 {
|
||||
return $view;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shop-side timeline; keep behaviour aligned with UserOrders.
|
||||
*
|
||||
* @param array<string, mixed> $order
|
||||
* @param array<int, array> $shipping_records
|
||||
* @param array<int, array> $after_sale_records
|
||||
* @return array<int, array{time:string,label:string,key?:string}>
|
||||
*/
|
||||
private function build_order_timeline(array $order, array $shipping_records, array $after_sale_records): array {
|
||||
$timeline = [];
|
||||
$date_fields = [
|
||||
'created_at' => __('订单创建', 'aksrt'),
|
||||
'paid_at' => __('完成支付', 'aksrt'),
|
||||
'delivered_at' => __('订单发货', 'aksrt'),
|
||||
'completed_at' => __('交易完成', 'aksrt'),
|
||||
'cancelled_at' => __('订单取消', 'aksrt'),
|
||||
'refunded_at' => __('完成退款', 'aksrt'),
|
||||
];
|
||||
|
||||
foreach ($date_fields as $key => $label) {
|
||||
if (!empty($order[$key])) {
|
||||
$timeline[] = [
|
||||
'time' => (string) $order[$key],
|
||||
'label' => $label,
|
||||
];
|
||||
// Prefer the unified order-center builder when available.
|
||||
if (class_exists('\AKSRT\Payment\UserOrders')) {
|
||||
$view = \AKSRT\Payment\UserOrders::get_instance()->decorate_order_view($order, true);
|
||||
if (is_array($view['timeline'] ?? null)) {
|
||||
return $view['timeline'];
|
||||
}
|
||||
}
|
||||
|
||||
$timeline = [];
|
||||
$created = (string) ($order['created_at'] ?? '');
|
||||
if ($created !== '') {
|
||||
$timeline[] = ['key' => 'created', 'time' => $created, 'label' => __('订单创建', 'aksrt')];
|
||||
}
|
||||
$status = (string) ($order['status'] ?? '');
|
||||
$paid_like = [
|
||||
\AKSRT\Payment\Order::STATUS_PAID,
|
||||
\AKSRT\Payment\Order::STATUS_DELIVERED,
|
||||
\AKSRT\Payment\Order::STATUS_COMPLETED,
|
||||
\AKSRT\Payment\Order::STATUS_PENDING_REVIEW,
|
||||
\AKSRT\Payment\Order::STATUS_REFUNDED,
|
||||
\AKSRT\Payment\Order::STATUS_REFUNDING,
|
||||
];
|
||||
if (!empty($order['paid_at']) || in_array($status, $paid_like, true)) {
|
||||
$timeline[] = [
|
||||
'key' => 'paid',
|
||||
'time' => (string) ($order['paid_at'] ?? $created),
|
||||
'label' => __('完成支付', 'aksrt'),
|
||||
];
|
||||
}
|
||||
foreach ($shipping_records as $record) {
|
||||
$timeline[] = [
|
||||
'time' => (string) ($record['created_at'] ?? ''),
|
||||
'key' => 'shipping',
|
||||
'time' => (string) ($record['created_at'] ?? ''),
|
||||
'label' => sprintf(__('物流更新:%s', 'aksrt'), $record['tracking_no'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($after_sale_records as $record) {
|
||||
$timeline[] = [
|
||||
'time' => (string) ($record['created_at'] ?? ''),
|
||||
'key' => 'after_sale',
|
||||
'time' => (string) ($record['created_at'] ?? ''),
|
||||
'label' => sprintf(__('售后申请:%s', 'aksrt'), $record['reason'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
usort($timeline, static function ($a, $b) {
|
||||
return strcmp($a['time'] ?? '', $b['time'] ?? '');
|
||||
return strcmp((string) ($a['time'] ?? ''), (string) ($b['time'] ?? ''));
|
||||
});
|
||||
|
||||
return $timeline;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user