fix(orders): wire detail progress timeline and two-step cancel

Order progress was stuck on "订单创建" because timeline only read sparse
timestamp columns and status transitions didn't always stamp them.
Drive timeline from current status + related records, backfill lifecycle
timestamps on update_status, and replace window.confirm cancel with a
关闭订单 → 确认取消 button step in the detail modal.
This commit is contained in:
2026-07-27 15:15:35 +08:00
parent 64d0dc66da
commit ad40d9a7ca
4 changed files with 327 additions and 61 deletions
+48 -7
View File
@@ -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;
+34 -7
View File
@@ -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';
+206 -25
View File
@@ -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
View File
@@ -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;
}