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;
}