diff --git a/assets/js/order-center.js b/assets/js/order-center.js index c7cba0aa..09f80630 100644 --- a/assets/js/order-center.js +++ b/assets/js/order-center.js @@ -637,7 +637,8 @@ var footer = ""; // Status-aware footer. Top-right ✕ is the only generic dismiss control. if (actions.can_cancel) { - footer += ''; + // Two-step cancel inside the modal — no window.confirm. + footer += ''; } if (actions.can_receipt) { footer += ''; @@ -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; diff --git a/src/AKSRT/Payment/OrderRepositoryCore.php b/src/AKSRT/Payment/OrderRepositoryCore.php index 2b3e6345..89698a71 100644 --- a/src/AKSRT/Payment/OrderRepositoryCore.php +++ b/src/AKSRT/Payment/OrderRepositoryCore.php @@ -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'; diff --git a/src/AKSRT/Payment/UserOrders.php b/src/AKSRT/Payment/UserOrders.php index 585bbee5..d73085ac 100644 --- a/src/AKSRT/Payment/UserOrders.php +++ b/src/AKSRT/Payment/UserOrders.php @@ -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 $order * @param array $shipping_records * @param array $after_sale_records - * @return array + * @return array */ 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; diff --git a/src/AKSRT/Shop/Part1.php b/src/AKSRT/Shop/Part1.php index 1d93eb12..53087750 100644 --- a/src/AKSRT/Shop/Part1.php +++ b/src/AKSRT/Shop/Part1.php @@ -556,44 +556,61 @@ trait Part1 { return $view; } + /** + * Shop-side timeline; keep behaviour aligned with UserOrders. + * + * @param array $order + * @param array $shipping_records + * @param array $after_sale_records + * @return array + */ 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; }