diff --git a/assets/css/frontend/modal-runtime.css b/assets/css/frontend/modal-runtime.css index 5252ffcc..feb3c5dd 100644 --- a/assets/css/frontend/modal-runtime.css +++ b/assets/css/frontend/modal-runtime.css @@ -1,804 +1,804 @@ -/** - * Modal runtime + ajax list primitives (shared). - * - * @package AKSRT - */ - -/* ---- portal token bridge ---- - * - * 弹窗由 runtime portal 到 ,因此它位于任何「页面级 token 作用域」之外。 - * 典型陷阱:路由样式把语义别名挂在页面上,例如 - * - * .aksrt-orders-page { --orders-ink: var(--color-ink, #333); } - * - * 弹窗内部再去 `var(--orders-ink)` 时该变量不存在,整条声明会被静默丢弃—— - * 表现为边框消失、次级文字变透明,而不是抛错,排查成本很高。 - * - * 因此共享调色板在这里声明:本壳层永远在 portal 内,任何弹窗都能直接消费 - * `--modal-*`。路由样式应把自己的局部别名映射到这里,而不是映射到页面级变量。 - */ -.aksrt-modal-runtime { - --modal-ink: var(--color-ink, #333333); - --modal-ink-2: var(--color-ink-2, #4e5358); - --modal-muted: var(--color-ink-3, #777777); - --modal-line: var(--color-line-stronger, rgba(50, 50, 50, 0.12)); - --modal-line-soft: var(--color-line, rgba(50, 50, 50, 0.06)); - --modal-surface: var(--surface-1, #ffffff); - --modal-surface-2: var(--surface-2, #eeeeee); - --modal-surface-3: var(--surface-4, #f8f8f8); - --modal-primary: var(--color-primary, #f04494); - --modal-on-primary: var(--color-on-primary, #ffffff); - --modal-warning: var(--color-warning, #f59e0b); - --modal-success: var(--color-success, #10b981); - --modal-danger: var(--color-danger, #ef4444); -} - -/* ---- scroll lock ---- */ -html.aksrt-modal-runtime-open, -body.aksrt-modal-runtime-open { - overflow: hidden; -} - -/* ---- 对话框宽度类不得作用于壳 ---- - 触发器沿用参考主题的 data-class 语义(modal-mini/full-sm 等), - 但这些类落在 fixed 全屏壳上时,.modal-mini 的 width:400px + - auto 边距会把壳压成居中单列,蒙版随之只覆盖一列。 */ -.aksrt-modal-runtime.modal-mini, -.aksrt-modal-runtime.full-sm { - width: auto; - max-width: none; - margin: 0; -} - -/* ---- modal runtime shell ---- */ -.aksrt-modal-runtime { - position: fixed; - inset: 0; - z-index: 100050; - display: flex; - align-items: center; - justify-content: center; - opacity: 0; - visibility: hidden; - transition: opacity 0.2s ease, visibility 0.2s ease; - pointer-events: none; -} - -.aksrt-modal-runtime.is-open { - opacity: 1; - visibility: visible; - pointer-events: auto; -} - -.aksrt-modal-runtime__overlay { - position: absolute; - inset: 0; - /* 参考主题 .modal-backdrop 同款:纯黑 50%,无模糊、无色调偏移 */ - background: rgba(0, 0, 0, 0.5); -} - -.aksrt-modal-runtime__dialog { - position: relative; - z-index: 1; - width: min(440px, calc(100vw - 32px)); - max-height: min(88vh, 720px); - overflow: auto; - background: var(--surface-1, #fff); - border-radius: 16px; - box-shadow: 0 18px 48px rgba(15, 18, 28, 0.22); - transform: translateY(12px) scale(0.98); - transition: transform 0.22s cubic-bezier(0.22, 1, 0.36, 1); - color: var(--color-ink, #1f2937); -} - -.aksrt-modal-runtime.is-open .aksrt-modal-runtime__dialog { - transform: translateY(0) scale(1); -} - -.aksrt-modal-runtime--bottom { - align-items: flex-end; -} - -.aksrt-modal-runtime--bottom .aksrt-modal-runtime__dialog { - width: 100%; - max-width: 100%; - border-radius: 16px 16px 0 0; - max-height: 90vh; - transform: translateY(24px); -} - -.aksrt-modal-runtime--bottom.is-open .aksrt-modal-runtime__dialog { - transform: translateY(0); -} - -.aksrt-modal-runtime__header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 16px 18px 0; -} - -.aksrt-modal-runtime__title { - margin: 0; - font-size: 17px; - line-height: 1.35; -} - -.aksrt-modal-runtime__content { - padding: 18px 18px 16px; -} - -.aksrt-modal-runtime__loading, -.aksrt-modal-runtime__error-body { - padding: 28px 8px; - text-align: center; - color: var(--color-ink-2, #6b7280); - font-size: 14px; -} - -.aksrt-modal-runtime__skeleton { - display: grid; - gap: 12px; - padding: 8px 2px; -} - -.aksrt-modal-runtime__skeleton-line, -.aksrt-modal-runtime__skeleton-block { - display: block; - border-radius: 8px; - background: linear-gradient(90deg, rgba(148, 163, 184, 0.16), rgba(148, 163, 184, 0.3), rgba(148, 163, 184, 0.16)); - background-size: 200% 100%; - animation: aksrt-modal-skeleton 1.2s ease-in-out infinite; -} - -.aksrt-modal-runtime__skeleton-line { - width: 72%; - height: 12px; -} - -.aksrt-modal-runtime__skeleton-line.is-wide { - width: 92%; - height: 18px; -} - -.aksrt-modal-runtime__skeleton-line.is-short { - width: 44%; -} - -.aksrt-modal-runtime__skeleton-block { - width: 100%; - min-height: 84px; -} - -.aksrt-modal-runtime__retry { - cursor: pointer; -} - -@keyframes aksrt-modal-skeleton { - 0% { background-position: 100% 0; } - 100% { background-position: -100% 0; } -} - -.aksrt-modal-runtime .touch.border-title, -.aksrt-modal-runtime .border-title { - font-size: 16px; - font-weight: 700; - margin: 0 0 14px; - display: flex; - align-items: baseline; - flex-wrap: wrap; - gap: 6px; -} - -.aksrt-modal-runtime .aksrt-modal-runtime__close { - position: absolute; - top: 10px; - right: 10px; - width: 32px; - height: 32px; - border: 0; - border-radius: 999px; - background: transparent; - color: var(--color-ink-3, #9ca3af); - cursor: pointer; - font-size: 18px; - line-height: 1; -} - -.aksrt-modal-runtime .but-average.modal-buts, -.aksrt-modal-runtime .modal-buts { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 10px; - margin-top: 16px; -} - -.aksrt-modal-runtime .but-average.modal-buts .but, -.aksrt-modal-runtime .modal-buts .but, -.aksrt-modal-runtime .modal-buts button { - min-height: 40px; - border-radius: 10px; - border: 1px solid var(--color-line, #e5e7eb); - background: var(--surface-4, #f3f4f6); - color: var(--color-ink, #111); - font-weight: 600; - cursor: pointer; -} - -.aksrt-modal-runtime .modal-buts .but.c-blue, -.aksrt-modal-runtime .modal-buts button.c-blue, -.aksrt-modal-runtime .modal-buts .btn-primary { - background: var(--color-primary, #f04494); - border-color: color-mix(in srgb, var(--color-primary, #f04494) 70%, #000 0%); - color: var(--color-on-primary, #fff); -} - -/* ---- 签到详情弹窗(参考主题 refresh_modal modal-mini 同构壳) ---- - 内容为服务端 HTML(modal-colorful-header + modal-body),与参考主题 - .modal-dialog.modal-mini 几何 1:1:宽 400px、圆角 var(--main-radius)。 */ -.aksrt-modal-runtime.aksrt-checkin-details-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog { - width: min(400px, calc(100vw - 32px)); - border-radius: var(--main-radius, 8px); - background: var(--main-bg-color, #fff); -} - -.aksrt-modal-runtime.aksrt-checkin-details-modal .aksrt-modal-runtime__content { - padding: 0; -} - -.aksrt-modal-runtime.aksrt-checkin-details-modal .aksrt-modal-runtime__skeleton { - padding: 24px 18px; -} - -/* ---- ajax list ---- */ -.ajaxpager.is-ajax-loading { - position: relative; - min-height: 80px; -} - -/* 骨架默认收起,由 JS 加 .is-loading 展开(参考主题 post_ajax 的 beforeSend - remove + fadeIn / success .hide() 语义等价物)。 - 这里不依赖 .ajaxpager 祖先:首页 tab pane、归档流、评论列表的 loader 层级各不 - 相同,而且并非每个 loader 都在 bootstrap 的 .tab-pane 里(bootstrap 只负责 - 隐藏 tab-pane 内的骨架),统一按类名自身控制。 */ -.post_ajax_loader { - display: none; -} - -.post_ajax_loader.is-loading { - display: block; - animation: aksrtFadeIn 0.2s ease both; -} - -.ajax-item.is-fading { - opacity: 0.35; - transition: opacity 0.15s ease; -} - -/* ---- 举报弹窗(参考主题报告弹窗几何 1:1)---- - 宽度对齐参考主题 Bootstrap 默认模态(>=768px 600px),圆角/底色走 - --main-radius / --main-bg-color;移动端仍由 --bottom 底部抽屉接管。 */ -.aksrt-report-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog { - width: min(600px, calc(100vw - 32px)); - border-radius: var(--main-radius, 8px); - background: var(--main-bg-color, #fff); -} - -/* edit-header:参考主题弹窗头(关闭钮靠右,红色警示徽标 + 标题居左)。 */ -.aksrt-report-modal .edit-header { - position: relative; - padding-right: 32px; -} - -.aksrt-report-modal .edit-header .close { - position: absolute; - right: 0; - top: 50%; - transform: translateY(-50%); - float: none; - font-size: 20px; - line-height: 1; - cursor: pointer; - background: none; - border: 0; - padding: 2px 6px; -} - -/* 原因徽章行:与参考主题一致,徽章可换行且间距走 mr6/mb6 工具类。 */ -.aksrt-report-modal .form-but-radio .badg { - cursor: pointer; - user-select: none; -} - -/* ---- 禁封用户弹窗(参考主题 set_user_ban 模态框几何 1:1)---- - 参考主题 zib_get_edit_user_ban_link 未传 data-class → 走 Bootstrap 默认 - .modal-dialog(>=768px 宽 600px);data-height=360 → .modal-content 初始 - 高度 360 后动画至内容高度;mobile-bottom → <769px 底部抽屉。 - 此处以 min-height:360px 复刻初始高度契约,桌面宽取 600px。 */ -.aksrt-user-ban-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog { - width: min(600px, calc(100vw - 32px)); - border-radius: var(--main-radius, 8px); - background: var(--main-bg-color, #fff); -} - -/* ---- 查看禁封状态 / 禁封申诉 / 禁封记录弹窗(参考主题 modal-mini 几何 1:1)---- - 参考主题三个链接均传 data_class='modal-mini'(宽 400px)、data-height=240。 */ -.aksrt-user-ban-info-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog, -.aksrt-user-ban-appeal-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog, -.aksrt-user-ban-log-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog { - width: min(400px, calc(100vw - 32px)); - border-radius: var(--main-radius, 8px); - background: var(--main-bg-color, #fff); -} - -.aksrt-user-ban-info-modal .aksrt-modal-runtime__skeleton, -.aksrt-user-ban-appeal-modal .aksrt-modal-runtime__skeleton, -.aksrt-user-ban-log-modal .aksrt-modal-runtime__skeleton { - padding: 24px 18px; -} - -.aksrt-user-ban-modal .aksrt-modal-runtime__skeleton { - padding: 24px 18px; -} - -/* edit-header:与禁封表单头部同构(关闭钮靠右,红色警示徽标 + 标题居左)。 */ -.aksrt-user-ban-modal .edit-header, -.aksrt-user-ban-appeal-modal .edit-header { - position: relative; - padding-right: 32px; -} - -.aksrt-user-ban-modal .edit-header .close, -.aksrt-user-ban-appeal-modal .edit-header .close { - position: absolute; - right: 0; - top: 50%; - transform: translateY(-50%); - float: none; - font-size: 20px; - line-height: 1; - cursor: pointer; - background: none; - border: 0; - padding: 2px 6px; -} - -/* 解封视图多彩横幅(参考主题 zib_get_modal_colorful_header('c-yellow', ...)): - 120px 高绝对定位横幅 + 旋转彩斑,后续表单整体下移 120px。 */ -.aksrt-user-ban-modal .modal-colorful-header, -.aksrt-user-ban-modal .colorful-bg, -.aksrt-user-ban-info-modal .modal-colorful-header, -.aksrt-user-ban-info-modal .colorful-bg, -.aksrt-user-ban-log-modal .modal-colorful-header, -.aksrt-user-ban-log-modal .colorful-bg { - position: relative; - overflow: hidden; - color: var(--this-color); - background: var(--this-bg); - background-size: 120%; -} - -.aksrt-user-ban-modal .modal-colorful-header, -.aksrt-user-ban-info-modal .modal-colorful-header, -.aksrt-user-ban-log-modal .modal-colorful-header { - position: absolute; - top: 0; - right: 0; - left: 0; - display: flex; - align-items: center; - justify-content: center; - height: 120px; - border-radius: var(--main-radius, 8px) var(--main-radius, 8px) 0 0; -} - -.aksrt-user-ban-modal .modal-colorful-header + *, -.aksrt-user-ban-info-modal .modal-colorful-header + *, -.aksrt-user-ban-log-modal .modal-colorful-header + * { - margin-top: 120px; -} - -.aksrt-user-ban-modal .modal-colorful-header > .close, -.aksrt-user-ban-info-modal .modal-colorful-header > .close, -.aksrt-user-ban-log-modal .modal-colorful-header > .close { - position: absolute; - top: 10px; - right: 10px; - z-index: 9; - color: var(--this-color); - font-size: 18px; - opacity: 0.6; -} - -.aksrt-user-ban-modal .colorful-bg > .colorful-make, -.aksrt-user-ban-modal .colorful-bg > .colorful-make::before, -.aksrt-user-ban-modal .colorful-bg > .colorful-make::after, -.aksrt-user-ban-info-modal .colorful-bg > .colorful-make, -.aksrt-user-ban-info-modal .colorful-bg > .colorful-make::before, -.aksrt-user-ban-info-modal .colorful-bg > .colorful-make::after, -.aksrt-user-ban-log-modal .colorful-bg > .colorful-make, -.aksrt-user-ban-log-modal .colorful-bg > .colorful-make::before, -.aksrt-user-ban-log-modal .colorful-bg > .colorful-make::after { - position: absolute; - color: var(--this-color); - background: var(--this-bg); - border-radius: 100%; - content: ''; -} - -.aksrt-user-ban-modal .colorful-bg > .colorful-make, -.aksrt-user-ban-info-modal .colorful-bg > .colorful-make, -.aksrt-user-ban-log-modal .colorful-bg > .colorful-make { - top: 60%; - bottom: 0; - width: 323px; - height: 290px; - animation: aksrt-ban-colorful 120s linear infinite; -} - -.aksrt-user-ban-modal .colorful-bg > .colorful-make::before, -.aksrt-user-ban-info-modal .colorful-bg > .colorful-make::before, -.aksrt-user-ban-log-modal .colorful-bg > .colorful-make::before { - top: -325px; - right: -558px; - bottom: 0; - width: 677px; - height: 300px; - opacity: 0.6; - transform: rotate(341deg); -} - -.aksrt-user-ban-modal .colorful-bg > .colorful-make::after, -.aksrt-user-ban-info-modal .colorful-bg > .colorful-make::after, -.aksrt-user-ban-log-modal .colorful-bg > .colorful-make::after { - top: -170px; - bottom: 0; - left: -262px; - width: 402px; - height: 214px; - opacity: 0.4; - transform: rotate(326deg); -} - -.aksrt-user-ban-modal .colorful-bg > .colorful-make + *, -.aksrt-user-ban-info-modal .colorful-bg > .colorful-make + *, -.aksrt-user-ban-log-modal .colorful-bg > .colorful-make + * { - position: relative; -} - -/* 单选徽章行(参考主题 .form-but-radio 契约):input 由 main.css 隐藏, - label 可点;选中态由 main.css 的 input:checked + .but-radio 驱动。 */ -.aksrt-user-ban-modal .form-but-radio label { - cursor: pointer; -} - -.aksrt-user-ban-modal .form-but-radio .but-radio { - user-select: none; -} - -/* 开关行(删除全部内容 / 禁止用户申诉):原生 checkbox 隐藏, - 视觉态由 main.css 的 input:checked ~ .form-switch 驱动。 */ -.aksrt-user-ban-modal .aksrt-user-ban-form label { - user-select: none; -} - -@keyframes aksrt-ban-colorful { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } -} - -/* ---- order review modal content ---- */ -.order-comment-from .shop-score-box { - display: flex; - align-items: center; - gap: 8px; - min-height: 36px; -} - -.order-comment-from .score-title { - width: 4.5em; - flex: 0 0 auto; - font-size: 13px; - color: var(--color-ink-2, #6b7280); -} - -.order-comment-from .score-icon { - display: inline-flex; - gap: 4px; - align-items: center; - height: 30px; -} - -.order-comment-from .icon-star { - display: inline-flex; - width: 22px; - height: 22px; - color: #d1d5db; - cursor: pointer; - transition: color 0.12s ease, transform 0.12s ease; -} - -.order-comment-from .icon-star svg { - width: 22px; - height: 22px; - fill: currentColor; -} - -.order-comment-from .icon-star.active, -.order-comment-from .icon-star.hover { - color: #fc7049; -} - -.order-comment-from .icon-star.hover { - transform: scale(1.08); -} - -.order-comment-from .score-text { - min-width: 3em; - font-size: 13px; - color: var(--color-ink-2, #6b7280); -} - -.order-comment-from textarea.form-control { - width: 100%; - border: 1px solid var(--color-line, #e5e7eb); - border-radius: 10px; - padding: 10px 12px; - font-size: 14px; - resize: vertical; - min-height: 96px; - background: var(--surface-1, #fff); - color: var(--color-ink, #111); -} - -:is(.order-comment-from, .aksrt-report-modal, .aksrt-after-sale-apply-modal) .form-upload .preview { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -:is(.order-comment-from, .aksrt-report-modal) .form-upload .preview .add, -:is(.order-comment-from, .aksrt-report-modal, .aksrt-after-sale-apply-modal) .form-upload .preview .add, -:is(.order-comment-from, .aksrt-report-modal, .aksrt-after-sale-apply-modal) .form-upload .preview .preview-item { - width: 80px; - height: 80px; - border-radius: 10px; - border: 1px dashed var(--color-line, #d1d5db); - background: var(--surface-4, #f9fafb); - position: relative; - overflow: hidden; - cursor: pointer; -} - -/* 加号图标不在此重复定义:main.css 已有参考主题原生实现 - (.preview .add:before/:after —— 25px 圆角杠,inset:0 + margin:auto 居中)。 - 此前这里的 left/top:50% + translate 版本与该规则叠加导致过约束, - 加号被推到 75%/75% 只剩“L”形,故整体删除、统一走主样式包。 */ - -:is(.order-comment-from, .aksrt-report-modal, .aksrt-after-sale-apply-modal) .form-upload .preview .preview-item img { - width: 100%; - height: 100%; - object-fit: cover; - display: block; -} - -:is(.order-comment-from, .aksrt-report-modal, .aksrt-after-sale-apply-modal) .form-upload .preview .preview-item .remove { - position: absolute; - top: 2px; - right: 2px; - width: 20px; - height: 20px; - border: 0; - border-radius: 999px; - background: rgba(0, 0, 0, 0.55); - color: #fff; - font-size: 12px; - line-height: 1; - cursor: pointer; -} - -:is(.order-comment-from, .aksrt-report-modal, .aksrt-after-sale-apply-modal) .form-upload .hide { - display: none !important; -} - -.order-comment-from .review-error { - color: #ef4444; - font-size: 13px; - margin-top: 8px; -} - -@media (max-width: 768px) { - .aksrt-modal-runtime:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog { - width: calc(100vw - 20px); - } -} - -/* ---- 售后弹窗(申请 / 售后详细 / 超时效提示,参考主题 modal-mini 几何 1:1)---- - 三个弹窗与禁封申诉弹窗同几何:data_class='modal-mini'(宽 400px),圆角与 - 底色走 --main-radius / --main-bg-color;移动端由 --bottom 底部抽屉接管。 */ -.aksrt-after-sale-apply-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog, -.aksrt-after-sale-detail-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog, -.aksrt-after-sale-expired-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog { - width: min(400px, calc(100vw - 32px)); - border-radius: var(--main-radius, 8px); - background: var(--main-bg-color, #fff); -} - -.aksrt-after-sale-apply-modal .aksrt-modal-runtime__skeleton, -.aksrt-after-sale-detail-modal .aksrt-modal-runtime__skeleton, -.aksrt-after-sale-expired-modal .aksrt-modal-runtime__skeleton { - padding: 24px 18px; -} - -/* 蓝色炫彩标题区(参考主题 zib_get_modal_colorful_header('jb-blue', ...)):120px - 绝对定位横幅 + 旋转彩斑,后续内容整体下移 120px,与禁封记录弹窗同构。 */ -.aksrt-after-sale-apply-modal .modal-colorful-header, -.aksrt-after-sale-detail-modal .modal-colorful-header, -.aksrt-after-sale-expired-modal .modal-colorful-header, -.aksrt-order-detail-modal .modal-colorful-header, -.aksrt-after-sale-apply-modal .colorful-bg, -.aksrt-after-sale-detail-modal .colorful-bg, -.aksrt-after-sale-expired-modal .colorful-bg, -.aksrt-order-detail-modal .colorful-bg { - position: relative; - overflow: hidden; - color: var(--this-color); - background: var(--this-bg); - background-size: 120%; -} - -.aksrt-after-sale-apply-modal .modal-colorful-header, -.aksrt-after-sale-detail-modal .modal-colorful-header, -.aksrt-after-sale-expired-modal .modal-colorful-header, -.aksrt-order-detail-modal .modal-colorful-header { - position: absolute; - top: 0; - right: 0; - left: 0; - display: flex; - align-items: center; - justify-content: center; - height: 120px; - border-radius: var(--main-radius, 8px) var(--main-radius, 8px) 0 0; -} - -.aksrt-after-sale-apply-modal .modal-colorful-header + *, -.aksrt-after-sale-detail-modal .modal-colorful-header + *, -.aksrt-after-sale-expired-modal .modal-colorful-header + *, -.aksrt-order-detail-modal .modal-colorful-header + * { - margin-top: 120px; -} - -.aksrt-after-sale-apply-modal .modal-colorful-header > .close, -.aksrt-after-sale-detail-modal .modal-colorful-header > .close, -.aksrt-after-sale-expired-modal .modal-colorful-header > .close, -.aksrt-order-detail-modal .modal-colorful-header > .close { - position: absolute; - top: 10px; - right: 10px; - z-index: 9; - color: var(--this-color); - font-size: 18px; - opacity: 0.6; -} - -.aksrt-after-sale-apply-modal .colorful-bg > .colorful-make, -.aksrt-after-sale-apply-modal .colorful-bg > .colorful-make::before, -.aksrt-after-sale-apply-modal .colorful-bg > .colorful-make::after, -.aksrt-after-sale-detail-modal .colorful-bg > .colorful-make, -.aksrt-after-sale-detail-modal .colorful-bg > .colorful-make::before, -.aksrt-after-sale-detail-modal .colorful-bg > .colorful-make::after, -.aksrt-after-sale-expired-modal .colorful-bg > .colorful-make, -.aksrt-order-detail-modal .colorful-bg > .colorful-make, -.aksrt-after-sale-expired-modal .colorful-bg > .colorful-make::before, -.aksrt-order-detail-modal .colorful-bg > .colorful-make::before, -.aksrt-after-sale-expired-modal .colorful-bg > .colorful-make::after, -.aksrt-order-detail-modal .colorful-bg > .colorful-make::after { - position: absolute; - color: var(--this-color); - background: var(--this-bg); - border-radius: 100%; - content: ''; -} - -.aksrt-after-sale-apply-modal .colorful-bg > .colorful-make, -.aksrt-after-sale-detail-modal .colorful-bg > .colorful-make, -.aksrt-after-sale-expired-modal .colorful-bg > .colorful-make, -.aksrt-order-detail-modal .colorful-bg > .colorful-make { - top: 60%; - bottom: 0; - width: 323px; - height: 290px; - animation: aksrt-ban-colorful 120s linear infinite; -} - -.aksrt-after-sale-apply-modal .colorful-bg > .colorful-make::before, -.aksrt-after-sale-detail-modal .colorful-bg > .colorful-make::before, -.aksrt-after-sale-expired-modal .colorful-bg > .colorful-make::before, -.aksrt-order-detail-modal .colorful-bg > .colorful-make::before { - top: -325px; - right: -558px; - bottom: 0; - width: 677px; - height: 300px; - opacity: 0.6; - transform: rotate(341deg); -} - -.aksrt-after-sale-apply-modal .colorful-bg > .colorful-make::after, -.aksrt-after-sale-detail-modal .colorful-bg > .colorful-make::after, -.aksrt-after-sale-expired-modal .colorful-bg > .colorful-make::after, -.aksrt-order-detail-modal .colorful-bg > .colorful-make::after { - top: -170px; - bottom: 0; - left: -262px; - width: 402px; - height: 214px; - opacity: 0.4; - transform: rotate(326deg); -} - -/* 售后原因徽章:与举报弹窗一致,可换行且间距走 mr6/mb6 工具类。 */ -.aksrt-after-sale-apply-modal .form-but-radio .badg { - cursor: pointer; - user-select: none; -} - -/* 订单摘要与售后明细行 */ -.aksrt-after-sale-summary__row, -.aksrt-after-sale-detail__row { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 12px; - margin-bottom: 6px; -} - -.aksrt-after-sale-summary__row:last-child, -.aksrt-after-sale-detail__row:last-child { - margin-bottom: 0; -} - -.aksrt-after-sale-summary__value, -.aksrt-after-sale-detail__value { - min-width: 0; - text-align: right; - word-break: break-all; -} - -.aksrt-after-sale-detail__statusRow { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 10px; -} - -.aksrt-after-sale-reason-text { - color: var(--color-ink-2, #4e5358); - background: var(--surface-2, #eee); - border-radius: 6px; - padding: 10px 12px; -} - -.aksrt-after-sale-images { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.aksrt-after-sale-images img { - width: 72px; - height: 72px; - object-fit: cover; - border-radius: 6px; - border: 1px solid var(--color-line, #e5e7eb); - display: block; -} - -.aksrt-after-sale-status.is-pending { color: var(--color-warning, #f59e0b); } -.aksrt-after-sale-status.is-processing { color: var(--color-info, #3b82f6); } -.aksrt-after-sale-status.is-approved, -.aksrt-after-sale-status.is-completed { color: var(--color-success, #10b981); } -.aksrt-after-sale-status.is-rejected, -.aksrt-after-sale-status.is-cancelled { color: var(--color-error, #ef4444); } +/** + * Modal runtime + ajax list primitives (shared). + * + * @package AKSRT + */ + +/* ---- portal token bridge ---- + * + * 弹窗由 runtime portal 到 ,因此它位于任何「页面级 token 作用域」之外。 + * 典型陷阱:路由样式把语义别名挂在页面上,例如 + * + * .aksrt-orders-page { --orders-ink: var(--color-ink, #333); } + * + * 弹窗内部再去 `var(--orders-ink)` 时该变量不存在,整条声明会被静默丢弃—— + * 表现为边框消失、次级文字变透明,而不是抛错,排查成本很高。 + * + * 因此共享调色板在这里声明:本壳层永远在 portal 内,任何弹窗都能直接消费 + * `--modal-*`。路由样式应把自己的局部别名映射到这里,而不是映射到页面级变量。 + */ +.aksrt-modal-runtime { + --modal-ink: var(--color-ink, #333333); + --modal-ink-2: var(--color-ink-2, #4e5358); + --modal-muted: var(--color-ink-3, #777777); + --modal-line: var(--color-line-stronger, rgba(50, 50, 50, 0.12)); + --modal-line-soft: var(--color-line, rgba(50, 50, 50, 0.06)); + --modal-surface: var(--surface-1, #ffffff); + --modal-surface-2: var(--surface-2, #eeeeee); + --modal-surface-3: var(--surface-4, #f8f8f8); + --modal-primary: var(--color-primary, #f04494); + --modal-on-primary: var(--color-on-primary, #ffffff); + --modal-warning: var(--color-warning, #f59e0b); + --modal-success: var(--color-success, #10b981); + --modal-danger: var(--color-danger, #ef4444); +} + +/* ---- scroll lock ---- */ +html.aksrt-modal-runtime-open, +body.aksrt-modal-runtime-open { + overflow: hidden; +} + +/* ---- 对话框宽度类不得作用于壳 ---- + 触发器沿用参考主题的 data-class 语义(modal-mini/full-sm 等), + 但这些类落在 fixed 全屏壳上时,.modal-mini 的 width:400px + + auto 边距会把壳压成居中单列,蒙版随之只覆盖一列。 */ +.aksrt-modal-runtime.modal-mini, +.aksrt-modal-runtime.full-sm { + width: auto; + max-width: none; + margin: 0; +} + +/* ---- modal runtime shell ---- */ +.aksrt-modal-runtime { + position: fixed; + inset: 0; + z-index: 100050; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + visibility: hidden; + transition: opacity 0.2s ease, visibility 0.2s ease; + pointer-events: none; +} + +.aksrt-modal-runtime.is-open { + opacity: 1; + visibility: visible; + pointer-events: auto; +} + +.aksrt-modal-runtime__overlay { + position: absolute; + inset: 0; + /* 参考主题 .modal-backdrop 同款:纯黑 50%,无模糊、无色调偏移 */ + background: rgba(0, 0, 0, 0.5); +} + +.aksrt-modal-runtime__dialog { + position: relative; + z-index: 1; + width: min(440px, calc(100vw - 32px)); + max-height: min(88vh, 720px); + overflow: auto; + background: var(--surface-1, #fff); + border-radius: 16px; + box-shadow: 0 18px 48px rgba(15, 18, 28, 0.22); + transform: translateY(12px) scale(0.98); + transition: transform 0.22s cubic-bezier(0.22, 1, 0.36, 1); + color: var(--color-ink, #1f2937); +} + +.aksrt-modal-runtime.is-open .aksrt-modal-runtime__dialog { + transform: translateY(0) scale(1); +} + +.aksrt-modal-runtime--bottom { + align-items: flex-end; +} + +.aksrt-modal-runtime--bottom .aksrt-modal-runtime__dialog { + width: 100%; + max-width: 100%; + border-radius: 16px 16px 0 0; + max-height: 90vh; + transform: translateY(24px); +} + +.aksrt-modal-runtime--bottom.is-open .aksrt-modal-runtime__dialog { + transform: translateY(0); +} + +.aksrt-modal-runtime__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 16px 18px 0; +} + +.aksrt-modal-runtime__title { + margin: 0; + font-size: 17px; + line-height: 1.35; +} + +.aksrt-modal-runtime__content { + padding: 18px 18px 16px; +} + +.aksrt-modal-runtime__loading, +.aksrt-modal-runtime__error-body { + padding: 28px 8px; + text-align: center; + color: var(--color-ink-2, #6b7280); + font-size: 14px; +} + +.aksrt-modal-runtime__skeleton { + display: grid; + gap: 12px; + padding: 8px 2px; +} + +.aksrt-modal-runtime__skeleton-line, +.aksrt-modal-runtime__skeleton-block { + display: block; + border-radius: 8px; + background: linear-gradient(90deg, rgba(148, 163, 184, 0.16), rgba(148, 163, 184, 0.3), rgba(148, 163, 184, 0.16)); + background-size: 200% 100%; + animation: aksrt-modal-skeleton 1.2s ease-in-out infinite; +} + +.aksrt-modal-runtime__skeleton-line { + width: 72%; + height: 12px; +} + +.aksrt-modal-runtime__skeleton-line.is-wide { + width: 92%; + height: 18px; +} + +.aksrt-modal-runtime__skeleton-line.is-short { + width: 44%; +} + +.aksrt-modal-runtime__skeleton-block { + width: 100%; + min-height: 84px; +} + +.aksrt-modal-runtime__retry { + cursor: pointer; +} + +@keyframes aksrt-modal-skeleton { + 0% { background-position: 100% 0; } + 100% { background-position: -100% 0; } +} + +.aksrt-modal-runtime .touch.border-title, +.aksrt-modal-runtime .border-title { + font-size: 16px; + font-weight: 700; + margin: 0 0 14px; + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 6px; +} + +.aksrt-modal-runtime .aksrt-modal-runtime__close { + position: absolute; + top: 10px; + right: 10px; + width: 32px; + height: 32px; + border: 0; + border-radius: 999px; + background: transparent; + color: var(--color-ink-3, #9ca3af); + cursor: pointer; + font-size: 18px; + line-height: 1; +} + +.aksrt-modal-runtime .but-average.modal-buts, +.aksrt-modal-runtime .modal-buts { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin-top: 16px; +} + +.aksrt-modal-runtime .but-average.modal-buts .but, +.aksrt-modal-runtime .modal-buts .but, +.aksrt-modal-runtime .modal-buts button { + min-height: 40px; + border-radius: 10px; + border: 1px solid var(--color-line, #e5e7eb); + background: var(--surface-4, #f3f4f6); + color: var(--color-ink, #111); + font-weight: 600; + cursor: pointer; +} + +.aksrt-modal-runtime .modal-buts .but.c-blue, +.aksrt-modal-runtime .modal-buts button.c-blue, +.aksrt-modal-runtime .modal-buts .btn-primary { + background: var(--color-primary, #f04494); + border-color: color-mix(in srgb, var(--color-primary, #f04494) 70%, #000 0%); + color: var(--color-on-primary, #fff); +} + +/* ---- 签到详情弹窗(参考主题 refresh_modal modal-mini 同构壳) ---- + 内容为服务端 HTML(modal-colorful-header + modal-body),与参考主题 + .modal-dialog.modal-mini 几何 1:1:宽 400px、圆角 var(--main-radius)。 */ +.aksrt-modal-runtime.aksrt-checkin-details-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog { + width: min(400px, calc(100vw - 32px)); + border-radius: var(--main-radius, 8px); + background: var(--main-bg-color, #fff); +} + +.aksrt-modal-runtime.aksrt-checkin-details-modal .aksrt-modal-runtime__content { + padding: 0; +} + +.aksrt-modal-runtime.aksrt-checkin-details-modal .aksrt-modal-runtime__skeleton { + padding: 24px 18px; +} + +/* ---- ajax list ---- */ +.ajaxpager.is-ajax-loading { + position: relative; + min-height: 80px; +} + +/* 骨架默认收起,由 JS 加 .is-loading 展开(参考主题 post_ajax 的 beforeSend + remove + fadeIn / success .hide() 语义等价物)。 + 这里不依赖 .ajaxpager 祖先:首页 tab pane、归档流、评论列表的 loader 层级各不 + 相同,而且并非每个 loader 都在 bootstrap 的 .tab-pane 里(bootstrap 只负责 + 隐藏 tab-pane 内的骨架),统一按类名自身控制。 */ +.post_ajax_loader { + display: none; +} + +.post_ajax_loader.is-loading { + display: block; + animation: aksrtFadeIn 0.2s ease both; +} + +.ajax-item.is-fading { + opacity: 0.35; + transition: opacity 0.15s ease; +} + +/* ---- 举报弹窗(参考主题报告弹窗几何 1:1)---- + 宽度对齐参考主题 Bootstrap 默认模态(>=768px 600px),圆角/底色走 + --main-radius / --main-bg-color;移动端仍由 --bottom 底部抽屉接管。 */ +.aksrt-report-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog { + width: min(600px, calc(100vw - 32px)); + border-radius: var(--main-radius, 8px); + background: var(--main-bg-color, #fff); +} + +/* edit-header:参考主题弹窗头(关闭钮靠右,红色警示徽标 + 标题居左)。 */ +.aksrt-report-modal .edit-header { + position: relative; + padding-right: 32px; +} + +.aksrt-report-modal .edit-header .close { + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + float: none; + font-size: 20px; + line-height: 1; + cursor: pointer; + background: none; + border: 0; + padding: 2px 6px; +} + +/* 原因徽章行:与参考主题一致,徽章可换行且间距走 mr6/mb6 工具类。 */ +.aksrt-report-modal .form-but-radio .badg { + cursor: pointer; + user-select: none; +} + +/* ---- 禁封用户弹窗(参考主题 set_user_ban 模态框几何 1:1)---- + 参考主题 zib_get_edit_user_ban_link 未传 data-class → 走 Bootstrap 默认 + .modal-dialog(>=768px 宽 600px);data-height=360 → .modal-content 初始 + 高度 360 后动画至内容高度;mobile-bottom → <769px 底部抽屉。 + 此处以 min-height:360px 复刻初始高度契约,桌面宽取 600px。 */ +.aksrt-user-ban-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog { + width: min(600px, calc(100vw - 32px)); + border-radius: var(--main-radius, 8px); + background: var(--main-bg-color, #fff); +} + +/* ---- 查看禁封状态 / 禁封申诉 / 禁封记录弹窗(参考主题 modal-mini 几何 1:1)---- + 参考主题三个链接均传 data_class='modal-mini'(宽 400px)、data-height=240。 */ +.aksrt-user-ban-info-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog, +.aksrt-user-ban-appeal-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog, +.aksrt-user-ban-log-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog { + width: min(400px, calc(100vw - 32px)); + border-radius: var(--main-radius, 8px); + background: var(--main-bg-color, #fff); +} + +.aksrt-user-ban-info-modal .aksrt-modal-runtime__skeleton, +.aksrt-user-ban-appeal-modal .aksrt-modal-runtime__skeleton, +.aksrt-user-ban-log-modal .aksrt-modal-runtime__skeleton { + padding: 24px 18px; +} + +.aksrt-user-ban-modal .aksrt-modal-runtime__skeleton { + padding: 24px 18px; +} + +/* edit-header:与禁封表单头部同构(关闭钮靠右,红色警示徽标 + 标题居左)。 */ +.aksrt-user-ban-modal .edit-header, +.aksrt-user-ban-appeal-modal .edit-header { + position: relative; + padding-right: 32px; +} + +.aksrt-user-ban-modal .edit-header .close, +.aksrt-user-ban-appeal-modal .edit-header .close { + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + float: none; + font-size: 20px; + line-height: 1; + cursor: pointer; + background: none; + border: 0; + padding: 2px 6px; +} + +/* 解封视图多彩横幅(参考主题 zib_get_modal_colorful_header('c-yellow', ...)): + 120px 高绝对定位横幅 + 旋转彩斑,后续表单整体下移 120px。 */ +.aksrt-user-ban-modal .modal-colorful-header, +.aksrt-user-ban-modal .colorful-bg, +.aksrt-user-ban-info-modal .modal-colorful-header, +.aksrt-user-ban-info-modal .colorful-bg, +.aksrt-user-ban-log-modal .modal-colorful-header, +.aksrt-user-ban-log-modal .colorful-bg { + position: relative; + overflow: hidden; + color: var(--this-color); + background: var(--this-bg); + background-size: 120%; +} + +.aksrt-user-ban-modal .modal-colorful-header, +.aksrt-user-ban-info-modal .modal-colorful-header, +.aksrt-user-ban-log-modal .modal-colorful-header { + position: absolute; + top: 0; + right: 0; + left: 0; + display: flex; + align-items: center; + justify-content: center; + height: 120px; + border-radius: var(--main-radius, 8px) var(--main-radius, 8px) 0 0; +} + +.aksrt-user-ban-modal .modal-colorful-header + *, +.aksrt-user-ban-info-modal .modal-colorful-header + *, +.aksrt-user-ban-log-modal .modal-colorful-header + * { + margin-top: 120px; +} + +.aksrt-user-ban-modal .modal-colorful-header > .close, +.aksrt-user-ban-info-modal .modal-colorful-header > .close, +.aksrt-user-ban-log-modal .modal-colorful-header > .close { + position: absolute; + top: 10px; + right: 10px; + z-index: 9; + color: var(--this-color); + font-size: 18px; + opacity: 0.6; +} + +.aksrt-user-ban-modal .colorful-bg > .colorful-make, +.aksrt-user-ban-modal .colorful-bg > .colorful-make::before, +.aksrt-user-ban-modal .colorful-bg > .colorful-make::after, +.aksrt-user-ban-info-modal .colorful-bg > .colorful-make, +.aksrt-user-ban-info-modal .colorful-bg > .colorful-make::before, +.aksrt-user-ban-info-modal .colorful-bg > .colorful-make::after, +.aksrt-user-ban-log-modal .colorful-bg > .colorful-make, +.aksrt-user-ban-log-modal .colorful-bg > .colorful-make::before, +.aksrt-user-ban-log-modal .colorful-bg > .colorful-make::after { + position: absolute; + color: var(--this-color); + background: var(--this-bg); + border-radius: 100%; + content: ''; +} + +.aksrt-user-ban-modal .colorful-bg > .colorful-make, +.aksrt-user-ban-info-modal .colorful-bg > .colorful-make, +.aksrt-user-ban-log-modal .colorful-bg > .colorful-make { + top: 60%; + bottom: 0; + width: 323px; + height: 290px; + animation: aksrt-ban-colorful 120s linear infinite; +} + +.aksrt-user-ban-modal .colorful-bg > .colorful-make::before, +.aksrt-user-ban-info-modal .colorful-bg > .colorful-make::before, +.aksrt-user-ban-log-modal .colorful-bg > .colorful-make::before { + top: -325px; + right: -558px; + bottom: 0; + width: 677px; + height: 300px; + opacity: 0.6; + transform: rotate(341deg); +} + +.aksrt-user-ban-modal .colorful-bg > .colorful-make::after, +.aksrt-user-ban-info-modal .colorful-bg > .colorful-make::after, +.aksrt-user-ban-log-modal .colorful-bg > .colorful-make::after { + top: -170px; + bottom: 0; + left: -262px; + width: 402px; + height: 214px; + opacity: 0.4; + transform: rotate(326deg); +} + +.aksrt-user-ban-modal .colorful-bg > .colorful-make + *, +.aksrt-user-ban-info-modal .colorful-bg > .colorful-make + *, +.aksrt-user-ban-log-modal .colorful-bg > .colorful-make + * { + position: relative; +} + +/* 单选徽章行(参考主题 .form-but-radio 契约):input 由 main.css 隐藏, + label 可点;选中态由 main.css 的 input:checked + .but-radio 驱动。 */ +.aksrt-user-ban-modal .form-but-radio label { + cursor: pointer; +} + +.aksrt-user-ban-modal .form-but-radio .but-radio { + user-select: none; +} + +/* 开关行(删除全部内容 / 禁止用户申诉):原生 checkbox 隐藏, + 视觉态由 main.css 的 input:checked ~ .form-switch 驱动。 */ +.aksrt-user-ban-modal .aksrt-user-ban-form label { + user-select: none; +} + +@keyframes aksrt-ban-colorful { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* ---- order review modal content ---- */ +.order-comment-from .shop-score-box { + display: flex; + align-items: center; + gap: 8px; + min-height: 36px; +} + +.order-comment-from .score-title { + width: 4.5em; + flex: 0 0 auto; + font-size: 13px; + color: var(--color-ink-2, #6b7280); +} + +.order-comment-from .score-icon { + display: inline-flex; + gap: 4px; + align-items: center; + height: 30px; +} + +.order-comment-from .icon-star { + display: inline-flex; + width: 22px; + height: 22px; + color: #d1d5db; + cursor: pointer; + transition: color 0.12s ease, transform 0.12s ease; +} + +.order-comment-from .icon-star svg { + width: 22px; + height: 22px; + fill: currentColor; +} + +.order-comment-from .icon-star.active, +.order-comment-from .icon-star.hover { + color: #fc7049; +} + +.order-comment-from .icon-star.hover { + transform: scale(1.08); +} + +.order-comment-from .score-text { + min-width: 3em; + font-size: 13px; + color: var(--color-ink-2, #6b7280); +} + +.order-comment-from textarea.form-control { + width: 100%; + border: 1px solid var(--color-line, #e5e7eb); + border-radius: 10px; + padding: 10px 12px; + font-size: 14px; + resize: vertical; + min-height: 96px; + background: var(--surface-1, #fff); + color: var(--color-ink, #111); +} + +:is(.order-comment-from, .aksrt-report-modal, .aksrt-after-sale-apply-modal) .form-upload .preview { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +:is(.order-comment-from, .aksrt-report-modal) .form-upload .preview .add, +:is(.order-comment-from, .aksrt-report-modal, .aksrt-after-sale-apply-modal) .form-upload .preview .add, +:is(.order-comment-from, .aksrt-report-modal, .aksrt-after-sale-apply-modal) .form-upload .preview .preview-item { + width: 80px; + height: 80px; + border-radius: 10px; + border: 1px dashed var(--color-line, #d1d5db); + background: var(--surface-4, #f9fafb); + position: relative; + overflow: hidden; + cursor: pointer; +} + +/* 加号图标不在此重复定义:main.css 已有参考主题原生实现 + (.preview .add:before/:after —— 25px 圆角杠,inset:0 + margin:auto 居中)。 + 此前这里的 left/top:50% + translate 版本与该规则叠加导致过约束, + 加号被推到 75%/75% 只剩“L”形,故整体删除、统一走主样式包。 */ + +:is(.order-comment-from, .aksrt-report-modal, .aksrt-after-sale-apply-modal) .form-upload .preview .preview-item img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +:is(.order-comment-from, .aksrt-report-modal, .aksrt-after-sale-apply-modal) .form-upload .preview .preview-item .remove { + position: absolute; + top: 2px; + right: 2px; + width: 20px; + height: 20px; + border: 0; + border-radius: 999px; + background: rgba(0, 0, 0, 0.55); + color: #fff; + font-size: 12px; + line-height: 1; + cursor: pointer; +} + +:is(.order-comment-from, .aksrt-report-modal, .aksrt-after-sale-apply-modal) .form-upload .hide { + display: none !important; +} + +.order-comment-from .review-error { + color: #ef4444; + font-size: 13px; + margin-top: 8px; +} + +@media (max-width: 768px) { + .aksrt-modal-runtime:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog { + width: calc(100vw - 20px); + } +} + +/* ---- 售后弹窗(申请 / 售后详细 / 超时效提示,参考主题 modal-mini 几何 1:1)---- + 三个弹窗与禁封申诉弹窗同几何:data_class='modal-mini'(宽 400px),圆角与 + 底色走 --main-radius / --main-bg-color;移动端由 --bottom 底部抽屉接管。 */ +.aksrt-after-sale-apply-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog, +.aksrt-after-sale-detail-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog, +.aksrt-after-sale-expired-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog { + width: min(400px, calc(100vw - 32px)); + border-radius: var(--main-radius, 8px); + background: var(--main-bg-color, #fff); +} + +.aksrt-after-sale-apply-modal .aksrt-modal-runtime__skeleton, +.aksrt-after-sale-detail-modal .aksrt-modal-runtime__skeleton, +.aksrt-after-sale-expired-modal .aksrt-modal-runtime__skeleton { + padding: 24px 18px; +} + +/* 蓝色炫彩标题区(参考主题 zib_get_modal_colorful_header('jb-blue', ...)):120px + 绝对定位横幅 + 旋转彩斑,后续内容整体下移 120px,与禁封记录弹窗同构。 */ +.aksrt-after-sale-apply-modal .modal-colorful-header, +.aksrt-after-sale-detail-modal .modal-colorful-header, +.aksrt-after-sale-expired-modal .modal-colorful-header, +.aksrt-order-detail-modal .modal-colorful-header, +.aksrt-after-sale-apply-modal .colorful-bg, +.aksrt-after-sale-detail-modal .colorful-bg, +.aksrt-after-sale-expired-modal .colorful-bg, +.aksrt-order-detail-modal .colorful-bg { + position: relative; + overflow: hidden; + color: var(--this-color); + background: var(--this-bg); + background-size: 120%; +} + +.aksrt-after-sale-apply-modal .modal-colorful-header, +.aksrt-after-sale-detail-modal .modal-colorful-header, +.aksrt-after-sale-expired-modal .modal-colorful-header, +.aksrt-order-detail-modal .modal-colorful-header { + position: absolute; + top: 0; + right: 0; + left: 0; + display: flex; + align-items: center; + justify-content: center; + height: 120px; + border-radius: var(--main-radius, 8px) var(--main-radius, 8px) 0 0; +} + +.aksrt-after-sale-apply-modal .modal-colorful-header + *, +.aksrt-after-sale-detail-modal .modal-colorful-header + *, +.aksrt-after-sale-expired-modal .modal-colorful-header + *, +.aksrt-order-detail-modal .modal-colorful-header + * { + margin-top: 120px; +} + +.aksrt-after-sale-apply-modal .modal-colorful-header > .close, +.aksrt-after-sale-detail-modal .modal-colorful-header > .close, +.aksrt-after-sale-expired-modal .modal-colorful-header > .close, +.aksrt-order-detail-modal .modal-colorful-header > .close { + position: absolute; + top: 10px; + right: 10px; + z-index: 9; + color: var(--this-color); + font-size: 18px; + opacity: 0.6; +} + +.aksrt-after-sale-apply-modal .colorful-bg > .colorful-make, +.aksrt-after-sale-apply-modal .colorful-bg > .colorful-make::before, +.aksrt-after-sale-apply-modal .colorful-bg > .colorful-make::after, +.aksrt-after-sale-detail-modal .colorful-bg > .colorful-make, +.aksrt-after-sale-detail-modal .colorful-bg > .colorful-make::before, +.aksrt-after-sale-detail-modal .colorful-bg > .colorful-make::after, +.aksrt-after-sale-expired-modal .colorful-bg > .colorful-make, +.aksrt-order-detail-modal .colorful-bg > .colorful-make, +.aksrt-after-sale-expired-modal .colorful-bg > .colorful-make::before, +.aksrt-order-detail-modal .colorful-bg > .colorful-make::before, +.aksrt-after-sale-expired-modal .colorful-bg > .colorful-make::after, +.aksrt-order-detail-modal .colorful-bg > .colorful-make::after { + position: absolute; + color: var(--this-color); + background: var(--this-bg); + border-radius: 100%; + content: ''; +} + +.aksrt-after-sale-apply-modal .colorful-bg > .colorful-make, +.aksrt-after-sale-detail-modal .colorful-bg > .colorful-make, +.aksrt-after-sale-expired-modal .colorful-bg > .colorful-make, +.aksrt-order-detail-modal .colorful-bg > .colorful-make { + top: 60%; + bottom: 0; + width: 323px; + height: 290px; + animation: aksrt-ban-colorful 120s linear infinite; +} + +.aksrt-after-sale-apply-modal .colorful-bg > .colorful-make::before, +.aksrt-after-sale-detail-modal .colorful-bg > .colorful-make::before, +.aksrt-after-sale-expired-modal .colorful-bg > .colorful-make::before, +.aksrt-order-detail-modal .colorful-bg > .colorful-make::before { + top: -325px; + right: -558px; + bottom: 0; + width: 677px; + height: 300px; + opacity: 0.6; + transform: rotate(341deg); +} + +.aksrt-after-sale-apply-modal .colorful-bg > .colorful-make::after, +.aksrt-after-sale-detail-modal .colorful-bg > .colorful-make::after, +.aksrt-after-sale-expired-modal .colorful-bg > .colorful-make::after, +.aksrt-order-detail-modal .colorful-bg > .colorful-make::after { + top: -170px; + bottom: 0; + left: -262px; + width: 402px; + height: 214px; + opacity: 0.4; + transform: rotate(326deg); +} + +/* 售后原因徽章:与举报弹窗一致,可换行且间距走 mr6/mb6 工具类。 */ +.aksrt-after-sale-apply-modal .form-but-radio .badg { + cursor: pointer; + user-select: none; +} + +/* 订单摘要与售后明细行 */ +.aksrt-after-sale-summary__row, +.aksrt-after-sale-detail__row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-bottom: 6px; +} + +.aksrt-after-sale-summary__row:last-child, +.aksrt-after-sale-detail__row:last-child { + margin-bottom: 0; +} + +.aksrt-after-sale-summary__value, +.aksrt-after-sale-detail__value { + min-width: 0; + text-align: right; + word-break: break-all; +} + +.aksrt-after-sale-detail__statusRow { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; +} + +.aksrt-after-sale-reason-text { + color: var(--color-ink-2, #4e5358); + background: var(--surface-2, #eee); + border-radius: 6px; + padding: 10px 12px; +} + +.aksrt-after-sale-images { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.aksrt-after-sale-images img { + width: 72px; + height: 72px; + object-fit: cover; + border-radius: 6px; + border: 1px solid var(--color-line, #e5e7eb); + display: block; +} + +.aksrt-after-sale-status.is-pending { color: var(--color-warning, #f59e0b); } +.aksrt-after-sale-status.is-processing { color: var(--color-info, #3b82f6); } +.aksrt-after-sale-status.is-approved, +.aksrt-after-sale-status.is-completed { color: var(--color-success, #10b981); } +.aksrt-after-sale-status.is-rejected, +.aksrt-after-sale-status.is-cancelled { color: var(--color-error, #ef4444); } diff --git a/assets/css/routes/orders.css b/assets/css/routes/orders.css index 4b3ac8f4..ce2833ff 100644 --- a/assets/css/routes/orders.css +++ b/assets/css/routes/orders.css @@ -1,204 +1,204 @@ -/* Knowledge-payment order center */ -.aksrt-orders-page { - --orders-ink: var(--color-ink, #1d2738); - --orders-muted: var(--color-ink-3, #758096); - --orders-line: color-mix(in srgb, var(--color-line, #e1e6ef) 88%, transparent); - padding: 0; - color: var(--orders-ink); -} - -.uc-orders-panel__head { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(285px, 42%); - gap: 28px; - align-items: end; - padding: 4px 0 22px; - border-bottom: 1px solid var(--orders-line); -} - -.aksrt-orders-page__eyebrow { - margin: 0 0 7px; - color: var(--color-primary, #3b82f6); - font-size: 11px; - font-weight: 750; - letter-spacing: .14em; - text-transform: uppercase; -} - -.uc-orders-panel__title { display: flex; align-items: center; gap: 9px; font-size: 21px; } -.uc-orders-panel__title-icon { color: var(--color-primary, #3b82f6); font-size: 17px; } -.uc-orders-panel__title-wrap > p:last-child { margin-bottom: 0; } - -.aksrt-orders-page__stats { - display: grid; - grid-template-columns: repeat(3, 1fr); - border: 1px solid var(--orders-line); - border-radius: 14px; - background: var(--surface-1, #fff); - overflow: hidden; -} -.aksrt-orders-page__stat { display: grid; gap: 5px; padding: 13px 15px; border-right: 1px solid var(--orders-line); } -.aksrt-orders-page__stat:last-child { border-right: 0; } -.aksrt-orders-page__stat span { color: var(--orders-muted); font-size: 11px; font-weight: 600; } -.aksrt-orders-page__stat strong { font-size: 21px; line-height: 1; font-variant-numeric: tabular-nums; } -.aksrt-orders-page__stat strong.is-pending { color: var(--color-warning, #c87819); } -.aksrt-orders-page__stat strong.is-paid { color: var(--color-success, #168560); } - -.aksrt-orders-page__search { display: flex; gap: 8px; align-items: center; margin: 20px 0 12px; padding: 7px; border: 1px solid var(--orders-line); border-radius: 12px; background: var(--surface-1, #fff); } -.aksrt-orders-page__search > i { margin-left: 7px; color: var(--orders-muted); font-size: 13px; } -.aksrt-orders-page__search input { flex: 1; min-width: 0; border: 0; outline: 0; background: transparent; color: var(--orders-ink); font-size: 13px; } -.aksrt-orders-page__search button, .aksrt-orders-page__card-actions .btn-primary, .aksrt-orders-page__card-actions .btn-outline, .aksrt-orders-page__card-actions .btn-ghost, .aksrt-orders-page__empty .btn-outline { display: inline-flex; align-items: center; justify-content: center; min-height: 34px; padding: 0 13px; border-radius: 8px; border: 1px solid transparent; font-size: 12px; font-weight: 650; text-decoration: none; cursor: pointer; } -.aksrt-orders-page .btn-primary { background: var(--color-primary, #3b82f6); color: var(--color-on-primary, #fff); } -.aksrt-orders-page .btn-outline { border-color: var(--orders-line); background: var(--surface-1, #fff); color: var(--orders-ink); } -.aksrt-orders-page .btn-ghost { background: transparent; color: var(--orders-muted); } -.aksrt-orders-page__filter-toggle { background: var(--surface-2, #f4f6f9); color: var(--orders-ink); white-space: nowrap; } - -.aksrt-orders-page__filter-panel { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: -2px 0 14px; padding: 12px; border-radius: 10px; background: var(--surface-2, #f4f6f9); } -.aksrt-orders-page__filter-panel[hidden] { display: none !important; } -.aksrt-orders-page__filter-group { display: flex; flex: 1 1 100%; gap: 10px; align-items: center; } -.aksrt-orders-page__filter-label { flex: 0 0 58px; color: var(--orders-muted); font-size: 12px; font-weight: 650; } -.aksrt-orders-page__status-filters, .aksrt-orders-page__type-filters { display: flex; flex-wrap: wrap; gap: 6px; } -.aksrt-orders-page__filter-chip { display: inline-flex; gap: 5px; align-items: center; padding: 5px 9px; border: 1px solid var(--orders-line); border-radius: 999px; background: var(--surface-1, #fff); color: var(--orders-muted); font: inherit; font-size: 12px; text-decoration: none; cursor: pointer; } -.aksrt-orders-page__filter-chip.is-active { border-color: var(--color-primary, #3b82f6); background: color-mix(in srgb, var(--color-primary, #3b82f6) 10%, var(--surface-1, #fff)); color: var(--color-primary, #3b82f6); font-weight: 700; } -.aksrt-orders-page__filter-count { display: inline-grid; min-width: 17px; height: 17px; padding: 0 4px; place-items: center; border-radius: 999px; background: var(--color-warning, #c87819); color: #fff; font-size: 10px; line-height: 1; } -.aksrt-orders-page__clear { margin-left: auto; padding: 0; border: 0; background: transparent; color: var(--color-primary, #3b82f6); font: inherit; font-size: 12px; text-decoration: none; cursor: pointer; } - -.aksrt-orders-page__list { display: grid; gap: 10px; } -.aksrt-orders-page__card { overflow: hidden; border: 1px solid var(--orders-line); border-radius: 13px; background: var(--surface-1, #fff); transition: border-color .18s ease, transform .18s ease, box-shadow .18s ease; } -.aksrt-orders-page__card:hover { border-color: color-mix(in srgb, var(--color-primary, #3b82f6) 36%, var(--orders-line)); box-shadow: 0 8px 22px rgba(30, 48, 79, .07); transform: translateY(-1px); } -.aksrt-orders-page__card-rail { display: flex; gap: 10px; align-items: center; padding: 9px 14px; background: var(--surface-2, #f7f8fb); color: var(--orders-muted); font-size: 11px; } -.aksrt-orders-page__card-rail time { margin-left: auto; font-variant-numeric: tabular-nums; } -.aksrt-orders-page__card-status { font-weight: 700; } -.aksrt-orders-page__card-status.is-pending { color: var(--color-warning, #c87819); } -.aksrt-orders-page__card-status.is-paid { color: var(--color-success, #168560); } -.aksrt-orders-page__card-main { display: grid; grid-template-columns: 52px minmax(0, 1fr) auto; gap: 13px; align-items: center; padding: 14px; } -.aksrt-orders-page__card-img, .aksrt-orders-page__card-art { width: 52px; height: 52px; border-radius: 10px; object-fit: cover; } -.aksrt-orders-page__card-art { display: grid; place-items: center; color: var(--color-primary, #3b82f6); background: color-mix(in srgb, var(--color-primary, #3b82f6) 10%, var(--surface-2, #f4f6f9)); } -.aksrt-orders-page__card-copy { min-width: 0; } -.aksrt-orders-page__card-copy h3 { display: -webkit-box; margin: 0 0 5px; overflow: hidden; color: var(--orders-ink); font-size: 14px; line-height: 1.35; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } -.aksrt-orders-page__card-copy p { margin: 0; color: var(--orders-muted); font-family: var(--font-mono, monospace); font-size: 11px; } -.aksrt-orders-page__card-value { display: grid; gap: 3px; text-align: right; } -.aksrt-orders-page__card-value strong { color: var(--orders-ink); font-size: 15px; font-variant-numeric: tabular-nums; } -.aksrt-orders-page__card-value span { color: var(--orders-muted); font-size: 11px; } -.aksrt-orders-page__card-actions { display: flex; gap: 7px; align-items: center; justify-content: flex-end; padding: 9px 14px; border-top: 1px solid var(--orders-line); } -.aksrt-orders-page__countdown { margin-right: auto; color: var(--color-warning, #c87819); font-size: 11px; font-variant-numeric: tabular-nums; } - -.aksrt-orders-page__pagination { display: flex; justify-content: center; gap: 6px; margin-top: 20px; } -.aksrt-orders-page__page-chip { display: grid; width: 31px; height: 31px; padding: 0; place-items: center; border: 1px solid var(--orders-line); border-radius: 8px; background: var(--surface-1, #fff); color: var(--orders-muted); font: inherit; font-size: 12px; text-decoration: none; cursor: pointer; } -.aksrt-orders-page__page-chip.is-active { border-color: var(--color-primary, #3b82f6); background: var(--color-primary, #3b82f6); color: #fff; } -.aksrt-orders-page [data-aksrt-order-list], .aksrt-orders-page__stats, .aksrt-orders-page__pagination { transition: opacity .16s ease; } -.aksrt-orders-page.is-loading [data-aksrt-order-list], .aksrt-orders-page.is-loading .aksrt-orders-page__stats, .aksrt-orders-page.is-loading .aksrt-orders-page__pagination { opacity: .55; pointer-events: none; } -.aksrt-orders-page__empty { padding: 52px 20px; border: 1px dashed var(--orders-line); border-radius: 13px; background: var(--surface-2, #f7f8fb); text-align: center; } -.aksrt-orders-page__empty i { display: block; margin-bottom: 12px; color: var(--color-primary, #3b82f6); font-size: 30px; } -.aksrt-orders-page__empty h3 { margin: 0 0 7px; font-size: 15px; }.aksrt-orders-page__empty p { margin: 0 0 16px; color: var(--orders-muted); font-size: 13px; } - -/* ---- Order detail modal (front-end order center) ---- */ -/* 弹窗 portal 到 ,读不到页面上的 `--orders-*` 别名,因此把本模块的局部 - 别名映射到 modal-runtime.css 声明的共享 portal 基座 `--modal-*`; - 路由文件不应再自行推导调色板值。 */ -.aksrt-order-detail-modal { - --detail-ink: var(--modal-ink); - --detail-muted: var(--modal-muted); - --detail-line: var(--modal-line); - --detail-surface: var(--modal-surface); - --detail-surface-2: var(--modal-surface-2); - --detail-surface-3: var(--modal-surface-3); - --detail-primary: var(--modal-primary); - --detail-warning: var(--modal-warning); - --detail-success: var(--modal-success); -} - -/* 订单详情弹窗:与售后弹窗统一为 modal-mini 几何(400px);标题区改用蓝色炫彩头,运行时不再渲染默认标题栏。 */ -.aksrt-order-detail-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog { - width: min(400px, calc(100vw - 32px)); - border-radius: var(--main-radius, 8px); - background: var(--main-bg-color, #fff); -} -.aksrt-order-detail-modal .aksrt-modal-runtime__content { display: flex; flex-direction: column; gap: 18px; padding: 16px 18px 0; } - -.aksrt-order-detail__skeleton { display: grid; gap: 12px; padding: 4px 0; } - -/* Status banner — a single local variable drives the whole colour scheme. */ -.aksrt-order-detail__hero { --hero-accent: var(--detail-primary); --hero-text: color-mix(in srgb, var(--hero-accent) 72%, var(--detail-ink)); display: flex; align-items: center; gap: 13px; padding: 15px; border: 1px solid color-mix(in srgb, var(--hero-accent) 20%, transparent); border-radius: 14px; background: color-mix(in srgb, var(--hero-accent) 12%, var(--detail-surface)); } -.aksrt-order-detail__hero.is-pending { --hero-accent: var(--detail-warning); } -.aksrt-order-detail__hero.is-paid { --hero-accent: var(--detail-success); } -.aksrt-order-detail__hero.is-refunded, .aksrt-order-detail__hero.is-closed { --hero-accent: var(--detail-muted); } -.aksrt-order-detail__hero-icon { display: grid; flex: 0 0 auto; width: 42px; height: 42px; place-items: center; border-radius: 12px; background: color-mix(in srgb, var(--hero-accent) 16%, transparent); color: var(--hero-text); font-size: 19px; } -.aksrt-order-detail__hero-copy { display: grid; flex: 1 1 auto; gap: 4px; min-width: 0; } -.aksrt-order-detail__status { color: var(--hero-text); font-size: 15px; font-weight: 750; line-height: 1.2; } -.aksrt-order-detail__desc { margin: 0; color: var(--detail-muted); font-size: 12px; line-height: 1.45; } -.aksrt-order-detail__countdown { color: var(--hero-text); font-size: 12px; font-variant-numeric: tabular-nums; } -.aksrt-order-detail__hero-amount { display: grid; flex: 0 0 auto; gap: 3px; justify-items: end; text-align: right; } -.aksrt-order-detail__hero-amount strong { color: var(--detail-ink); font-size: 21px; font-weight: 750; line-height: 1.1; letter-spacing: -.01em; font-variant-numeric: tabular-nums; } -.aksrt-order-detail__hero-amount span { color: var(--detail-muted); font-size: 11px; } - -.aksrt-order-detail__group { display: grid; gap: 10px; } -.aksrt-order-detail__group-title { display: flex; align-items: center; gap: 8px; margin: 0; color: var(--detail-ink); font-size: 13px; font-weight: 700; line-height: 1.3; } -.aksrt-order-detail__group-title::before { content: ""; width: 3px; height: 13px; border-radius: 2px; background: var(--detail-primary); } - -.aksrt-order-detail__product { display: grid; grid-template-columns: 54px minmax(0, 1fr); gap: 12px; align-items: center; padding: 12px; border: 1px solid var(--detail-line); border-radius: 12px; background: var(--detail-surface); } -.aksrt-order-detail__thumb { width: 54px; height: 54px; border-radius: 10px; object-fit: cover; } -.aksrt-order-detail__thumb--empty { display: grid; place-items: center; background: var(--detail-surface-2); color: var(--detail-primary); font-size: 19px; } -.aksrt-order-detail__product-copy { display: grid; gap: 6px; min-width: 0; } -.aksrt-order-detail__product-title { display: -webkit-box; overflow: hidden; color: var(--detail-ink); font-size: 14px; line-height: 1.45; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } -.aksrt-order-detail__product-meta { display: inline-flex; justify-self: start; padding: 2px 8px; border-radius: 999px; background: color-mix(in srgb, var(--detail-primary) 9%, var(--detail-surface-2)); color: var(--detail-primary); font-size: 11px; font-weight: 650; line-height: 1.5; } - -.aksrt-order-detail__list { display: grid; margin: 0; overflow: hidden; border: 1px solid var(--detail-line); border-radius: 12px; background: var(--detail-surface); } -.aksrt-order-detail__list > div { display: grid; grid-template-columns: 84px minmax(0, 1fr); align-items: center; gap: 12px; padding: 11px 13px; border-bottom: 1px solid var(--detail-line); } -.aksrt-order-detail__list > div:last-child { border-bottom: 0; } -.aksrt-order-detail__list dt { color: var(--detail-muted); font-size: 12.5px; } -.aksrt-order-detail__list dd { display: flex; align-items: center; justify-content: flex-end; gap: 8px; min-width: 0; margin: 0; color: var(--detail-ink); font-size: 13px; font-variant-numeric: tabular-nums; text-align: right; } -.aksrt-order-detail__value-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.aksrt-order-detail__value-text.is-mono { font-family: var(--font-mono, monospace); font-size: 12.5px; letter-spacing: .01em; } -.aksrt-order-detail__copy { display: inline-grid; flex: 0 0 auto; width: 24px; height: 24px; padding: 0; place-items: center; border: 1px solid var(--detail-line); border-radius: 7px; background: var(--detail-surface-3); color: var(--detail-muted); font-size: 11px; line-height: 1; cursor: pointer; transition: color .15s ease, border-color .15s ease; } -.aksrt-order-detail__copy:hover { border-color: color-mix(in srgb, var(--detail-primary) 45%, var(--detail-line)); color: var(--detail-primary); } -.aksrt-order-detail__copy.is-copied { border-color: color-mix(in srgb, var(--detail-success) 45%, var(--detail-line)); color: var(--detail-success); } - -.aksrt-order-detail__refresh-state { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 10px; padding: 12px 13px; border: 1px solid color-mix(in srgb, var(--detail-warning) 26%, transparent); border-radius: 12px; background: color-mix(in srgb, var(--detail-warning) 10%, var(--detail-surface-2)); color: var(--detail-ink); font-size: 12.5px; } -.aksrt-order-detail__refresh-state > i { color: var(--detail-warning); font-size: 15px; } -.aksrt-order-detail__refresh-state p { margin: 0; } -.aksrt-order-detail__refresh-state button { grid-column: 2; justify-self: start; } - -/* 底部操作区:按钮排布沿用主题 .but-average.modal-buts(禁封申诉弹窗同式),此处仅保留吸底定位与通栏背景。 - 主题 modal-buts 自带 margin-top:16px,吸底场景下用更高优先级清掉,避免内容与吸底条之间出现空隙。 */ -.aksrt-order-detail-modal .aksrt-order-detail__actions { position: sticky; bottom: 0; z-index: 2; margin: 0 -18px; padding: 13px 18px calc(13px + env(safe-area-inset-bottom)); border-top: 1px solid var(--detail-line); background: var(--detail-surface); } -.aksrt-order-detail-modal .aksrt-order-detail__actions.but-average.modal-buts { margin-top: 0; } - -/* Orders extensions */ -.aksrt-orders-page__extra-nav { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 12px; } -.aksrt-orders-page__extra-link { padding: 6px 12px; border-radius: 999px; background: var(--surface-2, #f3f4f6); color: inherit; text-decoration: none; font-size: 13px; } -.aksrt-orders-page__extra-link.is-active { background: var(--focus-color, var(--color-primary)); color: #fff; } -.aksrt-as-list { display: grid; gap: 12px; } -.aksrt-as-card { padding: 14px; border: 1px solid var(--main-border-color, #eee); border-radius: 10px; background: var(--main-bg-color, #fff); } -.aksrt-as-card__head { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 6px; } -.aksrt-as-card__status { margin-left: auto; color: var(--focus-color, #2f6df6); font-weight: 600; } -.aksrt-as-timeline { margin: 10px 0 0; padding-left: 18px; color: var(--muted-2-color, #888); font-size: 12px; } -.aksrt-as-card__actions { display: flex; gap: 8px; margin-top: 10px; } -.aksrt-coupon-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; } -.aksrt-coupon-card { padding: 12px; border: 1px dashed var(--focus-color, #2f6df6); border-radius: 10px; background: color-mix(in srgb, var(--focus-color, #2f6df6) 6%, #fff); } -.aksrt-coupon-card__value { color: var(--focus-color, #2f6df6); font-size: 20px; font-weight: 700; } -.aksrt-coupon-card__code { margin: 6px 0; font-family: ui-monospace, monospace; } -.aksrt-coupon-card__meta { margin-bottom: 4px; color: var(--muted-2-color, #888); font-size: 12px; } -.aksrt-orders-page__field { margin-bottom: 12px; } -.aksrt-orders-page__input, .aksrt-orders-page__select, .aksrt-orders-page__textarea { box-sizing: border-box; width: 100%; padding: 8px 10px; border: 1px solid var(--main-border-color, #ddd); border-radius: 6px; } -@media (max-width: 767px) { .uc-orders-panel__head { grid-template-columns: 1fr; gap: 17px; }.aksrt-orders-page__stats { max-width: none; }.aksrt-orders-page__search { flex-wrap: wrap; }.aksrt-orders-page__search input { min-width: 150px; }.aksrt-orders-page__search .btn-primary { margin-left: auto; }.aksrt-orders-page__filter-toggle { margin-left: auto; }.aksrt-orders-page__card-rail time { width: 100%; margin-left: 0; order: 3; }.aksrt-orders-page__card-main { grid-template-columns: 48px minmax(0, 1fr); }.aksrt-orders-page__card-img, .aksrt-orders-page__card-art { width: 48px; height: 48px; }.aksrt-orders-page__card-value { grid-column: 2; text-align: left; }.aksrt-orders-page__card-actions > :not(.aksrt-orders-page__countdown) { flex: 1 1 auto; }.aksrt-orders-page__countdown { width: 100%; }} -html.dark .aksrt-orders-page__stat, html.dark .aksrt-orders-page__card { background: var(--surface-1, #1a1d24); } html.dark .aksrt-orders-page__card-rail, html.dark .aksrt-orders-page__filter-panel { background: var(--surface-2, #12151b); } - -/* ---- 售后记录卡片(订单详情弹窗) ---- */ -.aksrt-order-detail__after-sales { display: grid; gap: 10px; margin: 0; padding: 0; list-style: none; } -.aksrt-order-detail__after-sale { --as-accent: var(--detail-muted); display: grid; gap: 10px; padding: 13px 14px; border: 1px solid var(--detail-line); border-left: 3px solid var(--as-accent); border-radius: 12px; background: var(--detail-surface); } -.aksrt-order-detail__after-sale.is-pending { --as-accent: var(--detail-warning); } -.aksrt-order-detail__after-sale.is-processing { --as-accent: var(--detail-primary); } -.aksrt-order-detail__after-sale.is-approved, .aksrt-order-detail__after-sale.is-completed { --as-accent: var(--detail-success); } -.aksrt-order-detail__after-sale.is-rejected, .aksrt-order-detail__after-sale.is-cancelled { --as-accent: var(--modal-danger, #ef4444); } -.aksrt-order-detail__after-sale-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; } -.aksrt-order-detail__after-sale-badge { padding: 3px 11px; border-radius: 999px; background: color-mix(in srgb, var(--as-accent) 12%, transparent); color: var(--as-accent); font-size: 12px; font-weight: 700; line-height: 1.5; } -.aksrt-order-detail__after-sale-head time { color: var(--detail-muted); font-size: 11.5px; font-variant-numeric: tabular-nums; } -.aksrt-order-detail__after-sale-reason { margin: 0; color: var(--detail-ink); font-size: 13px; line-height: 1.65; white-space: pre-wrap; word-break: break-word; } -.aksrt-order-detail__after-sale-meta { display: grid; margin: 0; overflow: hidden; border: 1px solid var(--detail-line); border-radius: 10px; background: var(--detail-surface-3); } -.aksrt-order-detail__after-sale-meta > div { display: grid; grid-template-columns: 76px minmax(0, 1fr); gap: 10px; padding: 9px 12px; border-bottom: 1px solid var(--detail-line); font-size: 12.5px; } -.aksrt-order-detail__after-sale-meta > div:last-child { border-bottom: 0; } -.aksrt-order-detail__after-sale-meta dt { color: var(--detail-muted); } -.aksrt-order-detail__after-sale-meta dd { margin: 0; color: var(--detail-ink); font-size: 12.5px; text-align: right; word-break: break-word; } -.aksrt-order-detail__after-sale-field { color: var(--detail-muted); } -.aksrt-order-detail__after-sale-foot { display: flex; flex-wrap: wrap; gap: 8px; } +/* Knowledge-payment order center */ +.aksrt-orders-page { + --orders-ink: var(--color-ink, #1d2738); + --orders-muted: var(--color-ink-3, #758096); + --orders-line: color-mix(in srgb, var(--color-line, #e1e6ef) 88%, transparent); + padding: 0; + color: var(--orders-ink); +} + +.uc-orders-panel__head { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(285px, 42%); + gap: 28px; + align-items: end; + padding: 4px 0 22px; + border-bottom: 1px solid var(--orders-line); +} + +.aksrt-orders-page__eyebrow { + margin: 0 0 7px; + color: var(--color-primary, #3b82f6); + font-size: 11px; + font-weight: 750; + letter-spacing: .14em; + text-transform: uppercase; +} + +.uc-orders-panel__title { display: flex; align-items: center; gap: 9px; font-size: 21px; } +.uc-orders-panel__title-icon { color: var(--color-primary, #3b82f6); font-size: 17px; } +.uc-orders-panel__title-wrap > p:last-child { margin-bottom: 0; } + +.aksrt-orders-page__stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + border: 1px solid var(--orders-line); + border-radius: 14px; + background: var(--surface-1, #fff); + overflow: hidden; +} +.aksrt-orders-page__stat { display: grid; gap: 5px; padding: 13px 15px; border-right: 1px solid var(--orders-line); } +.aksrt-orders-page__stat:last-child { border-right: 0; } +.aksrt-orders-page__stat span { color: var(--orders-muted); font-size: 11px; font-weight: 600; } +.aksrt-orders-page__stat strong { font-size: 21px; line-height: 1; font-variant-numeric: tabular-nums; } +.aksrt-orders-page__stat strong.is-pending { color: var(--color-warning, #c87819); } +.aksrt-orders-page__stat strong.is-paid { color: var(--color-success, #168560); } + +.aksrt-orders-page__search { display: flex; gap: 8px; align-items: center; margin: 20px 0 12px; padding: 7px; border: 1px solid var(--orders-line); border-radius: 12px; background: var(--surface-1, #fff); } +.aksrt-orders-page__search > i { margin-left: 7px; color: var(--orders-muted); font-size: 13px; } +.aksrt-orders-page__search input { flex: 1; min-width: 0; border: 0; outline: 0; background: transparent; color: var(--orders-ink); font-size: 13px; } +.aksrt-orders-page__search button, .aksrt-orders-page__card-actions .btn-primary, .aksrt-orders-page__card-actions .btn-outline, .aksrt-orders-page__card-actions .btn-ghost, .aksrt-orders-page__empty .btn-outline { display: inline-flex; align-items: center; justify-content: center; min-height: 34px; padding: 0 13px; border-radius: 8px; border: 1px solid transparent; font-size: 12px; font-weight: 650; text-decoration: none; cursor: pointer; } +.aksrt-orders-page .btn-primary { background: var(--color-primary, #3b82f6); color: var(--color-on-primary, #fff); } +.aksrt-orders-page .btn-outline { border-color: var(--orders-line); background: var(--surface-1, #fff); color: var(--orders-ink); } +.aksrt-orders-page .btn-ghost { background: transparent; color: var(--orders-muted); } +.aksrt-orders-page__filter-toggle { background: var(--surface-2, #f4f6f9); color: var(--orders-ink); white-space: nowrap; } + +.aksrt-orders-page__filter-panel { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: -2px 0 14px; padding: 12px; border-radius: 10px; background: var(--surface-2, #f4f6f9); } +.aksrt-orders-page__filter-panel[hidden] { display: none !important; } +.aksrt-orders-page__filter-group { display: flex; flex: 1 1 100%; gap: 10px; align-items: center; } +.aksrt-orders-page__filter-label { flex: 0 0 58px; color: var(--orders-muted); font-size: 12px; font-weight: 650; } +.aksrt-orders-page__status-filters, .aksrt-orders-page__type-filters { display: flex; flex-wrap: wrap; gap: 6px; } +.aksrt-orders-page__filter-chip { display: inline-flex; gap: 5px; align-items: center; padding: 5px 9px; border: 1px solid var(--orders-line); border-radius: 999px; background: var(--surface-1, #fff); color: var(--orders-muted); font: inherit; font-size: 12px; text-decoration: none; cursor: pointer; } +.aksrt-orders-page__filter-chip.is-active { border-color: var(--color-primary, #3b82f6); background: color-mix(in srgb, var(--color-primary, #3b82f6) 10%, var(--surface-1, #fff)); color: var(--color-primary, #3b82f6); font-weight: 700; } +.aksrt-orders-page__filter-count { display: inline-grid; min-width: 17px; height: 17px; padding: 0 4px; place-items: center; border-radius: 999px; background: var(--color-warning, #c87819); color: #fff; font-size: 10px; line-height: 1; } +.aksrt-orders-page__clear { margin-left: auto; padding: 0; border: 0; background: transparent; color: var(--color-primary, #3b82f6); font: inherit; font-size: 12px; text-decoration: none; cursor: pointer; } + +.aksrt-orders-page__list { display: grid; gap: 10px; } +.aksrt-orders-page__card { overflow: hidden; border: 1px solid var(--orders-line); border-radius: 13px; background: var(--surface-1, #fff); transition: border-color .18s ease, transform .18s ease, box-shadow .18s ease; } +.aksrt-orders-page__card:hover { border-color: color-mix(in srgb, var(--color-primary, #3b82f6) 36%, var(--orders-line)); box-shadow: 0 8px 22px rgba(30, 48, 79, .07); transform: translateY(-1px); } +.aksrt-orders-page__card-rail { display: flex; gap: 10px; align-items: center; padding: 9px 14px; background: var(--surface-2, #f7f8fb); color: var(--orders-muted); font-size: 11px; } +.aksrt-orders-page__card-rail time { margin-left: auto; font-variant-numeric: tabular-nums; } +.aksrt-orders-page__card-status { font-weight: 700; } +.aksrt-orders-page__card-status.is-pending { color: var(--color-warning, #c87819); } +.aksrt-orders-page__card-status.is-paid { color: var(--color-success, #168560); } +.aksrt-orders-page__card-main { display: grid; grid-template-columns: 52px minmax(0, 1fr) auto; gap: 13px; align-items: center; padding: 14px; } +.aksrt-orders-page__card-img, .aksrt-orders-page__card-art { width: 52px; height: 52px; border-radius: 10px; object-fit: cover; } +.aksrt-orders-page__card-art { display: grid; place-items: center; color: var(--color-primary, #3b82f6); background: color-mix(in srgb, var(--color-primary, #3b82f6) 10%, var(--surface-2, #f4f6f9)); } +.aksrt-orders-page__card-copy { min-width: 0; } +.aksrt-orders-page__card-copy h3 { display: -webkit-box; margin: 0 0 5px; overflow: hidden; color: var(--orders-ink); font-size: 14px; line-height: 1.35; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } +.aksrt-orders-page__card-copy p { margin: 0; color: var(--orders-muted); font-family: var(--font-mono, monospace); font-size: 11px; } +.aksrt-orders-page__card-value { display: grid; gap: 3px; text-align: right; } +.aksrt-orders-page__card-value strong { color: var(--orders-ink); font-size: 15px; font-variant-numeric: tabular-nums; } +.aksrt-orders-page__card-value span { color: var(--orders-muted); font-size: 11px; } +.aksrt-orders-page__card-actions { display: flex; gap: 7px; align-items: center; justify-content: flex-end; padding: 9px 14px; border-top: 1px solid var(--orders-line); } +.aksrt-orders-page__countdown { margin-right: auto; color: var(--color-warning, #c87819); font-size: 11px; font-variant-numeric: tabular-nums; } + +.aksrt-orders-page__pagination { display: flex; justify-content: center; gap: 6px; margin-top: 20px; } +.aksrt-orders-page__page-chip { display: grid; width: 31px; height: 31px; padding: 0; place-items: center; border: 1px solid var(--orders-line); border-radius: 8px; background: var(--surface-1, #fff); color: var(--orders-muted); font: inherit; font-size: 12px; text-decoration: none; cursor: pointer; } +.aksrt-orders-page__page-chip.is-active { border-color: var(--color-primary, #3b82f6); background: var(--color-primary, #3b82f6); color: #fff; } +.aksrt-orders-page [data-aksrt-order-list], .aksrt-orders-page__stats, .aksrt-orders-page__pagination { transition: opacity .16s ease; } +.aksrt-orders-page.is-loading [data-aksrt-order-list], .aksrt-orders-page.is-loading .aksrt-orders-page__stats, .aksrt-orders-page.is-loading .aksrt-orders-page__pagination { opacity: .55; pointer-events: none; } +.aksrt-orders-page__empty { padding: 52px 20px; border: 1px dashed var(--orders-line); border-radius: 13px; background: var(--surface-2, #f7f8fb); text-align: center; } +.aksrt-orders-page__empty i { display: block; margin-bottom: 12px; color: var(--color-primary, #3b82f6); font-size: 30px; } +.aksrt-orders-page__empty h3 { margin: 0 0 7px; font-size: 15px; }.aksrt-orders-page__empty p { margin: 0 0 16px; color: var(--orders-muted); font-size: 13px; } + +/* ---- Order detail modal (front-end order center) ---- */ +/* 弹窗 portal 到 ,读不到页面上的 `--orders-*` 别名,因此把本模块的局部 + 别名映射到 modal-runtime.css 声明的共享 portal 基座 `--modal-*`; + 路由文件不应再自行推导调色板值。 */ +.aksrt-order-detail-modal { + --detail-ink: var(--modal-ink); + --detail-muted: var(--modal-muted); + --detail-line: var(--modal-line); + --detail-surface: var(--modal-surface); + --detail-surface-2: var(--modal-surface-2); + --detail-surface-3: var(--modal-surface-3); + --detail-primary: var(--modal-primary); + --detail-warning: var(--modal-warning); + --detail-success: var(--modal-success); +} + +/* 订单详情弹窗:与售后弹窗统一为 modal-mini 几何(400px);标题区改用蓝色炫彩头,运行时不再渲染默认标题栏。 */ +.aksrt-order-detail-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog { + width: min(400px, calc(100vw - 32px)); + border-radius: var(--main-radius, 8px); + background: var(--main-bg-color, #fff); +} +.aksrt-order-detail-modal .aksrt-modal-runtime__content { display: flex; flex-direction: column; gap: 18px; padding: 16px 18px 0; } + +.aksrt-order-detail__skeleton { display: grid; gap: 12px; padding: 4px 0; } + +/* Status banner — a single local variable drives the whole colour scheme. */ +.aksrt-order-detail__hero { --hero-accent: var(--detail-primary); --hero-text: color-mix(in srgb, var(--hero-accent) 72%, var(--detail-ink)); display: flex; align-items: center; gap: 13px; padding: 15px; border: 1px solid color-mix(in srgb, var(--hero-accent) 20%, transparent); border-radius: 14px; background: color-mix(in srgb, var(--hero-accent) 12%, var(--detail-surface)); } +.aksrt-order-detail__hero.is-pending { --hero-accent: var(--detail-warning); } +.aksrt-order-detail__hero.is-paid { --hero-accent: var(--detail-success); } +.aksrt-order-detail__hero.is-refunded, .aksrt-order-detail__hero.is-closed { --hero-accent: var(--detail-muted); } +.aksrt-order-detail__hero-icon { display: grid; flex: 0 0 auto; width: 42px; height: 42px; place-items: center; border-radius: 12px; background: color-mix(in srgb, var(--hero-accent) 16%, transparent); color: var(--hero-text); font-size: 19px; } +.aksrt-order-detail__hero-copy { display: grid; flex: 1 1 auto; gap: 4px; min-width: 0; } +.aksrt-order-detail__status { color: var(--hero-text); font-size: 15px; font-weight: 750; line-height: 1.2; } +.aksrt-order-detail__desc { margin: 0; color: var(--detail-muted); font-size: 12px; line-height: 1.45; } +.aksrt-order-detail__countdown { color: var(--hero-text); font-size: 12px; font-variant-numeric: tabular-nums; } +.aksrt-order-detail__hero-amount { display: grid; flex: 0 0 auto; gap: 3px; justify-items: end; text-align: right; } +.aksrt-order-detail__hero-amount strong { color: var(--detail-ink); font-size: 21px; font-weight: 750; line-height: 1.1; letter-spacing: -.01em; font-variant-numeric: tabular-nums; } +.aksrt-order-detail__hero-amount span { color: var(--detail-muted); font-size: 11px; } + +.aksrt-order-detail__group { display: grid; gap: 10px; } +.aksrt-order-detail__group-title { display: flex; align-items: center; gap: 8px; margin: 0; color: var(--detail-ink); font-size: 13px; font-weight: 700; line-height: 1.3; } +.aksrt-order-detail__group-title::before { content: ""; width: 3px; height: 13px; border-radius: 2px; background: var(--detail-primary); } + +.aksrt-order-detail__product { display: grid; grid-template-columns: 54px minmax(0, 1fr); gap: 12px; align-items: center; padding: 12px; border: 1px solid var(--detail-line); border-radius: 12px; background: var(--detail-surface); } +.aksrt-order-detail__thumb { width: 54px; height: 54px; border-radius: 10px; object-fit: cover; } +.aksrt-order-detail__thumb--empty { display: grid; place-items: center; background: var(--detail-surface-2); color: var(--detail-primary); font-size: 19px; } +.aksrt-order-detail__product-copy { display: grid; gap: 6px; min-width: 0; } +.aksrt-order-detail__product-title { display: -webkit-box; overflow: hidden; color: var(--detail-ink); font-size: 14px; line-height: 1.45; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } +.aksrt-order-detail__product-meta { display: inline-flex; justify-self: start; padding: 2px 8px; border-radius: 999px; background: color-mix(in srgb, var(--detail-primary) 9%, var(--detail-surface-2)); color: var(--detail-primary); font-size: 11px; font-weight: 650; line-height: 1.5; } + +.aksrt-order-detail__list { display: grid; margin: 0; overflow: hidden; border: 1px solid var(--detail-line); border-radius: 12px; background: var(--detail-surface); } +.aksrt-order-detail__list > div { display: grid; grid-template-columns: 84px minmax(0, 1fr); align-items: center; gap: 12px; padding: 11px 13px; border-bottom: 1px solid var(--detail-line); } +.aksrt-order-detail__list > div:last-child { border-bottom: 0; } +.aksrt-order-detail__list dt { color: var(--detail-muted); font-size: 12.5px; } +.aksrt-order-detail__list dd { display: flex; align-items: center; justify-content: flex-end; gap: 8px; min-width: 0; margin: 0; color: var(--detail-ink); font-size: 13px; font-variant-numeric: tabular-nums; text-align: right; } +.aksrt-order-detail__value-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.aksrt-order-detail__value-text.is-mono { font-family: var(--font-mono, monospace); font-size: 12.5px; letter-spacing: .01em; } +.aksrt-order-detail__copy { display: inline-grid; flex: 0 0 auto; width: 24px; height: 24px; padding: 0; place-items: center; border: 1px solid var(--detail-line); border-radius: 7px; background: var(--detail-surface-3); color: var(--detail-muted); font-size: 11px; line-height: 1; cursor: pointer; transition: color .15s ease, border-color .15s ease; } +.aksrt-order-detail__copy:hover { border-color: color-mix(in srgb, var(--detail-primary) 45%, var(--detail-line)); color: var(--detail-primary); } +.aksrt-order-detail__copy.is-copied { border-color: color-mix(in srgb, var(--detail-success) 45%, var(--detail-line)); color: var(--detail-success); } + +.aksrt-order-detail__refresh-state { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 10px; padding: 12px 13px; border: 1px solid color-mix(in srgb, var(--detail-warning) 26%, transparent); border-radius: 12px; background: color-mix(in srgb, var(--detail-warning) 10%, var(--detail-surface-2)); color: var(--detail-ink); font-size: 12.5px; } +.aksrt-order-detail__refresh-state > i { color: var(--detail-warning); font-size: 15px; } +.aksrt-order-detail__refresh-state p { margin: 0; } +.aksrt-order-detail__refresh-state button { grid-column: 2; justify-self: start; } + +/* 底部操作区:按钮排布沿用主题 .but-average.modal-buts(禁封申诉弹窗同式),此处仅保留吸底定位与通栏背景。 + 主题 modal-buts 自带 margin-top:16px,吸底场景下用更高优先级清掉,避免内容与吸底条之间出现空隙。 */ +.aksrt-order-detail-modal .aksrt-order-detail__actions { position: sticky; bottom: 0; z-index: 2; margin: 0 -18px; padding: 13px 18px calc(13px + env(safe-area-inset-bottom)); border-top: 1px solid var(--detail-line); background: var(--detail-surface); } +.aksrt-order-detail-modal .aksrt-order-detail__actions.but-average.modal-buts { margin-top: 0; } + +/* Orders extensions */ +.aksrt-orders-page__extra-nav { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 12px; } +.aksrt-orders-page__extra-link { padding: 6px 12px; border-radius: 999px; background: var(--surface-2, #f3f4f6); color: inherit; text-decoration: none; font-size: 13px; } +.aksrt-orders-page__extra-link.is-active { background: var(--focus-color, var(--color-primary)); color: #fff; } +.aksrt-as-list { display: grid; gap: 12px; } +.aksrt-as-card { padding: 14px; border: 1px solid var(--main-border-color, #eee); border-radius: 10px; background: var(--main-bg-color, #fff); } +.aksrt-as-card__head { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 6px; } +.aksrt-as-card__status { margin-left: auto; color: var(--focus-color, #2f6df6); font-weight: 600; } +.aksrt-as-timeline { margin: 10px 0 0; padding-left: 18px; color: var(--muted-2-color, #888); font-size: 12px; } +.aksrt-as-card__actions { display: flex; gap: 8px; margin-top: 10px; } +.aksrt-coupon-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; } +.aksrt-coupon-card { padding: 12px; border: 1px dashed var(--focus-color, #2f6df6); border-radius: 10px; background: color-mix(in srgb, var(--focus-color, #2f6df6) 6%, #fff); } +.aksrt-coupon-card__value { color: var(--focus-color, #2f6df6); font-size: 20px; font-weight: 700; } +.aksrt-coupon-card__code { margin: 6px 0; font-family: ui-monospace, monospace; } +.aksrt-coupon-card__meta { margin-bottom: 4px; color: var(--muted-2-color, #888); font-size: 12px; } +.aksrt-orders-page__field { margin-bottom: 12px; } +.aksrt-orders-page__input, .aksrt-orders-page__select, .aksrt-orders-page__textarea { box-sizing: border-box; width: 100%; padding: 8px 10px; border: 1px solid var(--main-border-color, #ddd); border-radius: 6px; } +@media (max-width: 767px) { .uc-orders-panel__head { grid-template-columns: 1fr; gap: 17px; }.aksrt-orders-page__stats { max-width: none; }.aksrt-orders-page__search { flex-wrap: wrap; }.aksrt-orders-page__search input { min-width: 150px; }.aksrt-orders-page__search .btn-primary { margin-left: auto; }.aksrt-orders-page__filter-toggle { margin-left: auto; }.aksrt-orders-page__card-rail time { width: 100%; margin-left: 0; order: 3; }.aksrt-orders-page__card-main { grid-template-columns: 48px minmax(0, 1fr); }.aksrt-orders-page__card-img, .aksrt-orders-page__card-art { width: 48px; height: 48px; }.aksrt-orders-page__card-value { grid-column: 2; text-align: left; }.aksrt-orders-page__card-actions > :not(.aksrt-orders-page__countdown) { flex: 1 1 auto; }.aksrt-orders-page__countdown { width: 100%; }} +html.dark .aksrt-orders-page__stat, html.dark .aksrt-orders-page__card { background: var(--surface-1, #1a1d24); } html.dark .aksrt-orders-page__card-rail, html.dark .aksrt-orders-page__filter-panel { background: var(--surface-2, #12151b); } + +/* ---- 售后记录卡片(订单详情弹窗) ---- */ +.aksrt-order-detail__after-sales { display: grid; gap: 10px; margin: 0; padding: 0; list-style: none; } +.aksrt-order-detail__after-sale { --as-accent: var(--detail-muted); display: grid; gap: 10px; padding: 13px 14px; border: 1px solid var(--detail-line); border-left: 3px solid var(--as-accent); border-radius: 12px; background: var(--detail-surface); } +.aksrt-order-detail__after-sale.is-pending { --as-accent: var(--detail-warning); } +.aksrt-order-detail__after-sale.is-processing { --as-accent: var(--detail-primary); } +.aksrt-order-detail__after-sale.is-approved, .aksrt-order-detail__after-sale.is-completed { --as-accent: var(--detail-success); } +.aksrt-order-detail__after-sale.is-rejected, .aksrt-order-detail__after-sale.is-cancelled { --as-accent: var(--modal-danger, #ef4444); } +.aksrt-order-detail__after-sale-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; } +.aksrt-order-detail__after-sale-badge { padding: 3px 11px; border-radius: 999px; background: color-mix(in srgb, var(--as-accent) 12%, transparent); color: var(--as-accent); font-size: 12px; font-weight: 700; line-height: 1.5; } +.aksrt-order-detail__after-sale-head time { color: var(--detail-muted); font-size: 11.5px; font-variant-numeric: tabular-nums; } +.aksrt-order-detail__after-sale-reason { margin: 0; color: var(--detail-ink); font-size: 13px; line-height: 1.65; white-space: pre-wrap; word-break: break-word; } +.aksrt-order-detail__after-sale-meta { display: grid; margin: 0; overflow: hidden; border: 1px solid var(--detail-line); border-radius: 10px; background: var(--detail-surface-3); } +.aksrt-order-detail__after-sale-meta > div { display: grid; grid-template-columns: 76px minmax(0, 1fr); gap: 10px; padding: 9px 12px; border-bottom: 1px solid var(--detail-line); font-size: 12.5px; } +.aksrt-order-detail__after-sale-meta > div:last-child { border-bottom: 0; } +.aksrt-order-detail__after-sale-meta dt { color: var(--detail-muted); } +.aksrt-order-detail__after-sale-meta dd { margin: 0; color: var(--detail-ink); font-size: 12.5px; text-align: right; word-break: break-word; } +.aksrt-order-detail__after-sale-field { color: var(--detail-muted); } +.aksrt-order-detail__after-sale-foot { display: flex; flex-wrap: wrap; gap: 8px; } diff --git a/assets/js/order-after-sale.js b/assets/js/order-after-sale.js index 2251386f..e72cd06b 100644 --- a/assets/js/order-after-sale.js +++ b/assets/js/order-after-sale.js @@ -1,546 +1,546 @@ -/** - * AKSRT After-Sale — 前台售后弹窗(申请 / 超时效提示 / 售后详细) - * - * 与「禁封/举报」链路同构(report.js / user-ban.js): - * - [data-aksrt-after-sale-apply] → 申请售后弹窗(超时效则改为超时效提示弹窗) - * - [data-aksrt-after-sale-detail] → 售后详细弹窗(可再次申请 / 不可再申请提示) - * 两个入口的 data-* 属性携带订单与售后状态载荷(同 report.js 的 data-aksrt-report), - * 点击即同步弹窗,不做前置请求(避免首次点击无反馈)。 - * - 提交走 REST /aksrt/v1/orders/{order_number}/after-sale - * - 举证图片走 /orders/{order_number}/after-sale/upload-image(复用评论图片上传校验) - * - * 按钮形态由订单视图模型的 after_sale 状态决定(UserOrders::after_sale_state): - * 已申请 → 售后详细;未申请且在时效内 → 申请售后;超时效 → 申请售后(点击弹超时效提示)。 - * 弹窗统一经 AksrtModalRuntime.open 直出 HTML:桌面为主题 modal-mini 几何 - * (400px)+ 蓝色炫彩标题区(modal-colorful-header colorful-bg jb-blue), - * 移动端由 mobileBottom 底部抽屉接管。 - * - * @package AKSRT - */ - -(function () { - 'use strict'; - - var API_ROOT = (function () { - var d = window.aksrtData || {}; - var raw = d.apiUrl || d.restUrl || d.rest_url || '/index.php?rest_route=/aksrt/v1'; - return String(raw).replace(/\/$/, ''); - })(); - var NONCE = (window.aksrtData && window.aksrtData.restNonce) || ''; - var MAX_IMAGES = 3; - - var STATUS_LABELS = { - pending: '待审核', - processing: '处理中', - approved: '已通过', - rejected: '已拒绝', - cancelled: '已取消', - completed: '已完成', - }; - - function settings() { - var d = window.aksrtData && window.aksrtData.afterSale; - return d && typeof d === 'object' ? d : {}; - } - - function reasonOptions() { - var list = settings().reasons; - if (!Array.isArray(list)) return []; - return list - .map(function (item) { - return { - slug: String((item && (item.slug || item.value)) || ''), - name: String((item && (item.name || item.label || item.t)) || ''), - }; - }) - .filter(function (item) { return item.name !== ''; }); - } - - function materialOptions() { - var list = settings().materials; - if (!Array.isArray(list)) return []; - return list.map(function (item) { return String(item == null ? '' : item); }) - .filter(function (text) { return text !== ''; }); - } - - function escapeHtml(value) { - return String(value == null ? '' : value) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - } - - function colorfulHeader(title, icon) { - return ''; - } - - function orderAmountText(order) { - var raw = String(order && order.amount != null ? order.amount : '0'); - if (order && order.is_points) { - return escapeHtml(String(order.currency_symbol || '积分')) + ' ' + (/^\d+$/.test(raw) ? raw : '0'); - } - return '¥' + raw; - } - - function aftersaleAmountText(value, order) { - var amount = String(value == null ? '' : value); - return order && order.is_points ? amount + ' ' + String((order && order.currency_symbol) || '积分') : '¥' + amount; - } - - function orderSummaryHtml(order) { - if (!order) return ''; - return '
' + - '
商品' + - '' + escapeHtml(order.product_name || '订单') + '
' + - '
订单号' + - '' + escapeHtml(order.order_number || '') + '
' + - '
实付' + - '' + orderAmountText(order) + '
' + - '
'; - } - - function buildApplyHtml(order) { - var cfg = settings(); - var reasons = reasonOptions(); - var materials = materialOptions(); - - var html = '
'; - html += colorfulHeader('申请售后', 'fa-undo-alt'); - html += '
'; - // 滚动容器与禁封申诉弹窗一致:no-scrollbar 完全隐藏滚动条(含细滚动条)+ 50vh 内层滚动。 - html += '
'; - html += orderSummaryHtml(order); - - if (reasons.length) { - html += '
'; - html += '
售后原因
'; - html += '
'; - reasons.forEach(function (reason) { - html += '' + escapeHtml(reason.name) + ''; - }); - html += '
'; - } - - materials.forEach(function (label) { - html += '
' + escapeHtml(label) + '
' + - '
'; - }); - - var placeholder = String(cfg.reasonPlaceholder || '必填,请您详细填写申请原因'); - html += '
申请说明
' + - '
'; - - if (cfg.imgEnabled) { - html += '
' + - '
上传图片
' + - '
' + - '' + - '
最多上传 ' + MAX_IMAGES + ' 张图片
' + - '
'; - } - - // 申请售后说明(后台 after_sale_desc,服务端已 wp_kses_post)。 - var desc = String(cfg.desc || ''); - if (desc) { - html += '
' + desc + '
'; - } - - html += '
'; - html += ''; - html += '
'; - return html; - } - - function detailRow(label, value) { - if (value === '' || value == null) return ''; - return '
' + escapeHtml(label) + '' + - '' + escapeHtml(value) + '
'; - } - - function buildDetailHtml(order) { - var afterSale = (order && order.after_sale) || {}; - var latest = afterSale.latest || {}; - var status = String(latest.status || ''); - - var html = '
'; - html += colorfulHeader('售后详细', 'fa-clipboard-list'); - html += '
'; - html += orderSummaryHtml(order); - - html += '
' + - '售后状态' + - '' + escapeHtml(STATUS_LABELS[status] || status) + '' + - '
'; - - html += '
'; - html += detailRow('申请时间', String(latest.created_at || '')); - html += detailRow('退款金额', aftersaleAmountText(latest.refund_amount, order)); - html += detailRow('处理备注', String(latest.admin_note || '')); - html += detailRow('完成时间', String(latest.completed_at || '')); - html += '
'; - - // 申请表单分行展示:预置原因 / 申请资料 / 申请说明各自独立成行, - // 与上方「申请时间」等明细行同格式;老数据无 apply_form 时兜底展示融合串。 - var applyForm = latest.apply_form && typeof latest.apply_form === 'object' ? latest.apply_form : null; - if (applyForm) { - html += detailRow('原因', String(applyForm.reason_label || '')); - (Array.isArray(applyForm.materials) ? applyForm.materials : []).forEach(function (material) { - html += detailRow(String((material && material.label) || ''), String((material && material.value) || '')); - }); - html += detailRow('申请说明', String(applyForm.description || '')); - } else if (latest.reason) { - html += '
申请原因
' + - '
' + escapeHtml(latest.reason).replace(/\r\n|\r|\n/g, '
') + '
'; - } - - var images = Array.isArray(latest.images) ? latest.images : []; - if (images.length) { - html += '
举证图片
'; - images.forEach(function (url) { - html += ''; - }); - html += '
'; - } - - html += '
'; - - if (afterSale.can_reapply) { - // 可再次申请:仅保留单个主按钮(与禁封申诉弹窗单按钮同式)。 - html += ''; - } else { - // 不可再申请售后:不再展示阻断文案,仅保留关闭入口。 - html += ''; - } - - html += '
'; - return html; - } - - function buildExpiredHtml() { - var text = String(settings().expiredText || '抱歉,您的订单已超过售后申请时效'); - var html = '
'; - html += colorfulHeader('售后提示', 'fa-clock'); - // 超过时效提示(后台 after_sale_expired_text,服务端已 wp_kses_post,支持 HTML)。 - html += '
' + text + '
'; - html += ''; - html += '
'; - return html; - } - - function openModal(options) { - if (!window.AksrtModalRuntime || typeof window.AksrtModalRuntime.open !== 'function') { - notyf('售后组件加载失败,请刷新页面后重试', 'danger'); - return null; - } - var shell = window.AksrtModalRuntime.open({ - html: options.html, - className: options.className, - kind: options.kind, - mobileBottom: true, - }); - bindShell(shell); - return shell; - } - - function openApply(order) { - return openModal({ - html: buildApplyHtml(order), - className: 'aksrt-after-sale-apply-modal', - kind: 'after-sale-apply', - }); - } - - function openDetail(order) { - return openModal({ - html: buildDetailHtml(order), - className: 'aksrt-after-sale-detail-modal', - kind: 'after-sale-detail', - }); - } - - function openExpired() { - return openModal({ - html: buildExpiredHtml(), - className: 'aksrt-after-sale-expired-modal', - kind: 'after-sale-expired', - }); - } - - /** - * 解析按钮 data-* 上的售后载荷(与举报链路 data-aksrt-report 同模式): - * 入口数据随列表一起渲染,点击即可同步弹窗,不做前置请求。 - */ - function parsePayload(raw) { - if (!raw) return null; - try { - var data = JSON.parse(raw); - return data && typeof data === 'object' ? data : null; - } catch (error) { - return null; - } - } - - // 申请售后入口:超时效 → 超时效提示;已申请 → 售后详细;否则 → 申请表单。 - function openApplyFlow(payload) { - var afterSale = payload && payload.after_sale ? payload.after_sale : null; - if (!payload || !afterSale || !afterSale.enabled) { - notyf('售后申请暂不可用', 'danger'); - return; - } - if (afterSale.expired) { openExpired(); return; } - if (afterSale.has_application) { openDetail(payload); return; } - openApply(payload); - } - - function openDetailFlow(payload) { - var afterSale = payload && payload.after_sale ? payload.after_sale : null; - if (!payload || !afterSale || !afterSale.enabled || !afterSale.has_application) { - notyf('暂无售后记录', 'danger'); - return; - } - openDetail(payload); - } - - function collectImages(shell) { - return Array.prototype.map.call( - shell.querySelectorAll('.preview .preview-item img'), - function (img) { return img.getAttribute('src') || ''; } - ).filter(function (url) { return url !== ''; }); - } - - function uploadImage(shell, file) { - var form = shell.querySelector('[data-aksrt-after-sale-order]'); - var preview = shell.querySelector('.form-upload .preview'); - var orderNumber = form ? form.getAttribute('data-aksrt-after-sale-order') : ''; - if (!preview || !orderNumber) return Promise.resolve(); - if (collectImages(shell).length >= MAX_IMAGES) { - notyf('最多上传 ' + MAX_IMAGES + ' 张图片', 'danger'); - return Promise.resolve(); - } - - var body = new FormData(); - body.append('image', file); - - return window.fetch(API_ROOT + '/orders/' + encodeURIComponent(orderNumber) + '/after-sale/upload-image', { - method: 'POST', - headers: { 'X-WP-Nonce': NONCE }, - body: body, - }) - .then(function (res) { return res.json(); }) - .then(function (result) { - var url = result && result.success && result.data && result.data.url; - if (!url) { - notyf((result && result.message) || '图片上传失败', 'danger'); - return; - } - if (collectImages(shell).length >= MAX_IMAGES) { - notyf('最多上传 ' + MAX_IMAGES + ' 张图片', 'danger'); - return; - } - var item = document.createElement('div'); - item.className = 'preview-item'; - var img = document.createElement('img'); - img.src = url; - img.alt = ''; - var remove = document.createElement('button'); - remove.type = 'button'; - remove.className = 'remove'; - remove.setAttribute('data-aksrt-after-sale-image-remove', ''); - remove.setAttribute('aria-label', '删除图片'); - remove.innerHTML = '×'; - remove.addEventListener('click', function () { item.remove(); }); - item.appendChild(img); - item.appendChild(remove); - preview.insertBefore(item, preview.querySelector('.add')); - }) - .catch(function () { - notyf('图片上传失败,请重试', 'danger'); - }); - } - - function selectedReasonLabel(shell) { - var active = shell.querySelector('[data-aksrt-after-sale-reason].active'); - return active ? String(active.getAttribute('data-aksrt-after-sale-reason') || '') : ''; - } - - function collectMaterials(shell) { - var materials = []; - Array.prototype.forEach.call(shell.querySelectorAll('[data-aksrt-after-sale-material]'), function (input) { - var value = String(input.value || '').trim(); - if (value === '') return; - materials.push({ - label: String(input.getAttribute('data-aksrt-after-sale-material') || ''), - value: value, - }); - }); - return materials; - } - - function composeReason(shell) { - var parts = []; - var active = shell.querySelector('[data-aksrt-after-sale-reason].active'); - if (active) { - var label = active.getAttribute('data-aksrt-after-sale-reason') || ''; - if (label) parts.push(label); - } - var materials = []; - Array.prototype.forEach.call(shell.querySelectorAll('[data-aksrt-after-sale-material]'), function (input) { - var value = String(input.value || '').trim(); - if (value === '') return; - materials.push(String(input.getAttribute('data-aksrt-after-sale-material') || '') + ':' + value); - }); - if (materials.length) parts.push(materials.join('\n')); - var reasonInput = shell.querySelector('[name="reason"]'); - var reasonText = reasonInput ? String(reasonInput.value || '').trim() : ''; - if (reasonText) parts.push(reasonText); - return parts.join('\n'); - } - - function submitApply(shell) { - var form = shell.querySelector('[data-aksrt-after-sale-order]'); - var orderNumber = form ? form.getAttribute('data-aksrt-after-sale-order') : ''; - var reasonInput = shell.querySelector('[name="reason"]'); - var reasonText = reasonInput ? String(reasonInput.value || '').trim() : ''; - if (!reasonText) { - notyf('请填写申请说明', 'danger'); - if (reasonInput) reasonInput.focus(); - return; - } - // 预置原因被后台清空时不强制选择;有配置则必须选中一项(与举报弹窗一致)。 - if (shell.querySelector('[data-aksrt-after-sale-reason]') && !shell.querySelector('[data-aksrt-after-sale-reason].active')) { - notyf('请选择售后原因', 'danger'); - return; - } - - var submitBtn = shell.querySelector('[data-aksrt-after-sale-submit]'); - if (submitBtn) submitBtn.disabled = true; - notyf('正在提交售后申请…', 'load', 3000, 'aksrt-after-sale-submit'); - - window.fetch(API_ROOT + '/orders/' + encodeURIComponent(orderNumber) + '/after-sale', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-WP-Nonce': NONCE, - }, - body: JSON.stringify({ - reason: composeReason(shell), - reason_label: selectedReasonLabel(shell), - materials: collectMaterials(shell), - description: reasonText, - images: collectImages(shell), - }), - }) - .then(function (res) { return res.json(); }) - .then(function (result) { - if (result && result.success) { - window.AksrtModalRuntime.close('submit'); - notyf('售后申请已提交', 'success'); - if (window.AKSRTOrderCenter && typeof window.AKSRTOrderCenter.refresh === 'function') { - window.AKSRTOrderCenter.refresh(orderNumber); - } - } else { - if (submitBtn) submitBtn.disabled = false; - notyf((result && result.message) || '售后申请提交失败', 'danger'); - } - }) - .catch(function () { - if (submitBtn) submitBtn.disabled = false; - notyf('网络错误,请重试', 'danger'); - }) - .then(function () { - var toast = document.getElementById('aksrt-after-sale-submit'); - if (toast) toast.remove(); - }); - } - - function bindShell(shell) { - if (!shell) return; - - shell.addEventListener('click', function (event) { - // 原因徽章:单选 active(与举报弹窗同语义,重复点击已选徽章保持选中)。 - var chip = event.target.closest('[data-aksrt-after-sale-reason]'); - if (chip) { - event.preventDefault(); - Array.prototype.forEach.call(shell.querySelectorAll('[data-aksrt-after-sale-reason].active'), function (el) { - el.classList.remove('active'); - }); - chip.classList.add('active'); - return; - } - - var addBtn = event.target.closest('[data-aksrt-after-sale-add]'); - if (addBtn) { - event.preventDefault(); - var fileInput = shell.querySelector('[data-aksrt-after-sale-file]'); - if (fileInput) fileInput.click(); - return; - } - - var removeBtn = event.target.closest('[data-aksrt-after-sale-image-remove]'); - if (removeBtn) { - event.preventDefault(); - var item = removeBtn.closest('.preview-item'); - if (item) item.remove(); - return; - } - - var submitBtn = event.target.closest('[data-aksrt-after-sale-submit]'); - if (submitBtn) { - event.preventDefault(); - submitApply(shell); - return; - } - - // 再次申请售后:载荷已随按钮渲染,直接开申请表单(时效/工单状态随列表刷新更新)。 - var reapplyBtn = event.target.closest('[data-aksrt-after-sale-reapply]'); - if (reapplyBtn) { - event.preventDefault(); - var payload = parsePayload(reapplyBtn.getAttribute('data-aksrt-after-sale-payload')); - window.AksrtModalRuntime.close('replace'); - openApplyFlow(payload); - } - }); - - shell.addEventListener('change', function (event) { - var fileInput = event.target.closest('[data-aksrt-after-sale-file]'); - if (!fileInput || !fileInput.files || !fileInput.files.length) return; - Array.prototype.forEach.call(fileInput.files, function (file) { - uploadImage(shell, file); - }); - fileInput.value = ''; - }); - } - - function initAfterSaleButtons() { - if (document.documentElement.dataset.aksrtAfterSaleInitialized === '1') return; - document.documentElement.dataset.aksrtAfterSaleInitialized = '1'; - document.addEventListener('click', function (event) { - var apply = event.target.closest('[data-aksrt-after-sale-apply]'); - if (apply) { - event.preventDefault(); - openApplyFlow(parsePayload(apply.getAttribute('data-aksrt-after-sale-apply'))); - return; - } - var detail = event.target.closest('[data-aksrt-after-sale-detail]'); - if (detail) { - event.preventDefault(); - openDetailFlow(parsePayload(detail.getAttribute('data-aksrt-after-sale-detail'))); - } - }); - } - - jQuery(document).off('auto_fun.aksrt-after-sale').on('auto_fun.aksrt-after-sale', initAfterSaleButtons); - // 委托监听不依赖 DOM 扫描,脚本加载时立即挂上;auto_fun 钩子保留作幂等重扫。 - initAfterSaleButtons(); -})(); +/** + * AKSRT After-Sale — 前台售后弹窗(申请 / 超时效提示 / 售后详细) + * + * 与「禁封/举报」链路同构(report.js / user-ban.js): + * - [data-aksrt-after-sale-apply] → 申请售后弹窗(超时效则改为超时效提示弹窗) + * - [data-aksrt-after-sale-detail] → 售后详细弹窗(可再次申请 / 不可再申请提示) + * 两个入口的 data-* 属性携带订单与售后状态载荷(同 report.js 的 data-aksrt-report), + * 点击即同步弹窗,不做前置请求(避免首次点击无反馈)。 + * - 提交走 REST /aksrt/v1/orders/{order_number}/after-sale + * - 举证图片走 /orders/{order_number}/after-sale/upload-image(复用评论图片上传校验) + * + * 按钮形态由订单视图模型的 after_sale 状态决定(UserOrders::after_sale_state): + * 已申请 → 售后详细;未申请且在时效内 → 申请售后;超时效 → 申请售后(点击弹超时效提示)。 + * 弹窗统一经 AksrtModalRuntime.open 直出 HTML:桌面为主题 modal-mini 几何 + * (400px)+ 蓝色炫彩标题区(modal-colorful-header colorful-bg jb-blue), + * 移动端由 mobileBottom 底部抽屉接管。 + * + * @package AKSRT + */ + +(function () { + 'use strict'; + + var API_ROOT = (function () { + var d = window.aksrtData || {}; + var raw = d.apiUrl || d.restUrl || d.rest_url || '/index.php?rest_route=/aksrt/v1'; + return String(raw).replace(/\/$/, ''); + })(); + var NONCE = (window.aksrtData && window.aksrtData.restNonce) || ''; + var MAX_IMAGES = 3; + + var STATUS_LABELS = { + pending: '待审核', + processing: '处理中', + approved: '已通过', + rejected: '已拒绝', + cancelled: '已取消', + completed: '已完成', + }; + + function settings() { + var d = window.aksrtData && window.aksrtData.afterSale; + return d && typeof d === 'object' ? d : {}; + } + + function reasonOptions() { + var list = settings().reasons; + if (!Array.isArray(list)) return []; + return list + .map(function (item) { + return { + slug: String((item && (item.slug || item.value)) || ''), + name: String((item && (item.name || item.label || item.t)) || ''), + }; + }) + .filter(function (item) { return item.name !== ''; }); + } + + function materialOptions() { + var list = settings().materials; + if (!Array.isArray(list)) return []; + return list.map(function (item) { return String(item == null ? '' : item); }) + .filter(function (text) { return text !== ''; }); + } + + function escapeHtml(value) { + return String(value == null ? '' : value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function colorfulHeader(title, icon) { + return ''; + } + + function orderAmountText(order) { + var raw = String(order && order.amount != null ? order.amount : '0'); + if (order && order.is_points) { + return escapeHtml(String(order.currency_symbol || '积分')) + ' ' + (/^\d+$/.test(raw) ? raw : '0'); + } + return '¥' + raw; + } + + function aftersaleAmountText(value, order) { + var amount = String(value == null ? '' : value); + return order && order.is_points ? amount + ' ' + String((order && order.currency_symbol) || '积分') : '¥' + amount; + } + + function orderSummaryHtml(order) { + if (!order) return ''; + return '
' + + '
商品' + + '' + escapeHtml(order.product_name || '订单') + '
' + + '
订单号' + + '' + escapeHtml(order.order_number || '') + '
' + + '
实付' + + '' + orderAmountText(order) + '
' + + '
'; + } + + function buildApplyHtml(order) { + var cfg = settings(); + var reasons = reasonOptions(); + var materials = materialOptions(); + + var html = '
'; + html += colorfulHeader('申请售后', 'fa-undo-alt'); + html += '
'; + // 滚动容器与禁封申诉弹窗一致:no-scrollbar 完全隐藏滚动条(含细滚动条)+ 50vh 内层滚动。 + html += '
'; + html += orderSummaryHtml(order); + + if (reasons.length) { + html += '
'; + html += '
售后原因
'; + html += '
'; + reasons.forEach(function (reason) { + html += '' + escapeHtml(reason.name) + ''; + }); + html += '
'; + } + + materials.forEach(function (label) { + html += '
' + escapeHtml(label) + '
' + + '
'; + }); + + var placeholder = String(cfg.reasonPlaceholder || '必填,请您详细填写申请原因'); + html += '
申请说明
' + + '
'; + + if (cfg.imgEnabled) { + html += '
' + + '
上传图片
' + + '
' + + '' + + '
最多上传 ' + MAX_IMAGES + ' 张图片
' + + '
'; + } + + // 申请售后说明(后台 after_sale_desc,服务端已 wp_kses_post)。 + var desc = String(cfg.desc || ''); + if (desc) { + html += '
' + desc + '
'; + } + + html += '
'; + html += ''; + html += '
'; + return html; + } + + function detailRow(label, value) { + if (value === '' || value == null) return ''; + return '
' + escapeHtml(label) + '' + + '' + escapeHtml(value) + '
'; + } + + function buildDetailHtml(order) { + var afterSale = (order && order.after_sale) || {}; + var latest = afterSale.latest || {}; + var status = String(latest.status || ''); + + var html = '
'; + html += colorfulHeader('售后详细', 'fa-clipboard-list'); + html += '
'; + html += orderSummaryHtml(order); + + html += '
' + + '售后状态' + + '' + escapeHtml(STATUS_LABELS[status] || status) + '' + + '
'; + + html += '
'; + html += detailRow('申请时间', String(latest.created_at || '')); + html += detailRow('退款金额', aftersaleAmountText(latest.refund_amount, order)); + html += detailRow('处理备注', String(latest.admin_note || '')); + html += detailRow('完成时间', String(latest.completed_at || '')); + html += '
'; + + // 申请表单分行展示:预置原因 / 申请资料 / 申请说明各自独立成行, + // 与上方「申请时间」等明细行同格式;老数据无 apply_form 时兜底展示融合串。 + var applyForm = latest.apply_form && typeof latest.apply_form === 'object' ? latest.apply_form : null; + if (applyForm) { + html += detailRow('原因', String(applyForm.reason_label || '')); + (Array.isArray(applyForm.materials) ? applyForm.materials : []).forEach(function (material) { + html += detailRow(String((material && material.label) || ''), String((material && material.value) || '')); + }); + html += detailRow('申请说明', String(applyForm.description || '')); + } else if (latest.reason) { + html += '
申请原因
' + + '
' + escapeHtml(latest.reason).replace(/\r\n|\r|\n/g, '
') + '
'; + } + + var images = Array.isArray(latest.images) ? latest.images : []; + if (images.length) { + html += '
举证图片
'; + images.forEach(function (url) { + html += ''; + }); + html += '
'; + } + + html += '
'; + + if (afterSale.can_reapply) { + // 可再次申请:仅保留单个主按钮(与禁封申诉弹窗单按钮同式)。 + html += ''; + } else { + // 不可再申请售后:不再展示阻断文案,仅保留关闭入口。 + html += ''; + } + + html += '
'; + return html; + } + + function buildExpiredHtml() { + var text = String(settings().expiredText || '抱歉,您的订单已超过售后申请时效'); + var html = '
'; + html += colorfulHeader('售后提示', 'fa-clock'); + // 超过时效提示(后台 after_sale_expired_text,服务端已 wp_kses_post,支持 HTML)。 + html += '
' + text + '
'; + html += ''; + html += '
'; + return html; + } + + function openModal(options) { + if (!window.AksrtModalRuntime || typeof window.AksrtModalRuntime.open !== 'function') { + notyf('售后组件加载失败,请刷新页面后重试', 'danger'); + return null; + } + var shell = window.AksrtModalRuntime.open({ + html: options.html, + className: options.className, + kind: options.kind, + mobileBottom: true, + }); + bindShell(shell); + return shell; + } + + function openApply(order) { + return openModal({ + html: buildApplyHtml(order), + className: 'aksrt-after-sale-apply-modal', + kind: 'after-sale-apply', + }); + } + + function openDetail(order) { + return openModal({ + html: buildDetailHtml(order), + className: 'aksrt-after-sale-detail-modal', + kind: 'after-sale-detail', + }); + } + + function openExpired() { + return openModal({ + html: buildExpiredHtml(), + className: 'aksrt-after-sale-expired-modal', + kind: 'after-sale-expired', + }); + } + + /** + * 解析按钮 data-* 上的售后载荷(与举报链路 data-aksrt-report 同模式): + * 入口数据随列表一起渲染,点击即可同步弹窗,不做前置请求。 + */ + function parsePayload(raw) { + if (!raw) return null; + try { + var data = JSON.parse(raw); + return data && typeof data === 'object' ? data : null; + } catch (error) { + return null; + } + } + + // 申请售后入口:超时效 → 超时效提示;已申请 → 售后详细;否则 → 申请表单。 + function openApplyFlow(payload) { + var afterSale = payload && payload.after_sale ? payload.after_sale : null; + if (!payload || !afterSale || !afterSale.enabled) { + notyf('售后申请暂不可用', 'danger'); + return; + } + if (afterSale.expired) { openExpired(); return; } + if (afterSale.has_application) { openDetail(payload); return; } + openApply(payload); + } + + function openDetailFlow(payload) { + var afterSale = payload && payload.after_sale ? payload.after_sale : null; + if (!payload || !afterSale || !afterSale.enabled || !afterSale.has_application) { + notyf('暂无售后记录', 'danger'); + return; + } + openDetail(payload); + } + + function collectImages(shell) { + return Array.prototype.map.call( + shell.querySelectorAll('.preview .preview-item img'), + function (img) { return img.getAttribute('src') || ''; } + ).filter(function (url) { return url !== ''; }); + } + + function uploadImage(shell, file) { + var form = shell.querySelector('[data-aksrt-after-sale-order]'); + var preview = shell.querySelector('.form-upload .preview'); + var orderNumber = form ? form.getAttribute('data-aksrt-after-sale-order') : ''; + if (!preview || !orderNumber) return Promise.resolve(); + if (collectImages(shell).length >= MAX_IMAGES) { + notyf('最多上传 ' + MAX_IMAGES + ' 张图片', 'danger'); + return Promise.resolve(); + } + + var body = new FormData(); + body.append('image', file); + + return window.fetch(API_ROOT + '/orders/' + encodeURIComponent(orderNumber) + '/after-sale/upload-image', { + method: 'POST', + headers: { 'X-WP-Nonce': NONCE }, + body: body, + }) + .then(function (res) { return res.json(); }) + .then(function (result) { + var url = result && result.success && result.data && result.data.url; + if (!url) { + notyf((result && result.message) || '图片上传失败', 'danger'); + return; + } + if (collectImages(shell).length >= MAX_IMAGES) { + notyf('最多上传 ' + MAX_IMAGES + ' 张图片', 'danger'); + return; + } + var item = document.createElement('div'); + item.className = 'preview-item'; + var img = document.createElement('img'); + img.src = url; + img.alt = ''; + var remove = document.createElement('button'); + remove.type = 'button'; + remove.className = 'remove'; + remove.setAttribute('data-aksrt-after-sale-image-remove', ''); + remove.setAttribute('aria-label', '删除图片'); + remove.innerHTML = '×'; + remove.addEventListener('click', function () { item.remove(); }); + item.appendChild(img); + item.appendChild(remove); + preview.insertBefore(item, preview.querySelector('.add')); + }) + .catch(function () { + notyf('图片上传失败,请重试', 'danger'); + }); + } + + function selectedReasonLabel(shell) { + var active = shell.querySelector('[data-aksrt-after-sale-reason].active'); + return active ? String(active.getAttribute('data-aksrt-after-sale-reason') || '') : ''; + } + + function collectMaterials(shell) { + var materials = []; + Array.prototype.forEach.call(shell.querySelectorAll('[data-aksrt-after-sale-material]'), function (input) { + var value = String(input.value || '').trim(); + if (value === '') return; + materials.push({ + label: String(input.getAttribute('data-aksrt-after-sale-material') || ''), + value: value, + }); + }); + return materials; + } + + function composeReason(shell) { + var parts = []; + var active = shell.querySelector('[data-aksrt-after-sale-reason].active'); + if (active) { + var label = active.getAttribute('data-aksrt-after-sale-reason') || ''; + if (label) parts.push(label); + } + var materials = []; + Array.prototype.forEach.call(shell.querySelectorAll('[data-aksrt-after-sale-material]'), function (input) { + var value = String(input.value || '').trim(); + if (value === '') return; + materials.push(String(input.getAttribute('data-aksrt-after-sale-material') || '') + ':' + value); + }); + if (materials.length) parts.push(materials.join('\n')); + var reasonInput = shell.querySelector('[name="reason"]'); + var reasonText = reasonInput ? String(reasonInput.value || '').trim() : ''; + if (reasonText) parts.push(reasonText); + return parts.join('\n'); + } + + function submitApply(shell) { + var form = shell.querySelector('[data-aksrt-after-sale-order]'); + var orderNumber = form ? form.getAttribute('data-aksrt-after-sale-order') : ''; + var reasonInput = shell.querySelector('[name="reason"]'); + var reasonText = reasonInput ? String(reasonInput.value || '').trim() : ''; + if (!reasonText) { + notyf('请填写申请说明', 'danger'); + if (reasonInput) reasonInput.focus(); + return; + } + // 预置原因被后台清空时不强制选择;有配置则必须选中一项(与举报弹窗一致)。 + if (shell.querySelector('[data-aksrt-after-sale-reason]') && !shell.querySelector('[data-aksrt-after-sale-reason].active')) { + notyf('请选择售后原因', 'danger'); + return; + } + + var submitBtn = shell.querySelector('[data-aksrt-after-sale-submit]'); + if (submitBtn) submitBtn.disabled = true; + notyf('正在提交售后申请…', 'load', 3000, 'aksrt-after-sale-submit'); + + window.fetch(API_ROOT + '/orders/' + encodeURIComponent(orderNumber) + '/after-sale', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': NONCE, + }, + body: JSON.stringify({ + reason: composeReason(shell), + reason_label: selectedReasonLabel(shell), + materials: collectMaterials(shell), + description: reasonText, + images: collectImages(shell), + }), + }) + .then(function (res) { return res.json(); }) + .then(function (result) { + if (result && result.success) { + window.AksrtModalRuntime.close('submit'); + notyf('售后申请已提交', 'success'); + if (window.AKSRTOrderCenter && typeof window.AKSRTOrderCenter.refresh === 'function') { + window.AKSRTOrderCenter.refresh(orderNumber); + } + } else { + if (submitBtn) submitBtn.disabled = false; + notyf((result && result.message) || '售后申请提交失败', 'danger'); + } + }) + .catch(function () { + if (submitBtn) submitBtn.disabled = false; + notyf('网络错误,请重试', 'danger'); + }) + .then(function () { + var toast = document.getElementById('aksrt-after-sale-submit'); + if (toast) toast.remove(); + }); + } + + function bindShell(shell) { + if (!shell) return; + + shell.addEventListener('click', function (event) { + // 原因徽章:单选 active(与举报弹窗同语义,重复点击已选徽章保持选中)。 + var chip = event.target.closest('[data-aksrt-after-sale-reason]'); + if (chip) { + event.preventDefault(); + Array.prototype.forEach.call(shell.querySelectorAll('[data-aksrt-after-sale-reason].active'), function (el) { + el.classList.remove('active'); + }); + chip.classList.add('active'); + return; + } + + var addBtn = event.target.closest('[data-aksrt-after-sale-add]'); + if (addBtn) { + event.preventDefault(); + var fileInput = shell.querySelector('[data-aksrt-after-sale-file]'); + if (fileInput) fileInput.click(); + return; + } + + var removeBtn = event.target.closest('[data-aksrt-after-sale-image-remove]'); + if (removeBtn) { + event.preventDefault(); + var item = removeBtn.closest('.preview-item'); + if (item) item.remove(); + return; + } + + var submitBtn = event.target.closest('[data-aksrt-after-sale-submit]'); + if (submitBtn) { + event.preventDefault(); + submitApply(shell); + return; + } + + // 再次申请售后:载荷已随按钮渲染,直接开申请表单(时效/工单状态随列表刷新更新)。 + var reapplyBtn = event.target.closest('[data-aksrt-after-sale-reapply]'); + if (reapplyBtn) { + event.preventDefault(); + var payload = parsePayload(reapplyBtn.getAttribute('data-aksrt-after-sale-payload')); + window.AksrtModalRuntime.close('replace'); + openApplyFlow(payload); + } + }); + + shell.addEventListener('change', function (event) { + var fileInput = event.target.closest('[data-aksrt-after-sale-file]'); + if (!fileInput || !fileInput.files || !fileInput.files.length) return; + Array.prototype.forEach.call(fileInput.files, function (file) { + uploadImage(shell, file); + }); + fileInput.value = ''; + }); + } + + function initAfterSaleButtons() { + if (document.documentElement.dataset.aksrtAfterSaleInitialized === '1') return; + document.documentElement.dataset.aksrtAfterSaleInitialized = '1'; + document.addEventListener('click', function (event) { + var apply = event.target.closest('[data-aksrt-after-sale-apply]'); + if (apply) { + event.preventDefault(); + openApplyFlow(parsePayload(apply.getAttribute('data-aksrt-after-sale-apply'))); + return; + } + var detail = event.target.closest('[data-aksrt-after-sale-detail]'); + if (detail) { + event.preventDefault(); + openDetailFlow(parsePayload(detail.getAttribute('data-aksrt-after-sale-detail'))); + } + }); + } + + jQuery(document).off('auto_fun.aksrt-after-sale').on('auto_fun.aksrt-after-sale', initAfterSaleButtons); + // 委托监听不依赖 DOM 扫描,脚本加载时立即挂上;auto_fun 钩子保留作幂等重扫。 + initAfterSaleButtons(); +})(); diff --git a/assets/js/order-center.js b/assets/js/order-center.js index 68ea02cb..d56aee6e 100644 --- a/assets/js/order-center.js +++ b/assets/js/order-center.js @@ -1,756 +1,756 @@ -/** - * Knowledge-payment order center interactions. - * - * @package AKSRT - */ -(function () { - "use strict"; - - var instances = new WeakMap(); - var liveStates = []; - - function request(path, body, method, operationKey) { - var options = { method: method || "POST" }; - if (body !== undefined) options.body = JSON.stringify(body || {}); - if (operationKey) options.headers = { "Idempotency-Key": operationKey }; - return window.AksrtRequest.request(path, options); - } - - function orderPath(orderNumber, suffix) { - return "/orders/" + encodeURIComponent(orderNumber || "") + (suffix || ""); - } - - function errorMessage(error, fallback) { - if (error && error.message) return String(error.message); - return fallback || "请求失败"; - } - - function escapeHtml(value) { - return String(value == null ? "" : value) - .replace(/&/g, "&").replace(//g, ">").replace(/\"/g, """) - .replace(/'/g, "'"); - } - - function formatMoney(order) { - var raw = String(order && order.amount != null ? order.amount : "0"); - if (order && order.is_points) { - return escapeHtml(order.currency_symbol || "积分") + " " + (/^\d+$/.test(raw) ? raw : "0"); - } - var match = /^(0|[1-9]\d*)(?:\.(\d{1,2}))?$/.exec(raw); - var amount = match ? match[1] + "." + (match[2] || "").padEnd(2, "0") : "0.00"; - return "¥" + amount; - } - - function orderTag(orderNumber) { - return "order:" + String(orderNumber || "").toLowerCase(); - } - - function invalidateOrder(orderNumber, order) { - var tags = order && Array.isArray(order.invalidation_tags) ? order.invalidation_tags : [ - "orders", - orderTag(orderNumber), - ]; - window.AksrtModalRuntime.invalidate(tags); - } - - function activeDetail(state, number) { - return state.detailOrderNumber === number - && !!state.detailSession - && !state.detailSession.closed - && state.detailSession.options - && state.detailSession.options.kind === "orders" - && window.AksrtModalRuntime.getActive() === state.detailSession.shell; - } - - function detailSkeleton() { - return '
' + - '' + - '' + - '' + - '
'; - } - - function statusMeta(status) { - var map = { - pending: { icon: "fa-clock", desc: "订单已生成,请在有效期内完成支付" }, - paid: { icon: "fa-check-circle", desc: "支付已完成,相关权益已发放至账户" }, - refunded: { icon: "fa-undo-alt", desc: "退款已完成,款项将原路退回" }, - closed: { icon: "fa-times-circle", desc: "订单已关闭,如有需要可重新下单" }, - }; - return map[status] || map.closed; - } - - function amountCaption(status) { - if (status === "pending") return "应付款"; - if (status === "closed") return "订单金额"; - return "实付款"; - } - - function paymentMethodText(order) { - var method = order && order.payment_method ? order.payment_method : {}; - // 卡密核销不是真实收银渠道,直接回退的原始 key 对用户没有意义。 - if (method.value === "card_pass") return "卡密核销"; - return String(method.label || ""); - } - - function detailRow(label, value, copyable) { - var text = value == null ? "" : String(value); - var cell = '' + - escapeHtml(text || "—") + ""; - if (copyable && text !== "") { - cell += ''; - } - return "
" + escapeHtml(label) + "
" + cell + "
"; - } - - function gatewayResultText(value) { - if (!value) return ""; - var result = value; - if (typeof value === "string") { - try { result = JSON.parse(value); } catch (_) { return String(value); } - } - if (!result || typeof result !== "object") return String(result); - var labels = { succeeded: "退款成功", failed: "渠道明确拒绝", pending: "渠道处理中", unknown: "结果待确认" }; - var parts = []; - if (result.status) parts.push(labels[String(result.status)] || String(result.status)); - if (result.message) parts.push(String(result.message)); - if (result.refund_id) parts.push("退款单号 " + String(result.refund_id)); - return parts.join(" · ") || "渠道暂未提供可读结果"; - } - - function afterSaleAmount(value, order) { - var amount = String(value == null ? "" : value); - return order && order.is_points ? amount + " " + String(order.currency_symbol || "积分") : "¥" + amount; - } - - function colorfulHeader(title, icon) { - return ''; - } - - function renderDetail(state, order, session) { - var allowedActions = order.allowed_actions || {}; - var reconciliation = order.reconciliation || {}; - var paymentMethod = order.payment_method || {}; - var image = order.image || ""; - var title = order.product_name || "订单"; - var status = order.status_group || "closed"; - var meta = statusMeta(status); - var timeLeft = Number(order.time_left || 0); - var html = ""; - // 炫彩标题区:与售后弹窗统一(modal-mini + jb-blue),运行时不再渲染默认标题栏。 - html += colorfulHeader("订单详情", "fa-receipt"); - - // 状态横幅:整块配色只由 --hero-accent 驱动,换状态只改一处变量。 - html += '
'; - html += ''; - html += '
'; - html += '' + escapeHtml(order.status_label || "") + ""; - html += '

' + escapeHtml(meta.desc) + "

"; - if (status === "pending" && timeLeft > 0) { - html += '剩余支付时间 ' + timeLeft + " 秒"; - } - html += "
"; - html += '
' + formatMoney(order) + ""; - html += "" + escapeHtml(amountCaption(status)) + "
"; - - html += '
'; - html += '

商品信息

'; - html += '
'; - html += image - ? '' - : ''; - html += '
'; - html += '' + escapeHtml(title) + ""; - html += '' + escapeHtml(order.product_type_label || "") + ""; - html += "
"; - - html += '
'; - html += '

交易信息

'; - html += '
'; - [ - ["订单号", order.order_number], - ["支付方式", paymentMethodText(order)], - ["创建时间", order.created_at], - ["支付时间", order.paid_at], - ].forEach(function (row) { - html += detailRow(row[0], row[1], row[0] === "订单号"); - }); - html += "
"; - - var afterSales = Array.isArray(order.after_sales) ? order.after_sales : []; - if (afterSales.length) { - html += '

售后记录

"; - } - - if (reconciliation.state === "refresh_available") { - html += '
'; - html += ''; - html += "

支付状态可能仍在同步,请刷新后查看。

"; - html += ''; - html += "
"; - } - - // 底部按钮与禁封申诉弹窗同式:but-average modal-buts + .but / .but c-blue。 - html += '"; - - session.content.innerHTML = html; - } - - function detailOptions(state, orderNumber, refresh) { - var actionKey = refresh ? "refresh:" + orderNumber : ""; - if (actionKey && !state.operationKeys[actionKey]) state.operationKeys[actionKey] = window.AksrtRequest.createIdempotencyKey(); - return { - url: orderPath(orderNumber, refresh ? "/refresh" : ""), - method: refresh ? "POST" : "GET", - headers: actionKey ? { "Idempotency-Key": state.operationKeys[actionKey] } : {}, - kind: "orders", - className: "aksrt-order-detail-modal", - responseType: "json", - cache: !refresh, - cacheKey: "orders:detail:" + orderNumber, - cacheTags: ["orders", orderTag(orderNumber)], - skeleton: detailSkeleton(), - onState: function (_, session) { - if (orderNumber === state.detailOrderNumber) state.detailSession = session; - }, - renderData: function (data, session) { - if (orderNumber !== state.detailOrderNumber || !instances.has(state.root)) return false; - state.detailSession = session; - if (!data || typeof data !== "object") { - session.content.innerHTML = '

订单不存在

'; - return; - } - renderDetail(state, data, session); - }, - onData: function (data) { - if (refresh) { - delete state.operationKeys[actionKey]; - invalidateOrder(orderNumber, data); - } - }, - }; - } - - function openDetail(state, orderNumber) { - if (!orderNumber) return; - state.detailSession = null; - state.detailReplacement = false; - state.detailReplacementSession = null; - state.detailOrderNumber = orderNumber; - window.AksrtModalRuntime.openRemote(detailOptions(state, orderNumber, false)); - } - - function refreshDetail(state, orderNumber) { - if (!orderNumber || !activeDetail(state, orderNumber)) return; - state.detailReplacement = true; - state.detailReplacementSession = state.detailSession; - var refreshPromise = window.AksrtModalRuntime.openRemote(detailOptions(state, orderNumber, true)); - refreshPromise.then(function () { - state.detailReplacement = false; - state.detailReplacementSession = null; - }, function () { - state.detailReplacement = false; - state.detailReplacementSession = null; - }); - } - - function currentViewPath(state) { - var query = new URLSearchParams(); - if (state.query.status && state.query.status !== "all") query.set("order_status", state.query.status); - if (state.query.type) query.set("order_type", state.query.type); - if (state.query.orderNumber) query.set("order_number", state.query.orderNumber); - if (Number(state.query.page) > 1) query.set("order_page", String(state.query.page)); - return "/orders/view" + (query.toString() ? "?" + query.toString() : ""); - } - - function actionHtml(order) { - var allowedActions = order.allowed_actions || {}; - var afterSale = order.after_sale || {}; - var html = ''; - if (allowedActions.can_cancel) html += ''; - if (allowedActions.can_pay) html += ''; - // 售后入口:申请售后 / 售后详细走 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 += ''; - return html; - } - html += ''; - return html; - } - - function cardHtml(order) { - var image = order.image - ? '' - : ''; - var countdown = Number(order.time_left || 0) > 0 - ? '' - : ""; - return '
' + - '
' + escapeHtml(order.product_type_label || "") + '' + escapeHtml(order.status_label || "") + "
" + - '
' + image + '

' + escapeHtml(order.product_name || "订单") + "

订单号 " + escapeHtml(order.order_number) + "

" + formatMoney(order) + "实付
" + - '
"; - } - - function renderStats(state, stats) { - var root = state.root.querySelector("[data-aksrt-order-stats]"); - if (!root) return; - root.innerHTML = '
全部' + Number(stats.total || 0) + "
" + - '
待支付' + Number(stats.pending || 0) + "
" + - '
已支付' + Number(stats.paid || 0) + "
" + - '
已退款' + Number(stats.refunded || 0) + "
" + - '
已关闭' + Number(stats.closed || 0) + "
" + - '
售后待处理' + Number(stats.after_sale_pending || 0) + "
"; - var pendingCount = state.root.querySelector('[data-aksrt-order-filter="status"][data-aksrt-order-filter-value="pending"] .aksrt-orders-page__filter-count'); - if (pendingCount) { - pendingCount.textContent = String(Number(stats.pending || 0)); - pendingCount.hidden = Number(stats.pending || 0) < 1; - } - } - - function renderPagination(state, view) { - var existing = state.root.querySelector(".aksrt-orders-page__pagination"); - if (existing) existing.remove(); - if (Number(view.total_pages || 1) <= 1) return; - var root = document.createElement("nav"); - root.className = "aksrt-orders-page__pagination"; - root.setAttribute("aria-label", "订单分页"); - for (var i = 1; i <= Number(view.total_pages); i++) { - var button = document.createElement("button"); - button.type = "button"; - button.className = "aksrt-orders-page__page-chip" + (i === Number(view.page) ? " is-active" : ""); - button.setAttribute("data-aksrt-order-page", String(i)); - if (i === Number(view.page)) button.setAttribute("aria-current", "page"); - button.textContent = String(i); - root.appendChild(button); - } - var list = state.root.querySelector("[data-aksrt-order-list]"); - if (list) list.after(root); - } - - function countdownScopes(state) { - // 详情弹窗挂在 body 上,不在 state.root 内,需要单独纳入倒计时扫描。 - var scopes = [state.root]; - if (state.detailSession && state.detailSession.content) scopes.push(state.detailSession.content); - return scopes; - } - - function updateCountdowns(state) { - countdownScopes(state).forEach(function (scope) { - if (!scope || !scope.querySelectorAll) return; - scope.querySelectorAll("[data-aksrt-order-countdown]").forEach(function (node) { - var value = Math.max(0, Number(node.getAttribute("data-aksrt-order-countdown") || 0) - 1); - node.setAttribute("data-aksrt-order-countdown", String(value)); - node.textContent = value ? "剩余支付时间 " + value + " 秒" : "订单即将关闭"; - }); - }); - } - - function renderView(state, view) { - var viewQuery = view.query || {}; - state.query.status = String(viewQuery.order_status || "all"); - state.query.type = String(viewQuery.order_type || ""); - state.query.orderNumber = String(viewQuery.order_number || ""); - state.query.page = Math.max(1, Number(view.page || viewQuery.order_page || 1)); - renderStats(state, view.stats || {}); - var list = state.root.querySelector("[data-aksrt-order-list]"); - if (!list) return; - var items = Array.isArray(view.items) ? view.items : []; - if (!items.length) { - list.innerHTML = '

没有匹配的订单

换个条件试试,或清除当前筛选。

'; - list.classList.remove("aksrt-orders-page__list"); - list.classList.add("aksrt-orders-page__empty"); - } else { - list.classList.remove("aksrt-orders-page__empty"); - list.classList.add("aksrt-orders-page__list"); - list.innerHTML = items.map(cardHtml).join(""); - } - renderPagination(state, view); - syncControls(state); - updateCountdowns(state); - } - - function syncControls(state) { - state.root.querySelectorAll("[data-aksrt-order-filter]").forEach(function (button) { - var field = button.getAttribute("data-aksrt-order-filter"); - var expected = field === "status" ? state.query.status : state.query.type; - var active = button.getAttribute("data-aksrt-order-filter-value") === expected; - button.classList.toggle("is-active", active); - button.setAttribute("aria-pressed", active ? "true" : "false"); - }); - var input = state.root.querySelector("#aksrt-order-number"); - if (input && input.value !== state.query.orderNumber) input.value = state.query.orderNumber; - } - - function setViewLoading(state, loading) { - state.root.classList.toggle("is-loading", loading); - state.root.setAttribute("aria-busy", loading ? "true" : "false"); - } - - function loadView(state, patch) { - Object.keys(patch || {}).forEach(function (key) { state.query[key] = patch[key]; }); - var requestId = ++state.viewRequest; - setViewLoading(state, true); - return request(currentViewPath(state), undefined, "GET").then(function (result) { - if (requestId !== state.viewRequest) return null; - if (!result || !result.data) throw new Error("刷新失败"); - renderView(state, result.data); - return result.data; - }).catch(function (error) { - if (requestId === state.viewRequest) notyf(errorMessage(error, "订单加载失败,请稍后重试"), "danger"); - return null; - }).finally(function () { - if (requestId === state.viewRequest) setViewLoading(state, false); - }); - } - - function refreshView(state) { - return loadView(state); - } - - function startPayment(state, button) { - var number = button.getAttribute("data-aksrt-order-pay") || ""; - var method = button.getAttribute("data-aksrt-order-method") || ""; - if (!number || button.disabled) return; - var actionKey = "pay:" + number; - if (!state.operationKeys[actionKey]) state.operationKeys[actionKey] = window.AksrtRequest.createIdempotencyKey(); - button.disabled = true; - request(orderPath(number, "/pay"), method ? { payment_method: method } : {}, "POST", state.operationKeys[actionKey]).then(function (result) { - delete state.operationKeys[actionKey]; - var data = result.data || {}; - invalidateOrder(number, data.order); - if (data.redirect && data.payment_url) { - window.location.assign(data.payment_url); - return; - } - notyf(data.message || "支付成功", "success"); - refreshView(state); - if (activeDetail(state, number)) openDetail(state, number); - }).catch(function (error) { - button.disabled = false; - notyf(errorMessage(error, "网络错误,请稍后重试"), "danger"); - }); - } - - function closeOrder(state, button) { - var number = button.getAttribute("data-aksrt-order-cancel") || ""; - if (!number || button.disabled) return; - if (button.getAttribute("data-aksrt-confirm") !== "true") { - button.setAttribute("data-aksrt-confirm", "true"); - button.textContent = "确认关闭"; - return; - } - button.disabled = true; - var actionKey = "cancel:" + number; - if (!state.operationKeys[actionKey]) state.operationKeys[actionKey] = window.AksrtRequest.createIdempotencyKey(); - request(orderPath(number, "/cancel"), {}, "POST", state.operationKeys[actionKey]).then(function (result) { - delete state.operationKeys[actionKey]; - var data = result.data || {}; - invalidateOrder(number, data.order); - notyf("订单已关闭", "success"); - refreshView(state); - if (activeDetail(state, number)) openDetail(state, number); - }).catch(function (error) { - button.disabled = false; - button.removeAttribute("data-aksrt-confirm"); - button.textContent = "关闭订单"; - notyf(errorMessage(error, "网络错误,请稍后重试"), "danger"); - }); - } - - function copyOrderValue(button) { - var value = button.getAttribute("data-aksrt-order-copy") || ""; - if (!value) return; - var icon = button.querySelector("i"); - var settle = function (ok) { - notyf(ok ? "订单号已复制" : "复制失败,请手动选择复制", ok ? "success" : "danger"); - if (!ok) return; - button.classList.add("is-copied"); - if (icon) icon.className = "fa fas fa-check"; - window.setTimeout(function () { - button.classList.remove("is-copied"); - if (icon) icon.className = "fa fas fa-copy"; - }, 1600); - }; - var fallback = function () { - try { - var field = document.createElement("textarea"); - field.value = value; - field.setAttribute("readonly", "readonly"); - field.style.position = "fixed"; - field.style.top = "-1000px"; - field.style.opacity = "0"; - document.body.appendChild(field); - field.select(); - document.execCommand("copy"); - document.body.removeChild(field); - settle(true); - } catch (error) { - settle(false); - } - }; - if (navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard.writeText(value).then(function () { settle(true); }, fallback); - return; - } - fallback(); - } - - function closest(target, selector) { - return target && target.closest ? target.closest(selector) : null; - } - - function initRoot(root) { - if (instances.has(root)) return; - var state = { - root: root, - detailOrderNumber: "", - detailSession: null, - detailReplacement: false, - detailReplacementSession: null, - countdownTimer: null, - operationKeys: {}, - query: { status: "all", type: "", orderNumber: "", page: 1 }, - viewRequest: 0, - }; - - state.onRootClick = function (event) { - var filterToggle = closest(event.target, "[data-aksrt-orders-filter-toggle]"); - if (filterToggle && root.contains(filterToggle)) { - var panel = root.querySelector("[data-aksrt-orders-filter-panel]"); - var expanded = filterToggle.getAttribute("aria-expanded") === "true"; - filterToggle.setAttribute("aria-expanded", String(!expanded)); - if (panel) panel.hidden = expanded; - return; - } - var filter = closest(event.target, "[data-aksrt-order-filter]"); - if (filter && root.contains(filter)) { - event.preventDefault(); - var field = filter.getAttribute("data-aksrt-order-filter"); - var patch = { page: 1 }; - patch[field] = filter.getAttribute("data-aksrt-order-filter-value") || ""; - loadView(state, patch); - return; - } - var page = closest(event.target, "[data-aksrt-order-page]"); - if (page && root.contains(page)) { - event.preventDefault(); - loadView(state, { page: Math.max(1, Number(page.getAttribute("data-aksrt-order-page") || 1)) }); - return; - } - var clear = closest(event.target, "[data-aksrt-orders-clear]"); - if (clear && root.contains(clear)) { - event.preventDefault(); - loadView(state, { status: "all", type: "", orderNumber: "", page: 1 }); - return; - } - var detail = closest(event.target, "[data-aksrt-order-detail]"); - if (detail && root.contains(detail)) { openDetail(state, detail.getAttribute("data-aksrt-order-detail")); return; } - var pay = closest(event.target, "[data-aksrt-order-pay]"); - if (pay && root.contains(pay)) { event.preventDefault(); startPayment(state, pay); return; } - var cancel = closest(event.target, "[data-aksrt-order-cancel]"); - if (cancel && root.contains(cancel)) { event.preventDefault(); closeOrder(state, cancel); } - }; - state.onRootSubmit = function (event) { - var form = closest(event.target, "[data-aksrt-orders-search]"); - if (!form || !root.contains(form)) return; - event.preventDefault(); - var input = form.querySelector("#aksrt-order-number"); - loadView(state, { orderNumber: input ? String(input.value || "").trim() : "", page: 1 }); - }; - state.onModalClose = function (event) { - var detail = event && event.detail ? event.detail : {}; - if (!state.detailSession || detail.session !== state.detailSession) return; - if (detail.reason === "replace" && state.detailReplacement && detail.session === state.detailReplacementSession) return; - state.detailSession = null; - state.detailOrderNumber = ""; - state.detailReplacement = false; - state.detailReplacementSession = null; - }; - state.onDocumentClick = function (event) { - var afterSaleCancel = closest(event.target, "[data-aksrt-after-sale-cancel]"); - if (afterSaleCancel && !event.__aksrtAfterSaleCancelHandled) { - var afterSaleNumber = afterSaleCancel.getAttribute("data-aksrt-after-sale-cancel") || ""; - if (activeDetail(state, afterSaleNumber)) { - event.__aksrtAfterSaleCancelHandled = true; - event.preventDefault(); - afterSaleCancel.disabled = true; - request(orderPath(afterSaleNumber, "/after-sale"), undefined, "DELETE", window.AksrtRequest.createIdempotencyKey()).then(function () { - notyf("售后申请已取消", "success"); - invalidateOrder(afterSaleNumber); - openDetail(state, afterSaleNumber); - refreshView(state); - }).catch(function (error) { - afterSaleCancel.disabled = false; - notyf(errorMessage(error, "取消申请失败"), "danger"); - }); - return; - } - } - if (!state.detailSession || window.AksrtModalRuntime.getActive() !== state.detailSession.shell) return; - var copy = closest(event.target, "[data-aksrt-order-copy]"); - if (copy) { - event.preventDefault(); - copyOrderValue(copy); - return; - } - var refresh = closest(event.target, "[data-aksrt-order-refresh]"); - if (refresh) { - event.preventDefault(); - refreshDetail(state, refresh.getAttribute("data-aksrt-order-refresh") || ""); - return; - } - var pay = closest(event.target, "[data-aksrt-order-pay]"); - if (pay && !root.contains(pay)) { - event.preventDefault(); - startPayment(state, pay); - return; - } - var cancel = closest(event.target, "[data-aksrt-order-cancel]"); - if (cancel && !root.contains(cancel)) { - event.preventDefault(); - closeOrder(state, cancel); - } - }; - - root.addEventListener("click", state.onRootClick); - root.addEventListener("submit", state.onRootSubmit); - document.addEventListener("aksrt:modal:close", state.onModalClose); - document.addEventListener("click", state.onDocumentClick); - state.countdownTimer = window.setInterval(function () { updateCountdowns(state); }, 1000); - instances.set(root, state); - liveStates.push(state); - } - - function rootsIn(root) { - if (!root) return []; - var roots = []; - if (root.matches && root.matches("[data-aksrt-order-center]")) roots.push(root); - if (root.querySelectorAll) root.querySelectorAll("[data-aksrt-order-center]").forEach(function (item) { roots.push(item); }); - return roots; - } - - function init(root) { - rootsIn(root || document).forEach(initRoot); - } - - function destroy(root) { - rootsIn(root).forEach(function (item) { - var state = instances.get(item); - if (!state) return; - if (activeDetail(state, state.detailOrderNumber)) window.AksrtModalRuntime.close("replace"); - item.removeEventListener("click", state.onRootClick); - item.removeEventListener("submit", state.onRootSubmit); - document.removeEventListener("aksrt:modal:close", state.onModalClose); - document.removeEventListener("click", state.onDocumentClick); - window.clearInterval(state.countdownTimer); - instances.delete(item); - liveStates = liveStates.filter(function (entry) { return entry !== state; }); - }); - } - - // refresh 供售后弹窗(order-after-sale.js)提交/取消后刷新列表与统计。 - function refresh(orderNumber) { - invalidateOrder(orderNumber); - liveStates.forEach(function (state) { - refreshView(state); - }); - } - - window.AKSRTOrderCenter = { init: init, destroy: destroy, refresh: refresh }; - jQuery(document).off("auto_fun.aksrt-order-center").on("auto_fun.aksrt-order-center", function () { init(document); }); -}()); +/** + * Knowledge-payment order center interactions. + * + * @package AKSRT + */ +(function () { + "use strict"; + + var instances = new WeakMap(); + var liveStates = []; + + function request(path, body, method, operationKey) { + var options = { method: method || "POST" }; + if (body !== undefined) options.body = JSON.stringify(body || {}); + if (operationKey) options.headers = { "Idempotency-Key": operationKey }; + return window.AksrtRequest.request(path, options); + } + + function orderPath(orderNumber, suffix) { + return "/orders/" + encodeURIComponent(orderNumber || "") + (suffix || ""); + } + + function errorMessage(error, fallback) { + if (error && error.message) return String(error.message); + return fallback || "请求失败"; + } + + function escapeHtml(value) { + return String(value == null ? "" : value) + .replace(/&/g, "&").replace(//g, ">").replace(/\"/g, """) + .replace(/'/g, "'"); + } + + function formatMoney(order) { + var raw = String(order && order.amount != null ? order.amount : "0"); + if (order && order.is_points) { + return escapeHtml(order.currency_symbol || "积分") + " " + (/^\d+$/.test(raw) ? raw : "0"); + } + var match = /^(0|[1-9]\d*)(?:\.(\d{1,2}))?$/.exec(raw); + var amount = match ? match[1] + "." + (match[2] || "").padEnd(2, "0") : "0.00"; + return "¥" + amount; + } + + function orderTag(orderNumber) { + return "order:" + String(orderNumber || "").toLowerCase(); + } + + function invalidateOrder(orderNumber, order) { + var tags = order && Array.isArray(order.invalidation_tags) ? order.invalidation_tags : [ + "orders", + orderTag(orderNumber), + ]; + window.AksrtModalRuntime.invalidate(tags); + } + + function activeDetail(state, number) { + return state.detailOrderNumber === number + && !!state.detailSession + && !state.detailSession.closed + && state.detailSession.options + && state.detailSession.options.kind === "orders" + && window.AksrtModalRuntime.getActive() === state.detailSession.shell; + } + + function detailSkeleton() { + return '
' + + '' + + '' + + '' + + '
'; + } + + function statusMeta(status) { + var map = { + pending: { icon: "fa-clock", desc: "订单已生成,请在有效期内完成支付" }, + paid: { icon: "fa-check-circle", desc: "支付已完成,相关权益已发放至账户" }, + refunded: { icon: "fa-undo-alt", desc: "退款已完成,款项将原路退回" }, + closed: { icon: "fa-times-circle", desc: "订单已关闭,如有需要可重新下单" }, + }; + return map[status] || map.closed; + } + + function amountCaption(status) { + if (status === "pending") return "应付款"; + if (status === "closed") return "订单金额"; + return "实付款"; + } + + function paymentMethodText(order) { + var method = order && order.payment_method ? order.payment_method : {}; + // 卡密核销不是真实收银渠道,直接回退的原始 key 对用户没有意义。 + if (method.value === "card_pass") return "卡密核销"; + return String(method.label || ""); + } + + function detailRow(label, value, copyable) { + var text = value == null ? "" : String(value); + var cell = '' + + escapeHtml(text || "—") + ""; + if (copyable && text !== "") { + cell += ''; + } + return "
" + escapeHtml(label) + "
" + cell + "
"; + } + + function gatewayResultText(value) { + if (!value) return ""; + var result = value; + if (typeof value === "string") { + try { result = JSON.parse(value); } catch (_) { return String(value); } + } + if (!result || typeof result !== "object") return String(result); + var labels = { succeeded: "退款成功", failed: "渠道明确拒绝", pending: "渠道处理中", unknown: "结果待确认" }; + var parts = []; + if (result.status) parts.push(labels[String(result.status)] || String(result.status)); + if (result.message) parts.push(String(result.message)); + if (result.refund_id) parts.push("退款单号 " + String(result.refund_id)); + return parts.join(" · ") || "渠道暂未提供可读结果"; + } + + function afterSaleAmount(value, order) { + var amount = String(value == null ? "" : value); + return order && order.is_points ? amount + " " + String(order.currency_symbol || "积分") : "¥" + amount; + } + + function colorfulHeader(title, icon) { + return ''; + } + + function renderDetail(state, order, session) { + var allowedActions = order.allowed_actions || {}; + var reconciliation = order.reconciliation || {}; + var paymentMethod = order.payment_method || {}; + var image = order.image || ""; + var title = order.product_name || "订单"; + var status = order.status_group || "closed"; + var meta = statusMeta(status); + var timeLeft = Number(order.time_left || 0); + var html = ""; + // 炫彩标题区:与售后弹窗统一(modal-mini + jb-blue),运行时不再渲染默认标题栏。 + html += colorfulHeader("订单详情", "fa-receipt"); + + // 状态横幅:整块配色只由 --hero-accent 驱动,换状态只改一处变量。 + html += '
'; + html += ''; + html += '
'; + html += '' + escapeHtml(order.status_label || "") + ""; + html += '

' + escapeHtml(meta.desc) + "

"; + if (status === "pending" && timeLeft > 0) { + html += '剩余支付时间 ' + timeLeft + " 秒"; + } + html += "
"; + html += '
' + formatMoney(order) + ""; + html += "" + escapeHtml(amountCaption(status)) + "
"; + + html += '
'; + html += '

商品信息

'; + html += '
'; + html += image + ? '' + : ''; + html += '
'; + html += '' + escapeHtml(title) + ""; + html += '' + escapeHtml(order.product_type_label || "") + ""; + html += "
"; + + html += '
'; + html += '

交易信息

'; + html += '
'; + [ + ["订单号", order.order_number], + ["支付方式", paymentMethodText(order)], + ["创建时间", order.created_at], + ["支付时间", order.paid_at], + ].forEach(function (row) { + html += detailRow(row[0], row[1], row[0] === "订单号"); + }); + html += "
"; + + var afterSales = Array.isArray(order.after_sales) ? order.after_sales : []; + if (afterSales.length) { + html += '

售后记录

"; + } + + if (reconciliation.state === "refresh_available") { + html += '
'; + html += ''; + html += "

支付状态可能仍在同步,请刷新后查看。

"; + html += ''; + html += "
"; + } + + // 底部按钮与禁封申诉弹窗同式:but-average modal-buts + .but / .but c-blue。 + html += '"; + + session.content.innerHTML = html; + } + + function detailOptions(state, orderNumber, refresh) { + var actionKey = refresh ? "refresh:" + orderNumber : ""; + if (actionKey && !state.operationKeys[actionKey]) state.operationKeys[actionKey] = window.AksrtRequest.createIdempotencyKey(); + return { + url: orderPath(orderNumber, refresh ? "/refresh" : ""), + method: refresh ? "POST" : "GET", + headers: actionKey ? { "Idempotency-Key": state.operationKeys[actionKey] } : {}, + kind: "orders", + className: "aksrt-order-detail-modal", + responseType: "json", + cache: !refresh, + cacheKey: "orders:detail:" + orderNumber, + cacheTags: ["orders", orderTag(orderNumber)], + skeleton: detailSkeleton(), + onState: function (_, session) { + if (orderNumber === state.detailOrderNumber) state.detailSession = session; + }, + renderData: function (data, session) { + if (orderNumber !== state.detailOrderNumber || !instances.has(state.root)) return false; + state.detailSession = session; + if (!data || typeof data !== "object") { + session.content.innerHTML = '

订单不存在

'; + return; + } + renderDetail(state, data, session); + }, + onData: function (data) { + if (refresh) { + delete state.operationKeys[actionKey]; + invalidateOrder(orderNumber, data); + } + }, + }; + } + + function openDetail(state, orderNumber) { + if (!orderNumber) return; + state.detailSession = null; + state.detailReplacement = false; + state.detailReplacementSession = null; + state.detailOrderNumber = orderNumber; + window.AksrtModalRuntime.openRemote(detailOptions(state, orderNumber, false)); + } + + function refreshDetail(state, orderNumber) { + if (!orderNumber || !activeDetail(state, orderNumber)) return; + state.detailReplacement = true; + state.detailReplacementSession = state.detailSession; + var refreshPromise = window.AksrtModalRuntime.openRemote(detailOptions(state, orderNumber, true)); + refreshPromise.then(function () { + state.detailReplacement = false; + state.detailReplacementSession = null; + }, function () { + state.detailReplacement = false; + state.detailReplacementSession = null; + }); + } + + function currentViewPath(state) { + var query = new URLSearchParams(); + if (state.query.status && state.query.status !== "all") query.set("order_status", state.query.status); + if (state.query.type) query.set("order_type", state.query.type); + if (state.query.orderNumber) query.set("order_number", state.query.orderNumber); + if (Number(state.query.page) > 1) query.set("order_page", String(state.query.page)); + return "/orders/view" + (query.toString() ? "?" + query.toString() : ""); + } + + function actionHtml(order) { + var allowedActions = order.allowed_actions || {}; + var afterSale = order.after_sale || {}; + var html = ''; + if (allowedActions.can_cancel) html += ''; + if (allowedActions.can_pay) html += ''; + // 售后入口:申请售后 / 售后详细走 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 += ''; + return html; + } + html += ''; + return html; + } + + function cardHtml(order) { + var image = order.image + ? '' + : ''; + var countdown = Number(order.time_left || 0) > 0 + ? '' + : ""; + return '
' + + '
' + escapeHtml(order.product_type_label || "") + '' + escapeHtml(order.status_label || "") + "
" + + '
' + image + '

' + escapeHtml(order.product_name || "订单") + "

订单号 " + escapeHtml(order.order_number) + "

" + formatMoney(order) + "实付
" + + '
"; + } + + function renderStats(state, stats) { + var root = state.root.querySelector("[data-aksrt-order-stats]"); + if (!root) return; + root.innerHTML = '
全部' + Number(stats.total || 0) + "
" + + '
待支付' + Number(stats.pending || 0) + "
" + + '
已支付' + Number(stats.paid || 0) + "
" + + '
已退款' + Number(stats.refunded || 0) + "
" + + '
已关闭' + Number(stats.closed || 0) + "
" + + '
售后待处理' + Number(stats.after_sale_pending || 0) + "
"; + var pendingCount = state.root.querySelector('[data-aksrt-order-filter="status"][data-aksrt-order-filter-value="pending"] .aksrt-orders-page__filter-count'); + if (pendingCount) { + pendingCount.textContent = String(Number(stats.pending || 0)); + pendingCount.hidden = Number(stats.pending || 0) < 1; + } + } + + function renderPagination(state, view) { + var existing = state.root.querySelector(".aksrt-orders-page__pagination"); + if (existing) existing.remove(); + if (Number(view.total_pages || 1) <= 1) return; + var root = document.createElement("nav"); + root.className = "aksrt-orders-page__pagination"; + root.setAttribute("aria-label", "订单分页"); + for (var i = 1; i <= Number(view.total_pages); i++) { + var button = document.createElement("button"); + button.type = "button"; + button.className = "aksrt-orders-page__page-chip" + (i === Number(view.page) ? " is-active" : ""); + button.setAttribute("data-aksrt-order-page", String(i)); + if (i === Number(view.page)) button.setAttribute("aria-current", "page"); + button.textContent = String(i); + root.appendChild(button); + } + var list = state.root.querySelector("[data-aksrt-order-list]"); + if (list) list.after(root); + } + + function countdownScopes(state) { + // 详情弹窗挂在 body 上,不在 state.root 内,需要单独纳入倒计时扫描。 + var scopes = [state.root]; + if (state.detailSession && state.detailSession.content) scopes.push(state.detailSession.content); + return scopes; + } + + function updateCountdowns(state) { + countdownScopes(state).forEach(function (scope) { + if (!scope || !scope.querySelectorAll) return; + scope.querySelectorAll("[data-aksrt-order-countdown]").forEach(function (node) { + var value = Math.max(0, Number(node.getAttribute("data-aksrt-order-countdown") || 0) - 1); + node.setAttribute("data-aksrt-order-countdown", String(value)); + node.textContent = value ? "剩余支付时间 " + value + " 秒" : "订单即将关闭"; + }); + }); + } + + function renderView(state, view) { + var viewQuery = view.query || {}; + state.query.status = String(viewQuery.order_status || "all"); + state.query.type = String(viewQuery.order_type || ""); + state.query.orderNumber = String(viewQuery.order_number || ""); + state.query.page = Math.max(1, Number(view.page || viewQuery.order_page || 1)); + renderStats(state, view.stats || {}); + var list = state.root.querySelector("[data-aksrt-order-list]"); + if (!list) return; + var items = Array.isArray(view.items) ? view.items : []; + if (!items.length) { + list.innerHTML = '

没有匹配的订单

换个条件试试,或清除当前筛选。

'; + list.classList.remove("aksrt-orders-page__list"); + list.classList.add("aksrt-orders-page__empty"); + } else { + list.classList.remove("aksrt-orders-page__empty"); + list.classList.add("aksrt-orders-page__list"); + list.innerHTML = items.map(cardHtml).join(""); + } + renderPagination(state, view); + syncControls(state); + updateCountdowns(state); + } + + function syncControls(state) { + state.root.querySelectorAll("[data-aksrt-order-filter]").forEach(function (button) { + var field = button.getAttribute("data-aksrt-order-filter"); + var expected = field === "status" ? state.query.status : state.query.type; + var active = button.getAttribute("data-aksrt-order-filter-value") === expected; + button.classList.toggle("is-active", active); + button.setAttribute("aria-pressed", active ? "true" : "false"); + }); + var input = state.root.querySelector("#aksrt-order-number"); + if (input && input.value !== state.query.orderNumber) input.value = state.query.orderNumber; + } + + function setViewLoading(state, loading) { + state.root.classList.toggle("is-loading", loading); + state.root.setAttribute("aria-busy", loading ? "true" : "false"); + } + + function loadView(state, patch) { + Object.keys(patch || {}).forEach(function (key) { state.query[key] = patch[key]; }); + var requestId = ++state.viewRequest; + setViewLoading(state, true); + return request(currentViewPath(state), undefined, "GET").then(function (result) { + if (requestId !== state.viewRequest) return null; + if (!result || !result.data) throw new Error("刷新失败"); + renderView(state, result.data); + return result.data; + }).catch(function (error) { + if (requestId === state.viewRequest) notyf(errorMessage(error, "订单加载失败,请稍后重试"), "danger"); + return null; + }).finally(function () { + if (requestId === state.viewRequest) setViewLoading(state, false); + }); + } + + function refreshView(state) { + return loadView(state); + } + + function startPayment(state, button) { + var number = button.getAttribute("data-aksrt-order-pay") || ""; + var method = button.getAttribute("data-aksrt-order-method") || ""; + if (!number || button.disabled) return; + var actionKey = "pay:" + number; + if (!state.operationKeys[actionKey]) state.operationKeys[actionKey] = window.AksrtRequest.createIdempotencyKey(); + button.disabled = true; + request(orderPath(number, "/pay"), method ? { payment_method: method } : {}, "POST", state.operationKeys[actionKey]).then(function (result) { + delete state.operationKeys[actionKey]; + var data = result.data || {}; + invalidateOrder(number, data.order); + if (data.redirect && data.payment_url) { + window.location.assign(data.payment_url); + return; + } + notyf(data.message || "支付成功", "success"); + refreshView(state); + if (activeDetail(state, number)) openDetail(state, number); + }).catch(function (error) { + button.disabled = false; + notyf(errorMessage(error, "网络错误,请稍后重试"), "danger"); + }); + } + + function closeOrder(state, button) { + var number = button.getAttribute("data-aksrt-order-cancel") || ""; + if (!number || button.disabled) return; + if (button.getAttribute("data-aksrt-confirm") !== "true") { + button.setAttribute("data-aksrt-confirm", "true"); + button.textContent = "确认关闭"; + return; + } + button.disabled = true; + var actionKey = "cancel:" + number; + if (!state.operationKeys[actionKey]) state.operationKeys[actionKey] = window.AksrtRequest.createIdempotencyKey(); + request(orderPath(number, "/cancel"), {}, "POST", state.operationKeys[actionKey]).then(function (result) { + delete state.operationKeys[actionKey]; + var data = result.data || {}; + invalidateOrder(number, data.order); + notyf("订单已关闭", "success"); + refreshView(state); + if (activeDetail(state, number)) openDetail(state, number); + }).catch(function (error) { + button.disabled = false; + button.removeAttribute("data-aksrt-confirm"); + button.textContent = "关闭订单"; + notyf(errorMessage(error, "网络错误,请稍后重试"), "danger"); + }); + } + + function copyOrderValue(button) { + var value = button.getAttribute("data-aksrt-order-copy") || ""; + if (!value) return; + var icon = button.querySelector("i"); + var settle = function (ok) { + notyf(ok ? "订单号已复制" : "复制失败,请手动选择复制", ok ? "success" : "danger"); + if (!ok) return; + button.classList.add("is-copied"); + if (icon) icon.className = "fa fas fa-check"; + window.setTimeout(function () { + button.classList.remove("is-copied"); + if (icon) icon.className = "fa fas fa-copy"; + }, 1600); + }; + var fallback = function () { + try { + var field = document.createElement("textarea"); + field.value = value; + field.setAttribute("readonly", "readonly"); + field.style.position = "fixed"; + field.style.top = "-1000px"; + field.style.opacity = "0"; + document.body.appendChild(field); + field.select(); + document.execCommand("copy"); + document.body.removeChild(field); + settle(true); + } catch (error) { + settle(false); + } + }; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(value).then(function () { settle(true); }, fallback); + return; + } + fallback(); + } + + function closest(target, selector) { + return target && target.closest ? target.closest(selector) : null; + } + + function initRoot(root) { + if (instances.has(root)) return; + var state = { + root: root, + detailOrderNumber: "", + detailSession: null, + detailReplacement: false, + detailReplacementSession: null, + countdownTimer: null, + operationKeys: {}, + query: { status: "all", type: "", orderNumber: "", page: 1 }, + viewRequest: 0, + }; + + state.onRootClick = function (event) { + var filterToggle = closest(event.target, "[data-aksrt-orders-filter-toggle]"); + if (filterToggle && root.contains(filterToggle)) { + var panel = root.querySelector("[data-aksrt-orders-filter-panel]"); + var expanded = filterToggle.getAttribute("aria-expanded") === "true"; + filterToggle.setAttribute("aria-expanded", String(!expanded)); + if (panel) panel.hidden = expanded; + return; + } + var filter = closest(event.target, "[data-aksrt-order-filter]"); + if (filter && root.contains(filter)) { + event.preventDefault(); + var field = filter.getAttribute("data-aksrt-order-filter"); + var patch = { page: 1 }; + patch[field] = filter.getAttribute("data-aksrt-order-filter-value") || ""; + loadView(state, patch); + return; + } + var page = closest(event.target, "[data-aksrt-order-page]"); + if (page && root.contains(page)) { + event.preventDefault(); + loadView(state, { page: Math.max(1, Number(page.getAttribute("data-aksrt-order-page") || 1)) }); + return; + } + var clear = closest(event.target, "[data-aksrt-orders-clear]"); + if (clear && root.contains(clear)) { + event.preventDefault(); + loadView(state, { status: "all", type: "", orderNumber: "", page: 1 }); + return; + } + var detail = closest(event.target, "[data-aksrt-order-detail]"); + if (detail && root.contains(detail)) { openDetail(state, detail.getAttribute("data-aksrt-order-detail")); return; } + var pay = closest(event.target, "[data-aksrt-order-pay]"); + if (pay && root.contains(pay)) { event.preventDefault(); startPayment(state, pay); return; } + var cancel = closest(event.target, "[data-aksrt-order-cancel]"); + if (cancel && root.contains(cancel)) { event.preventDefault(); closeOrder(state, cancel); } + }; + state.onRootSubmit = function (event) { + var form = closest(event.target, "[data-aksrt-orders-search]"); + if (!form || !root.contains(form)) return; + event.preventDefault(); + var input = form.querySelector("#aksrt-order-number"); + loadView(state, { orderNumber: input ? String(input.value || "").trim() : "", page: 1 }); + }; + state.onModalClose = function (event) { + var detail = event && event.detail ? event.detail : {}; + if (!state.detailSession || detail.session !== state.detailSession) return; + if (detail.reason === "replace" && state.detailReplacement && detail.session === state.detailReplacementSession) return; + state.detailSession = null; + state.detailOrderNumber = ""; + state.detailReplacement = false; + state.detailReplacementSession = null; + }; + state.onDocumentClick = function (event) { + var afterSaleCancel = closest(event.target, "[data-aksrt-after-sale-cancel]"); + if (afterSaleCancel && !event.__aksrtAfterSaleCancelHandled) { + var afterSaleNumber = afterSaleCancel.getAttribute("data-aksrt-after-sale-cancel") || ""; + if (activeDetail(state, afterSaleNumber)) { + event.__aksrtAfterSaleCancelHandled = true; + event.preventDefault(); + afterSaleCancel.disabled = true; + request(orderPath(afterSaleNumber, "/after-sale"), undefined, "DELETE", window.AksrtRequest.createIdempotencyKey()).then(function () { + notyf("售后申请已取消", "success"); + invalidateOrder(afterSaleNumber); + openDetail(state, afterSaleNumber); + refreshView(state); + }).catch(function (error) { + afterSaleCancel.disabled = false; + notyf(errorMessage(error, "取消申请失败"), "danger"); + }); + return; + } + } + if (!state.detailSession || window.AksrtModalRuntime.getActive() !== state.detailSession.shell) return; + var copy = closest(event.target, "[data-aksrt-order-copy]"); + if (copy) { + event.preventDefault(); + copyOrderValue(copy); + return; + } + var refresh = closest(event.target, "[data-aksrt-order-refresh]"); + if (refresh) { + event.preventDefault(); + refreshDetail(state, refresh.getAttribute("data-aksrt-order-refresh") || ""); + return; + } + var pay = closest(event.target, "[data-aksrt-order-pay]"); + if (pay && !root.contains(pay)) { + event.preventDefault(); + startPayment(state, pay); + return; + } + var cancel = closest(event.target, "[data-aksrt-order-cancel]"); + if (cancel && !root.contains(cancel)) { + event.preventDefault(); + closeOrder(state, cancel); + } + }; + + root.addEventListener("click", state.onRootClick); + root.addEventListener("submit", state.onRootSubmit); + document.addEventListener("aksrt:modal:close", state.onModalClose); + document.addEventListener("click", state.onDocumentClick); + state.countdownTimer = window.setInterval(function () { updateCountdowns(state); }, 1000); + instances.set(root, state); + liveStates.push(state); + } + + function rootsIn(root) { + if (!root) return []; + var roots = []; + if (root.matches && root.matches("[data-aksrt-order-center]")) roots.push(root); + if (root.querySelectorAll) root.querySelectorAll("[data-aksrt-order-center]").forEach(function (item) { roots.push(item); }); + return roots; + } + + function init(root) { + rootsIn(root || document).forEach(initRoot); + } + + function destroy(root) { + rootsIn(root).forEach(function (item) { + var state = instances.get(item); + if (!state) return; + if (activeDetail(state, state.detailOrderNumber)) window.AksrtModalRuntime.close("replace"); + item.removeEventListener("click", state.onRootClick); + item.removeEventListener("submit", state.onRootSubmit); + document.removeEventListener("aksrt:modal:close", state.onModalClose); + document.removeEventListener("click", state.onDocumentClick); + window.clearInterval(state.countdownTimer); + instances.delete(item); + liveStates = liveStates.filter(function (entry) { return entry !== state; }); + }); + } + + // refresh 供售后弹窗(order-after-sale.js)提交/取消后刷新列表与统计。 + function refresh(orderNumber) { + invalidateOrder(orderNumber); + liveStates.forEach(function (state) { + refreshView(state); + }); + } + + window.AKSRTOrderCenter = { init: init, destroy: destroy, refresh: refresh }; + jQuery(document).off("auto_fun.aksrt-order-center").on("auto_fun.aksrt-order-center", function () { init(document); }); +}()); diff --git a/src/Core/DBMigrator.php b/src/Core/DBMigrator.php index d36ded0d..e6e1acf0 100644 --- a/src/Core/DBMigrator.php +++ b/src/Core/DBMigrator.php @@ -1,44 +1,44 @@ -get_charset_collate(); - $prefix = $wpdb->prefix; - - // ---------- 1. 统一资产账户与不可变账本 ---------- - $sql_asset_account = "CREATE TABLE {$prefix}aksrt_asset_account ( - user_id BIGINT UNSIGNED NOT NULL, - asset VARCHAR(16) NOT NULL, - balance_minor BIGINT UNSIGNED NOT NULL DEFAULT 0, - version BIGINT UNSIGNED NOT NULL DEFAULT 0, - updated_at DATETIME(6) NOT NULL, - PRIMARY KEY (user_id, asset), - KEY idx_asset_balance (asset, balance_minor) - ) ENGINE=InnoDB {$charset_collate};"; - dbDelta($sql_asset_account); - - $sql_asset_transaction = "CREATE TABLE {$prefix}aksrt_asset_transaction ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - operation_key VARCHAR(191) NOT NULL, - payload_hash CHAR(64) NOT NULL, - reason VARCHAR(64) NOT NULL, - ref_type VARCHAR(32) DEFAULT NULL, - ref_key VARCHAR(191) DEFAULT NULL, - actor_user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, - metadata LONGTEXT DEFAULT NULL, - ip VARCHAR(64) DEFAULT NULL, - ua VARCHAR(255) DEFAULT NULL, - created_at DATETIME(6) NOT NULL, - PRIMARY KEY (id), - UNIQUE KEY uniq_operation_key (operation_key), - KEY idx_reason_time (reason, created_at), - KEY idx_reference (ref_type, ref_key) - ) ENGINE=InnoDB {$charset_collate};"; - dbDelta($sql_asset_transaction); - - $sql_asset_entry = "CREATE TABLE {$prefix}aksrt_asset_entry ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - transaction_id BIGINT UNSIGNED NOT NULL, - line_no SMALLINT UNSIGNED NOT NULL, - user_id BIGINT UNSIGNED NOT NULL, - asset VARCHAR(16) NOT NULL, - entry_type VARCHAR(32) NOT NULL, - delta_minor BIGINT NOT NULL, - balance_after_minor BIGINT UNSIGNED NOT NULL, - counterparty_user_id BIGINT UNSIGNED DEFAULT NULL, - remark VARCHAR(255) DEFAULT NULL, - created_at DATETIME(6) NOT NULL, - PRIMARY KEY (id), - UNIQUE KEY uniq_transaction_line (transaction_id, line_no), - KEY idx_user_asset_time (user_id, asset, created_at, id), - KEY idx_transaction (transaction_id) - ) ENGINE=InnoDB {$charset_collate};"; - dbDelta($sql_asset_entry); - - // 后台「资金流水」页的默认查询是 WHERE 1=1 ORDER BY created_at DESC, id DESC LIMIT n - // 外加同条件的 COUNT(*),且界面上的日期区间筛选也只有 created_at 可用。 - // 索引必须能以 created_at 为前导列,否则每次翻页都是全表扫描 + filesort, - // 而 idx_flow_event_time 的前导列是 event_type(筛选时可空),救不了这两条查询。 - $sql_financial_flow = "CREATE TABLE {$prefix}aksrt_financial_flow ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - flow_number VARCHAR(32) NOT NULL, - operation_key VARCHAR(191) NOT NULL, - event_type VARCHAR(64) NOT NULL, - ref_type VARCHAR(32) DEFAULT NULL, - ref_key VARCHAR(191) DEFAULT NULL, - order_number VARCHAR(32) DEFAULT NULL, - withdrawal_number VARCHAR(32) DEFAULT NULL, - ledger_transaction_id BIGINT UNSIGNED DEFAULT NULL, - provider_transaction_id VARCHAR(128) DEFAULT NULL, - metadata LONGTEXT DEFAULT NULL, - created_at DATETIME(6) NOT NULL, - PRIMARY KEY (id), - UNIQUE KEY uniq_flow_number (flow_number), - UNIQUE KEY uniq_flow_operation (operation_key), - UNIQUE KEY uniq_flow_ledger_transaction (ledger_transaction_id), - KEY idx_flow_created (created_at), - KEY idx_flow_event_time (event_type, created_at), - KEY idx_flow_order (order_number), - KEY idx_flow_withdrawal (withdrawal_number), - KEY idx_flow_reference (ref_type, ref_key), - KEY idx_flow_provider_transaction (provider_transaction_id) - ) ENGINE=InnoDB {$charset_collate};"; - dbDelta($sql_financial_flow); - - $sql_financial_flow_entry = "CREATE TABLE {$prefix}aksrt_financial_flow_entry ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - flow_id BIGINT UNSIGNED NOT NULL, - line_no SMALLINT UNSIGNED NOT NULL, - user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, - asset VARCHAR(16) NOT NULL, - direction VARCHAR(8) NOT NULL, - entry_type VARCHAR(32) NOT NULL, - delta_minor BIGINT NOT NULL, - balance_after_minor BIGINT UNSIGNED DEFAULT NULL, - counterparty_user_id BIGINT UNSIGNED DEFAULT NULL, - remark VARCHAR(255) DEFAULT NULL, - PRIMARY KEY (id), - UNIQUE KEY uniq_flow_line (flow_id, line_no), - KEY idx_flow_entry_user (user_id, flow_id), - KEY idx_flow_entry_asset (asset, flow_id), - KEY idx_flow_entry_direction (direction, flow_id), - KEY idx_flow_entry_counterparty (counterparty_user_id, flow_id) - ) ENGINE=InnoDB {$charset_collate};"; - dbDelta($sql_financial_flow_entry); - - // ---------- 2. 签到 ---------- - $sql_signin = "CREATE TABLE {$prefix}aksrt_signin_log ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - user_id BIGINT UNSIGNED NOT NULL, - signin_date DATE NOT NULL, - streak INT NOT NULL DEFAULT 1, - reward INT NOT NULL DEFAULT 0, - PRIMARY KEY (id), - UNIQUE KEY uniq_user_date (user_id, signin_date), - KEY idx_user (user_id) - ) ENGINE=InnoDB {$charset_collate};"; - dbDelta($sql_signin); - - // ---------- 3. 下载 token ---------- - $sql_dl_token = "CREATE TABLE {$prefix}aksrt_dl_token ( - token CHAR(48) NOT NULL, - user_id BIGINT UNSIGNED NOT NULL, - post_id BIGINT UNSIGNED NOT NULL, - source_index INT NOT NULL DEFAULT 0, - cost INT NOT NULL DEFAULT 0, - ip_bind VARCHAR(64) DEFAULT NULL, - ua_hash CHAR(40) DEFAULT NULL, - used_count INT NOT NULL DEFAULT 0, - max_uses INT NOT NULL DEFAULT 1, - expires_at DATETIME NOT NULL, - created_at DATETIME NOT NULL, - PRIMARY KEY (token), - KEY idx_expires (expires_at), - KEY idx_user (user_id), - KEY idx_post (post_id) - ) ENGINE=InnoDB {$charset_collate};"; - dbDelta($sql_dl_token); - - // ---------- 5. 用户通知收件箱 ---------- - $sql_notification = "CREATE TABLE {$prefix}aksrt_notification ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - user_id BIGINT UNSIGNED NOT NULL, - type VARCHAR(32) NOT NULL, - title VARCHAR(191) NOT NULL, - description TEXT DEFAULT NULL, - target_url TEXT DEFAULT NULL, - is_read TINYINT(1) NOT NULL DEFAULT 0, - created_at DATETIME NOT NULL, - read_at DATETIME DEFAULT NULL, - PRIMARY KEY (id), - KEY idx_user_created (user_id, created_at), - KEY idx_user_read (user_id, is_read) - ) ENGINE=InnoDB {$charset_collate};"; - dbDelta($sql_notification); - - // ---------- 8. 订单表 ---------- - $sql_order = "CREATE TABLE {$prefix}aksrt_order ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - order_number VARCHAR(32) NOT NULL, - request_key VARCHAR(191) DEFAULT NULL, - user_id BIGINT UNSIGNED NOT NULL, - product_type VARCHAR(32) NOT NULL DEFAULT '', - product_id BIGINT UNSIGNED NOT NULL DEFAULT 0, - amount DECIMAL(10,2) NOT NULL DEFAULT 0, - currency VARCHAR(8) NOT NULL DEFAULT 'CNY', - status VARCHAR(20) NOT NULL DEFAULT 'pending', - shipping_status TINYINT NOT NULL DEFAULT 0, - payment_method VARCHAR(32) NOT NULL DEFAULT '', - payment_transaction_id VARCHAR(128) NOT NULL DEFAULT '', - created_at DATETIME NOT NULL, - paid_at DATETIME DEFAULT NULL, - delivered_at DATETIME DEFAULT NULL, - completed_at DATETIME DEFAULT NULL, - cancelled_at DATETIME DEFAULT NULL, - refunded_at DATETIME DEFAULT NULL, - expires_at DATETIME DEFAULT NULL, - note TEXT DEFAULT NULL, - PRIMARY KEY (id), - UNIQUE KEY uniq_order_number (order_number), - UNIQUE KEY uniq_request_key (request_key), - KEY idx_user (user_id), - KEY idx_user_status_created (user_id, status, created_at), - KEY idx_status (status), - KEY idx_created (created_at), - KEY idx_product (product_type, product_id) - ) ENGINE=InnoDB {$charset_collate};"; - dbDelta($sql_order); - - // ---------- order after-sales ---------- - $sql_order_aftersale = "CREATE TABLE {$prefix}aksrt_order_aftersale ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - order_number VARCHAR(32) NOT NULL, - user_id BIGINT UNSIGNED NOT NULL, - aftersale_type VARCHAR(24) NOT NULL DEFAULT 'refund', - reason TEXT NOT NULL, - apply_form LONGTEXT DEFAULT NULL, - images LONGTEXT DEFAULT NULL, - refund_amount VARCHAR(32) NOT NULL DEFAULT '0', - status VARCHAR(20) NOT NULL DEFAULT 'pending', - admin_note TEXT DEFAULT NULL, - gateway_request_id VARCHAR(128) DEFAULT NULL, - gateway_refund_id VARCHAR(128) DEFAULT NULL, - gateway_result LONGTEXT DEFAULT NULL, - created_at DATETIME NOT NULL, - reviewed_at DATETIME DEFAULT NULL, - completed_at DATETIME DEFAULT NULL, - PRIMARY KEY (id), - KEY idx_order_status (order_number, status), - KEY idx_user_created (user_id, created_at), - KEY idx_status_created (status, created_at) - ) ENGINE=InnoDB {$charset_collate};"; - dbDelta($sql_order_aftersale); - - // ---------- 9. 作者收益台账 ---------- - $sql_revenue = "CREATE TABLE {$prefix}aksrt_revenue ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - author_id BIGINT UNSIGNED NOT NULL DEFAULT 0, - order_number VARCHAR(32) NOT NULL DEFAULT '', - post_id BIGINT UNSIGNED NOT NULL DEFAULT 0, - buyer_id BIGINT UNSIGNED NOT NULL DEFAULT 0, - currency VARCHAR(8) NOT NULL DEFAULT 'CNY', - original_amount DECIMAL(18,2) NOT NULL DEFAULT 0, - share_ratio DECIMAL(5,2) NOT NULL DEFAULT 0, - author_level TINYINT UNSIGNED NOT NULL DEFAULT 0, - amount DECIMAL(18,2) NOT NULL DEFAULT 0, - type VARCHAR(32) NOT NULL DEFAULT 'order', - status VARCHAR(20) NOT NULL DEFAULT 'pending', - reversal_amount DECIMAL(18,2) NOT NULL DEFAULT 0, - error_message TEXT DEFAULT NULL, - audit_json LONGTEXT DEFAULT NULL, - created_at DATETIME NOT NULL, - settled_at DATETIME DEFAULT NULL, - reversed_at DATETIME DEFAULT NULL, - PRIMARY KEY (id), - UNIQUE KEY uniq_creator_order (author_id, order_number), - KEY idx_author (author_id), - KEY idx_order (order_number), - KEY idx_post (post_id), - KEY idx_buyer (buyer_id), - KEY idx_currency (currency), - KEY idx_status (status), - KEY idx_created (created_at) - ) ENGINE=InnoDB {$charset_collate};"; - dbDelta($sql_revenue); - - // ---------- 10. 提现台账 ---------- - $sql_withdrawal = "CREATE TABLE {$prefix}aksrt_withdrawal ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - withdrawal_number VARCHAR(32) NOT NULL, - user_id BIGINT UNSIGNED NOT NULL, - amount DECIMAL(10,2) NOT NULL DEFAULT 0, - gross_amount DECIMAL(10,2) NOT NULL DEFAULT 0, - method VARCHAR(32) NOT NULL DEFAULT '', - account_info TEXT DEFAULT NULL, - status VARCHAR(20) NOT NULL DEFAULT 'pending', - operation_key VARCHAR(191) DEFAULT NULL, - ledger_transaction_id BIGINT UNSIGNED DEFAULT NULL, - created_at DATETIME NOT NULL, - processed_at DATETIME DEFAULT NULL, - note TEXT DEFAULT NULL, - PRIMARY KEY (id), - UNIQUE KEY uniq_withdrawal_number (withdrawal_number), - UNIQUE KEY uniq_withdrawal_operation (operation_key), - UNIQUE KEY uniq_ledger_transaction (ledger_transaction_id), - KEY idx_user (user_id), - KEY idx_user_created (user_id, created_at), - KEY idx_status (status), - KEY idx_created (created_at) - ) ENGINE=InnoDB {$charset_collate};"; - dbDelta($sql_withdrawal); - - // ---------- 11. 举报工单 ---------- - $sql_report = "CREATE TABLE {$prefix}aksrt_report ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - reporter_id BIGINT UNSIGNED NOT NULL, - target_type VARCHAR(20) NOT NULL, - target_id BIGINT UNSIGNED NOT NULL, - reason TEXT NOT NULL, - category VARCHAR(40) NOT NULL DEFAULT '', - status VARCHAR(20) NOT NULL DEFAULT 'pending', - created_at DATETIME NOT NULL, - updated_at DATETIME DEFAULT NULL, - notes_json TEXT DEFAULT NULL, - PRIMARY KEY (id), - KEY idx_status (status), - KEY idx_target (target_type, target_id), - KEY idx_reporter (reporter_id), - KEY idx_created (created_at) - ) ENGINE=InnoDB {$charset_collate};"; - dbDelta($sql_report); - - // ---------- 12. 用户限制/封禁 ---------- - $sql_restriction = "CREATE TABLE {$prefix}aksrt_user_restriction ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - user_id BIGINT UNSIGNED NOT NULL, - type VARCHAR(20) NOT NULL DEFAULT 'warn', - reason TEXT NOT NULL, - scope VARCHAR(20) NOT NULL DEFAULT 'global', - expires_at DATETIME DEFAULT NULL, - created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, - created_at DATETIME NOT NULL, - PRIMARY KEY (id), - KEY idx_user (user_id), - KEY idx_type (type), - KEY idx_scope (scope), - KEY idx_expires (expires_at), - KEY idx_created (created_at) - ) ENGINE=InnoDB {$charset_collate};"; - dbDelta($sql_restriction); - - // ── follow ── - $sql_follow = "CREATE TABLE {$prefix}aksrt_follow ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - follower_id BIGINT UNSIGNED NOT NULL, - following_id BIGINT UNSIGNED NOT NULL, - created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', - PRIMARY KEY (id), - UNIQUE KEY idx_pair (follower_id, following_id), - KEY idx_following (following_id), - KEY idx_created (created_at) - ) ENGINE=InnoDB {$charset_collate};"; - dbDelta($sql_follow); - - // ── coupon ── - $sql_coupon = "CREATE TABLE {$prefix}aksrt_coupon ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - code VARCHAR(60) NOT NULL DEFAULT '', - type VARCHAR(20) NOT NULL DEFAULT 'fixed', - value DECIMAL(10,2) NOT NULL DEFAULT 0, - min_amount DECIMAL(10,2) NOT NULL DEFAULT 0, - usage_limit INT UNSIGNED NOT NULL DEFAULT 0, - used_count INT UNSIGNED NOT NULL DEFAULT 0, - expires_at DATETIME DEFAULT NULL, - per_user_limit INT UNSIGNED NOT NULL DEFAULT 0, - product_scope TEXT DEFAULT NULL, - max_discount DECIMAL(10,2) NOT NULL DEFAULT 0, - status VARCHAR(20) NOT NULL DEFAULT 'active', - created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', - PRIMARY KEY (id), - UNIQUE KEY idx_code (code), - KEY idx_status (status), - KEY idx_expires (expires_at) - ) ENGINE=InnoDB {$charset_collate};"; - dbDelta($sql_coupon); - - // ── coupon_usage (R68) ── - $sql_coupon_usage = "CREATE TABLE {$prefix}aksrt_coupon_usage ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - coupon_id BIGINT UNSIGNED NOT NULL DEFAULT 0, - user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, - order_number VARCHAR(32) NOT NULL DEFAULT '', - discount DECIMAL(10,2) NOT NULL DEFAULT 0, - created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', - PRIMARY KEY (id), - KEY idx_coupon (coupon_id), - KEY idx_user (user_id), - UNIQUE KEY uniq_coupon_usage_order (order_number) - ) ENGINE=InnoDB {$charset_collate};"; - dbDelta($sql_coupon_usage); - - // ── typed charge cards ── - $sql_charge_card = "CREATE TABLE {$prefix}aksrt_charge_card ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - card VARCHAR(60) NOT NULL DEFAULT '', - password VARCHAR(80) NOT NULL DEFAULT '', - type VARCHAR(20) NOT NULL DEFAULT 'balance', - amount DECIMAL(10,2) NOT NULL DEFAULT 0, - balance DECIMAL(10,2) NOT NULL DEFAULT 0, - status VARCHAR(20) NOT NULL DEFAULT 'active', - meta LONGTEXT DEFAULT NULL, - order_number VARCHAR(32) NOT NULL DEFAULT '', - used_by BIGINT UNSIGNED DEFAULT NULL, - used_at DATETIME DEFAULT NULL, - expires_at DATETIME DEFAULT NULL, - created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', - note TEXT DEFAULT NULL, - PRIMARY KEY (id), - UNIQUE KEY idx_password (password), - KEY idx_card (card), - KEY idx_type (type), - KEY idx_status (status), - KEY idx_used_by (used_by), - KEY idx_order_number (order_number), - KEY idx_expires (expires_at) - ) ENGINE=InnoDB {$charset_collate};"; - dbDelta($sql_charge_card); - - // ── one-time invite codes (registration gate + per-batch rewards) ── - $sql_invite_code = "CREATE TABLE {$prefix}aksrt_invite_code ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - code VARCHAR(32) NOT NULL, - batch_id VARCHAR(32) NOT NULL DEFAULT '', - remark VARCHAR(191) NOT NULL DEFAULT '', - rewards TEXT DEFAULT NULL, - max_uses INT UNSIGNED NOT NULL DEFAULT 1, - used_count INT UNSIGNED NOT NULL DEFAULT 0, - status VARCHAR(20) NOT NULL DEFAULT 'active', - expires_at DATETIME DEFAULT NULL, - created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00', - PRIMARY KEY (id), - UNIQUE KEY uniq_invite_code (code), - KEY idx_batch (batch_id), - KEY idx_status (status), - KEY idx_expires (expires_at) - ) ENGINE=InnoDB {$charset_collate};"; - dbDelta($sql_invite_code); - - // ── invite-code usage log (who consumed which code) ── - $sql_invite_code_usage = "CREATE TABLE {$prefix}aksrt_invite_code_usage ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - code_id BIGINT UNSIGNED NOT NULL, - user_id BIGINT UNSIGNED NOT NULL, - used_at DATETIME NOT NULL, - PRIMARY KEY (id), - UNIQUE KEY uniq_invite_usage (code_id, user_id), - KEY idx_usage_user (user_id), - KEY idx_usage_code (code_id) - ) ENGINE=InnoDB {$charset_collate};"; - dbDelta($sql_invite_code_usage); - - // ── canonical Ref attribution and permanent user binding ── - $sql_affiliate_relation = "CREATE TABLE {$prefix}aksrt_affiliate_relation ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - referral_code VARCHAR(32) NOT NULL, - promoter_id BIGINT UNSIGNED NOT NULL, - invitee_id BIGINT UNSIGNED DEFAULT NULL, - first_touch_at DATETIME NOT NULL, - bound_at DATETIME DEFAULT NULL, - status VARCHAR(20) NOT NULL DEFAULT 'captured', - reward_status VARCHAR(20) NOT NULL DEFAULT 'skipped', - rewarded_at DATETIME DEFAULT NULL, - registration_ip VARCHAR(64) DEFAULT NULL, - created_at DATETIME NOT NULL, - PRIMARY KEY (id), - KEY idx_referral_code (referral_code), - UNIQUE KEY uniq_invitee (invitee_id), - KEY idx_promoter (promoter_id), - KEY idx_status_touch (status, first_touch_at), - KEY idx_reward_status (reward_status), - KEY idx_reward_ip (registration_ip, rewarded_at) - ) ENGINE=InnoDB {$charset_collate};"; - dbDelta($sql_affiliate_relation); - - // ── immutable commission ledger ── - // 佣金即时入账(无冻结/结算期):下单支付即 credit 到 COMMISSION 资金池, - // 本表仅作佣金明细账 + 退款冲正的溯源记录。status: credited | cancelled。 - $sql_commission_ledger = "CREATE TABLE {$prefix}aksrt_commission_ledger ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - promoter_id BIGINT UNSIGNED NOT NULL, - source_user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, - order_number VARCHAR(64) NOT NULL, - order_type VARCHAR(32) NOT NULL, - eligible_base DECIMAL(18,2) NOT NULL DEFAULT 0, - ratio DECIMAL(8,4) NOT NULL DEFAULT 0, - original_amount DECIMAL(18,2) NOT NULL DEFAULT 0, - adjustment_total DECIMAL(18,2) NOT NULL DEFAULT 0, - status VARCHAR(20) NOT NULL DEFAULT 'credited', - idempotency_key VARCHAR(128) NOT NULL, - cancel_operation_key VARCHAR(191) DEFAULT NULL, - cancel_payload_hash CHAR(64) DEFAULT NULL, - created_at DATETIME NOT NULL, - note TEXT DEFAULT NULL, - PRIMARY KEY (id), - UNIQUE KEY uniq_commission_idempotency (idempotency_key), - UNIQUE KEY uniq_commission_cancel_operation (cancel_operation_key), - KEY idx_promoter_status (promoter_id, status), - KEY idx_order_type (order_number, order_type), - KEY idx_created (created_at) - ) ENGINE=InnoDB {$charset_collate};"; - dbDelta($sql_commission_ledger); - - // 佣金欠款挂账:退款全额收回后,若佣金池不足产生的差额记为欠款, - // 后续新增佣金先抵扣欠款再入池。每用户一行。 - $sql_commission_debt = "CREATE TABLE {$prefix}aksrt_commission_debt ( - user_id BIGINT UNSIGNED NOT NULL, - debt_minor BIGINT UNSIGNED NOT NULL DEFAULT 0, - updated_at DATETIME NOT NULL, - PRIMARY KEY (user_id) - ) ENGINE=InnoDB {$charset_collate};"; - dbDelta($sql_commission_debt); - - // ── immutable commission adjustments (refund/debt/manual) ── - $sql_commission_adjustment = "CREATE TABLE {$prefix}aksrt_commission_adjustment ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - ledger_id BIGINT UNSIGNED NOT NULL, - promoter_id BIGINT UNSIGNED NOT NULL, - amount DECIMAL(18,2) NOT NULL DEFAULT 0, - reason VARCHAR(32) NOT NULL, - source_key VARCHAR(128) NOT NULL, - operator_id BIGINT UNSIGNED NOT NULL DEFAULT 0, - created_at DATETIME NOT NULL, - note TEXT DEFAULT NULL, - PRIMARY KEY (id), - UNIQUE KEY uniq_adjustment_source (source_key), - KEY idx_ledger (ledger_id), - KEY idx_promoter (promoter_id) - ) ENGINE=InnoDB {$charset_collate};"; - dbDelta($sql_commission_adjustment); - - // ── payout account snapshots ── - $sql_payout_account = "CREATE TABLE {$prefix}aksrt_payout_account ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - user_id BIGINT UNSIGNED NOT NULL, - channel VARCHAR(20) NOT NULL, - attachment_id BIGINT UNSIGNED NOT NULL DEFAULT 0, - account_hash CHAR(64) NOT NULL DEFAULT '', - status VARCHAR(20) NOT NULL DEFAULT 'active', - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, - PRIMARY KEY (id), - UNIQUE KEY uniq_user_channel (user_id, channel), - KEY idx_user_status (user_id, status) - ) ENGINE=InnoDB {$charset_collate};"; - dbDelta($sql_payout_account); - - // ── order_items (§6.5 多商品多商家) ── - $sql_order_items = "CREATE TABLE {$prefix}aksrt_order_item ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, - order_number VARCHAR(32) NOT NULL DEFAULT '', - product_id BIGINT UNSIGNED NOT NULL DEFAULT 0, - product_title VARCHAR(255) NOT NULL DEFAULT '', - options_key VARCHAR(255) NOT NULL DEFAULT '', - options_label VARCHAR(255) NOT NULL DEFAULT '', - quantity INT UNSIGNED NOT NULL DEFAULT 1, - unit_price DECIMAL(10,2) NOT NULL DEFAULT 0, - total_price DECIMAL(10,2) NOT NULL DEFAULT 0, - discount_amount DECIMAL(10,2) NOT NULL DEFAULT 0, - shipping_fee DECIMAL(10,2) NOT NULL DEFAULT 0, - pay_price DECIMAL(10,2) NOT NULL DEFAULT 0, - shipping_type VARCHAR(20) NOT NULL DEFAULT 'express', - auto_delivery_content TEXT DEFAULT NULL, - discount_hit TEXT DEFAULT NULL, - gift_data TEXT DEFAULT NULL, - image VARCHAR(512) NOT NULL DEFAULT '', - PRIMARY KEY (id), - KEY idx_order (order_number), - KEY idx_product (product_id) - ) ENGINE=InnoDB {$charset_collate};"; - dbDelta($sql_order_items); - - self::migrate_identity_tables($prefix, $charset_collate); - self::migrate_vip_tables($prefix, $charset_collate); - } -} +get_charset_collate(); + $prefix = $wpdb->prefix; + + // ---------- 1. 统一资产账户与不可变账本 ---------- + $sql_asset_account = "CREATE TABLE {$prefix}aksrt_asset_account ( + user_id BIGINT UNSIGNED NOT NULL, + asset VARCHAR(16) NOT NULL, + balance_minor BIGINT UNSIGNED NOT NULL DEFAULT 0, + version BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (user_id, asset), + KEY idx_asset_balance (asset, balance_minor) + ) ENGINE=InnoDB {$charset_collate};"; + dbDelta($sql_asset_account); + + $sql_asset_transaction = "CREATE TABLE {$prefix}aksrt_asset_transaction ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + operation_key VARCHAR(191) NOT NULL, + payload_hash CHAR(64) NOT NULL, + reason VARCHAR(64) NOT NULL, + ref_type VARCHAR(32) DEFAULT NULL, + ref_key VARCHAR(191) DEFAULT NULL, + actor_user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + metadata LONGTEXT DEFAULT NULL, + ip VARCHAR(64) DEFAULT NULL, + ua VARCHAR(255) DEFAULT NULL, + created_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uniq_operation_key (operation_key), + KEY idx_reason_time (reason, created_at), + KEY idx_reference (ref_type, ref_key) + ) ENGINE=InnoDB {$charset_collate};"; + dbDelta($sql_asset_transaction); + + $sql_asset_entry = "CREATE TABLE {$prefix}aksrt_asset_entry ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + transaction_id BIGINT UNSIGNED NOT NULL, + line_no SMALLINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + asset VARCHAR(16) NOT NULL, + entry_type VARCHAR(32) NOT NULL, + delta_minor BIGINT NOT NULL, + balance_after_minor BIGINT UNSIGNED NOT NULL, + counterparty_user_id BIGINT UNSIGNED DEFAULT NULL, + remark VARCHAR(255) DEFAULT NULL, + created_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uniq_transaction_line (transaction_id, line_no), + KEY idx_user_asset_time (user_id, asset, created_at, id), + KEY idx_transaction (transaction_id) + ) ENGINE=InnoDB {$charset_collate};"; + dbDelta($sql_asset_entry); + + // 后台「资金流水」页的默认查询是 WHERE 1=1 ORDER BY created_at DESC, id DESC LIMIT n + // 外加同条件的 COUNT(*),且界面上的日期区间筛选也只有 created_at 可用。 + // 索引必须能以 created_at 为前导列,否则每次翻页都是全表扫描 + filesort, + // 而 idx_flow_event_time 的前导列是 event_type(筛选时可空),救不了这两条查询。 + $sql_financial_flow = "CREATE TABLE {$prefix}aksrt_financial_flow ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + flow_number VARCHAR(32) NOT NULL, + operation_key VARCHAR(191) NOT NULL, + event_type VARCHAR(64) NOT NULL, + ref_type VARCHAR(32) DEFAULT NULL, + ref_key VARCHAR(191) DEFAULT NULL, + order_number VARCHAR(32) DEFAULT NULL, + withdrawal_number VARCHAR(32) DEFAULT NULL, + ledger_transaction_id BIGINT UNSIGNED DEFAULT NULL, + provider_transaction_id VARCHAR(128) DEFAULT NULL, + metadata LONGTEXT DEFAULT NULL, + created_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uniq_flow_number (flow_number), + UNIQUE KEY uniq_flow_operation (operation_key), + UNIQUE KEY uniq_flow_ledger_transaction (ledger_transaction_id), + KEY idx_flow_created (created_at), + KEY idx_flow_event_time (event_type, created_at), + KEY idx_flow_order (order_number), + KEY idx_flow_withdrawal (withdrawal_number), + KEY idx_flow_reference (ref_type, ref_key), + KEY idx_flow_provider_transaction (provider_transaction_id) + ) ENGINE=InnoDB {$charset_collate};"; + dbDelta($sql_financial_flow); + + $sql_financial_flow_entry = "CREATE TABLE {$prefix}aksrt_financial_flow_entry ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + flow_id BIGINT UNSIGNED NOT NULL, + line_no SMALLINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + asset VARCHAR(16) NOT NULL, + direction VARCHAR(8) NOT NULL, + entry_type VARCHAR(32) NOT NULL, + delta_minor BIGINT NOT NULL, + balance_after_minor BIGINT UNSIGNED DEFAULT NULL, + counterparty_user_id BIGINT UNSIGNED DEFAULT NULL, + remark VARCHAR(255) DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY uniq_flow_line (flow_id, line_no), + KEY idx_flow_entry_user (user_id, flow_id), + KEY idx_flow_entry_asset (asset, flow_id), + KEY idx_flow_entry_direction (direction, flow_id), + KEY idx_flow_entry_counterparty (counterparty_user_id, flow_id) + ) ENGINE=InnoDB {$charset_collate};"; + dbDelta($sql_financial_flow_entry); + + // ---------- 2. 签到 ---------- + $sql_signin = "CREATE TABLE {$prefix}aksrt_signin_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + signin_date DATE NOT NULL, + streak INT NOT NULL DEFAULT 1, + reward INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uniq_user_date (user_id, signin_date), + KEY idx_user (user_id) + ) ENGINE=InnoDB {$charset_collate};"; + dbDelta($sql_signin); + + // ---------- 3. 下载 token ---------- + $sql_dl_token = "CREATE TABLE {$prefix}aksrt_dl_token ( + token CHAR(48) NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + post_id BIGINT UNSIGNED NOT NULL, + source_index INT NOT NULL DEFAULT 0, + cost INT NOT NULL DEFAULT 0, + ip_bind VARCHAR(64) DEFAULT NULL, + ua_hash CHAR(40) DEFAULT NULL, + used_count INT NOT NULL DEFAULT 0, + max_uses INT NOT NULL DEFAULT 1, + expires_at DATETIME NOT NULL, + created_at DATETIME NOT NULL, + PRIMARY KEY (token), + KEY idx_expires (expires_at), + KEY idx_user (user_id), + KEY idx_post (post_id) + ) ENGINE=InnoDB {$charset_collate};"; + dbDelta($sql_dl_token); + + // ---------- 5. 用户通知收件箱 ---------- + $sql_notification = "CREATE TABLE {$prefix}aksrt_notification ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + type VARCHAR(32) NOT NULL, + title VARCHAR(191) NOT NULL, + description TEXT DEFAULT NULL, + target_url TEXT DEFAULT NULL, + is_read TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL, + read_at DATETIME DEFAULT NULL, + PRIMARY KEY (id), + KEY idx_user_created (user_id, created_at), + KEY idx_user_read (user_id, is_read) + ) ENGINE=InnoDB {$charset_collate};"; + dbDelta($sql_notification); + + // ---------- 8. 订单表 ---------- + $sql_order = "CREATE TABLE {$prefix}aksrt_order ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + order_number VARCHAR(32) NOT NULL, + request_key VARCHAR(191) DEFAULT NULL, + user_id BIGINT UNSIGNED NOT NULL, + product_type VARCHAR(32) NOT NULL DEFAULT '', + product_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + amount DECIMAL(10,2) NOT NULL DEFAULT 0, + currency VARCHAR(8) NOT NULL DEFAULT 'CNY', + status VARCHAR(20) NOT NULL DEFAULT 'pending', + shipping_status TINYINT NOT NULL DEFAULT 0, + payment_method VARCHAR(32) NOT NULL DEFAULT '', + payment_transaction_id VARCHAR(128) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL, + paid_at DATETIME DEFAULT NULL, + delivered_at DATETIME DEFAULT NULL, + completed_at DATETIME DEFAULT NULL, + cancelled_at DATETIME DEFAULT NULL, + refunded_at DATETIME DEFAULT NULL, + expires_at DATETIME DEFAULT NULL, + note TEXT DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY uniq_order_number (order_number), + UNIQUE KEY uniq_request_key (request_key), + KEY idx_user (user_id), + KEY idx_user_status_created (user_id, status, created_at), + KEY idx_status (status), + KEY idx_created (created_at), + KEY idx_product (product_type, product_id) + ) ENGINE=InnoDB {$charset_collate};"; + dbDelta($sql_order); + + // ---------- order after-sales ---------- + $sql_order_aftersale = "CREATE TABLE {$prefix}aksrt_order_aftersale ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + order_number VARCHAR(32) NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + aftersale_type VARCHAR(24) NOT NULL DEFAULT 'refund', + reason TEXT NOT NULL, + apply_form LONGTEXT DEFAULT NULL, + images LONGTEXT DEFAULT NULL, + refund_amount VARCHAR(32) NOT NULL DEFAULT '0', + status VARCHAR(20) NOT NULL DEFAULT 'pending', + admin_note TEXT DEFAULT NULL, + gateway_request_id VARCHAR(128) DEFAULT NULL, + gateway_refund_id VARCHAR(128) DEFAULT NULL, + gateway_result LONGTEXT DEFAULT NULL, + created_at DATETIME NOT NULL, + reviewed_at DATETIME DEFAULT NULL, + completed_at DATETIME DEFAULT NULL, + PRIMARY KEY (id), + KEY idx_order_status (order_number, status), + KEY idx_user_created (user_id, created_at), + KEY idx_status_created (status, created_at) + ) ENGINE=InnoDB {$charset_collate};"; + dbDelta($sql_order_aftersale); + + // ---------- 9. 作者收益台账 ---------- + $sql_revenue = "CREATE TABLE {$prefix}aksrt_revenue ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + author_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + order_number VARCHAR(32) NOT NULL DEFAULT '', + post_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + buyer_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + currency VARCHAR(8) NOT NULL DEFAULT 'CNY', + original_amount DECIMAL(18,2) NOT NULL DEFAULT 0, + share_ratio DECIMAL(5,2) NOT NULL DEFAULT 0, + author_level TINYINT UNSIGNED NOT NULL DEFAULT 0, + amount DECIMAL(18,2) NOT NULL DEFAULT 0, + type VARCHAR(32) NOT NULL DEFAULT 'order', + status VARCHAR(20) NOT NULL DEFAULT 'pending', + reversal_amount DECIMAL(18,2) NOT NULL DEFAULT 0, + error_message TEXT DEFAULT NULL, + audit_json LONGTEXT DEFAULT NULL, + created_at DATETIME NOT NULL, + settled_at DATETIME DEFAULT NULL, + reversed_at DATETIME DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY uniq_creator_order (author_id, order_number), + KEY idx_author (author_id), + KEY idx_order (order_number), + KEY idx_post (post_id), + KEY idx_buyer (buyer_id), + KEY idx_currency (currency), + KEY idx_status (status), + KEY idx_created (created_at) + ) ENGINE=InnoDB {$charset_collate};"; + dbDelta($sql_revenue); + + // ---------- 10. 提现台账 ---------- + $sql_withdrawal = "CREATE TABLE {$prefix}aksrt_withdrawal ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + withdrawal_number VARCHAR(32) NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + amount DECIMAL(10,2) NOT NULL DEFAULT 0, + gross_amount DECIMAL(10,2) NOT NULL DEFAULT 0, + method VARCHAR(32) NOT NULL DEFAULT '', + account_info TEXT DEFAULT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + operation_key VARCHAR(191) DEFAULT NULL, + ledger_transaction_id BIGINT UNSIGNED DEFAULT NULL, + created_at DATETIME NOT NULL, + processed_at DATETIME DEFAULT NULL, + note TEXT DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY uniq_withdrawal_number (withdrawal_number), + UNIQUE KEY uniq_withdrawal_operation (operation_key), + UNIQUE KEY uniq_ledger_transaction (ledger_transaction_id), + KEY idx_user (user_id), + KEY idx_user_created (user_id, created_at), + KEY idx_status (status), + KEY idx_created (created_at) + ) ENGINE=InnoDB {$charset_collate};"; + dbDelta($sql_withdrawal); + + // ---------- 11. 举报工单 ---------- + $sql_report = "CREATE TABLE {$prefix}aksrt_report ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + reporter_id BIGINT UNSIGNED NOT NULL, + target_type VARCHAR(20) NOT NULL, + target_id BIGINT UNSIGNED NOT NULL, + reason TEXT NOT NULL, + category VARCHAR(40) NOT NULL DEFAULT '', + status VARCHAR(20) NOT NULL DEFAULT 'pending', + created_at DATETIME NOT NULL, + updated_at DATETIME DEFAULT NULL, + notes_json TEXT DEFAULT NULL, + PRIMARY KEY (id), + KEY idx_status (status), + KEY idx_target (target_type, target_id), + KEY idx_reporter (reporter_id), + KEY idx_created (created_at) + ) ENGINE=InnoDB {$charset_collate};"; + dbDelta($sql_report); + + // ---------- 12. 用户限制/封禁 ---------- + $sql_restriction = "CREATE TABLE {$prefix}aksrt_user_restriction ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + type VARCHAR(20) NOT NULL DEFAULT 'warn', + reason TEXT NOT NULL, + scope VARCHAR(20) NOT NULL DEFAULT 'global', + expires_at DATETIME DEFAULT NULL, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY idx_user (user_id), + KEY idx_type (type), + KEY idx_scope (scope), + KEY idx_expires (expires_at), + KEY idx_created (created_at) + ) ENGINE=InnoDB {$charset_collate};"; + dbDelta($sql_restriction); + + // ── follow ── + $sql_follow = "CREATE TABLE {$prefix}aksrt_follow ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + follower_id BIGINT UNSIGNED NOT NULL, + following_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', + PRIMARY KEY (id), + UNIQUE KEY idx_pair (follower_id, following_id), + KEY idx_following (following_id), + KEY idx_created (created_at) + ) ENGINE=InnoDB {$charset_collate};"; + dbDelta($sql_follow); + + // ── coupon ── + $sql_coupon = "CREATE TABLE {$prefix}aksrt_coupon ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + code VARCHAR(60) NOT NULL DEFAULT '', + type VARCHAR(20) NOT NULL DEFAULT 'fixed', + value DECIMAL(10,2) NOT NULL DEFAULT 0, + min_amount DECIMAL(10,2) NOT NULL DEFAULT 0, + usage_limit INT UNSIGNED NOT NULL DEFAULT 0, + used_count INT UNSIGNED NOT NULL DEFAULT 0, + expires_at DATETIME DEFAULT NULL, + per_user_limit INT UNSIGNED NOT NULL DEFAULT 0, + product_scope TEXT DEFAULT NULL, + max_discount DECIMAL(10,2) NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'active', + created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', + PRIMARY KEY (id), + UNIQUE KEY idx_code (code), + KEY idx_status (status), + KEY idx_expires (expires_at) + ) ENGINE=InnoDB {$charset_collate};"; + dbDelta($sql_coupon); + + // ── coupon_usage (R68) ── + $sql_coupon_usage = "CREATE TABLE {$prefix}aksrt_coupon_usage ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + coupon_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + order_number VARCHAR(32) NOT NULL DEFAULT '', + discount DECIMAL(10,2) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', + PRIMARY KEY (id), + KEY idx_coupon (coupon_id), + KEY idx_user (user_id), + UNIQUE KEY uniq_coupon_usage_order (order_number) + ) ENGINE=InnoDB {$charset_collate};"; + dbDelta($sql_coupon_usage); + + // ── typed charge cards ── + $sql_charge_card = "CREATE TABLE {$prefix}aksrt_charge_card ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + card VARCHAR(60) NOT NULL DEFAULT '', + password VARCHAR(80) NOT NULL DEFAULT '', + type VARCHAR(20) NOT NULL DEFAULT 'balance', + amount DECIMAL(10,2) NOT NULL DEFAULT 0, + balance DECIMAL(10,2) NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'active', + meta LONGTEXT DEFAULT NULL, + order_number VARCHAR(32) NOT NULL DEFAULT '', + used_by BIGINT UNSIGNED DEFAULT NULL, + used_at DATETIME DEFAULT NULL, + expires_at DATETIME DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', + note TEXT DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY idx_password (password), + KEY idx_card (card), + KEY idx_type (type), + KEY idx_status (status), + KEY idx_used_by (used_by), + KEY idx_order_number (order_number), + KEY idx_expires (expires_at) + ) ENGINE=InnoDB {$charset_collate};"; + dbDelta($sql_charge_card); + + // ── one-time invite codes (registration gate + per-batch rewards) ── + $sql_invite_code = "CREATE TABLE {$prefix}aksrt_invite_code ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + code VARCHAR(32) NOT NULL, + batch_id VARCHAR(32) NOT NULL DEFAULT '', + remark VARCHAR(191) NOT NULL DEFAULT '', + rewards TEXT DEFAULT NULL, + max_uses INT UNSIGNED NOT NULL DEFAULT 1, + used_count INT UNSIGNED NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'active', + expires_at DATETIME DEFAULT NULL, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00', + PRIMARY KEY (id), + UNIQUE KEY uniq_invite_code (code), + KEY idx_batch (batch_id), + KEY idx_status (status), + KEY idx_expires (expires_at) + ) ENGINE=InnoDB {$charset_collate};"; + dbDelta($sql_invite_code); + + // ── invite-code usage log (who consumed which code) ── + $sql_invite_code_usage = "CREATE TABLE {$prefix}aksrt_invite_code_usage ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + code_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + used_at DATETIME NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uniq_invite_usage (code_id, user_id), + KEY idx_usage_user (user_id), + KEY idx_usage_code (code_id) + ) ENGINE=InnoDB {$charset_collate};"; + dbDelta($sql_invite_code_usage); + + // ── canonical Ref attribution and permanent user binding ── + $sql_affiliate_relation = "CREATE TABLE {$prefix}aksrt_affiliate_relation ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + referral_code VARCHAR(32) NOT NULL, + promoter_id BIGINT UNSIGNED NOT NULL, + invitee_id BIGINT UNSIGNED DEFAULT NULL, + first_touch_at DATETIME NOT NULL, + bound_at DATETIME DEFAULT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'captured', + reward_status VARCHAR(20) NOT NULL DEFAULT 'skipped', + rewarded_at DATETIME DEFAULT NULL, + registration_ip VARCHAR(64) DEFAULT NULL, + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY idx_referral_code (referral_code), + UNIQUE KEY uniq_invitee (invitee_id), + KEY idx_promoter (promoter_id), + KEY idx_status_touch (status, first_touch_at), + KEY idx_reward_status (reward_status), + KEY idx_reward_ip (registration_ip, rewarded_at) + ) ENGINE=InnoDB {$charset_collate};"; + dbDelta($sql_affiliate_relation); + + // ── immutable commission ledger ── + // 佣金即时入账(无冻结/结算期):下单支付即 credit 到 COMMISSION 资金池, + // 本表仅作佣金明细账 + 退款冲正的溯源记录。status: credited | cancelled。 + $sql_commission_ledger = "CREATE TABLE {$prefix}aksrt_commission_ledger ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + promoter_id BIGINT UNSIGNED NOT NULL, + source_user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + order_number VARCHAR(64) NOT NULL, + order_type VARCHAR(32) NOT NULL, + eligible_base DECIMAL(18,2) NOT NULL DEFAULT 0, + ratio DECIMAL(8,4) NOT NULL DEFAULT 0, + original_amount DECIMAL(18,2) NOT NULL DEFAULT 0, + adjustment_total DECIMAL(18,2) NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'credited', + idempotency_key VARCHAR(128) NOT NULL, + cancel_operation_key VARCHAR(191) DEFAULT NULL, + cancel_payload_hash CHAR(64) DEFAULT NULL, + created_at DATETIME NOT NULL, + note TEXT DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY uniq_commission_idempotency (idempotency_key), + UNIQUE KEY uniq_commission_cancel_operation (cancel_operation_key), + KEY idx_promoter_status (promoter_id, status), + KEY idx_order_type (order_number, order_type), + KEY idx_created (created_at) + ) ENGINE=InnoDB {$charset_collate};"; + dbDelta($sql_commission_ledger); + + // 佣金欠款挂账:退款全额收回后,若佣金池不足产生的差额记为欠款, + // 后续新增佣金先抵扣欠款再入池。每用户一行。 + $sql_commission_debt = "CREATE TABLE {$prefix}aksrt_commission_debt ( + user_id BIGINT UNSIGNED NOT NULL, + debt_minor BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL, + PRIMARY KEY (user_id) + ) ENGINE=InnoDB {$charset_collate};"; + dbDelta($sql_commission_debt); + + // ── immutable commission adjustments (refund/debt/manual) ── + $sql_commission_adjustment = "CREATE TABLE {$prefix}aksrt_commission_adjustment ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + ledger_id BIGINT UNSIGNED NOT NULL, + promoter_id BIGINT UNSIGNED NOT NULL, + amount DECIMAL(18,2) NOT NULL DEFAULT 0, + reason VARCHAR(32) NOT NULL, + source_key VARCHAR(128) NOT NULL, + operator_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL, + note TEXT DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY uniq_adjustment_source (source_key), + KEY idx_ledger (ledger_id), + KEY idx_promoter (promoter_id) + ) ENGINE=InnoDB {$charset_collate};"; + dbDelta($sql_commission_adjustment); + + // ── payout account snapshots ── + $sql_payout_account = "CREATE TABLE {$prefix}aksrt_payout_account ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + channel VARCHAR(20) NOT NULL, + attachment_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + account_hash CHAR(64) NOT NULL DEFAULT '', + status VARCHAR(20) NOT NULL DEFAULT 'active', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uniq_user_channel (user_id, channel), + KEY idx_user_status (user_id, status) + ) ENGINE=InnoDB {$charset_collate};"; + dbDelta($sql_payout_account); + + // ── order_items (§6.5 多商品多商家) ── + $sql_order_items = "CREATE TABLE {$prefix}aksrt_order_item ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + order_number VARCHAR(32) NOT NULL DEFAULT '', + product_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + product_title VARCHAR(255) NOT NULL DEFAULT '', + options_key VARCHAR(255) NOT NULL DEFAULT '', + options_label VARCHAR(255) NOT NULL DEFAULT '', + quantity INT UNSIGNED NOT NULL DEFAULT 1, + unit_price DECIMAL(10,2) NOT NULL DEFAULT 0, + total_price DECIMAL(10,2) NOT NULL DEFAULT 0, + discount_amount DECIMAL(10,2) NOT NULL DEFAULT 0, + shipping_fee DECIMAL(10,2) NOT NULL DEFAULT 0, + pay_price DECIMAL(10,2) NOT NULL DEFAULT 0, + shipping_type VARCHAR(20) NOT NULL DEFAULT 'express', + auto_delivery_content TEXT DEFAULT NULL, + discount_hit TEXT DEFAULT NULL, + gift_data TEXT DEFAULT NULL, + image VARCHAR(512) NOT NULL DEFAULT '', + PRIMARY KEY (id), + KEY idx_order (order_number), + KEY idx_product (product_id) + ) ENGINE=InnoDB {$charset_collate};"; + dbDelta($sql_order_items); + + self::migrate_identity_tables($prefix, $charset_collate); + self::migrate_vip_tables($prefix, $charset_collate); + } +} diff --git a/src/Payment/AfterSaleService.php b/src/Payment/AfterSaleService.php index 7ad2509c..473bc530 100644 --- a/src/Payment/AfterSaleService.php +++ b/src/Payment/AfterSaleService.php @@ -1,680 +1,680 @@ -[0-9]{20}|[0-9]{22})'; - register_rest_route('aksrt/v1', '/orders/' . $number . '/after-sale', [ - ['methods' => \WP_REST_Server::CREATABLE, 'callback' => [$this, 'rest_submit'], 'permission_callback' => [$this, 'require_user'], 'args' => [ - 'order_number' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'], - 'reason' => ['required' => true, 'sanitize_callback' => 'sanitize_textarea_field'], - 'reason_label' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'], - 'materials' => ['required' => false, 'type' => 'array', 'validate_callback' => static function ($value): bool { - if (!is_array($value) || count($value) > 10) return false; - foreach ($value as $item) { - if (!is_array($item)) return false; - foreach (['label', 'value'] as $key) { - if (isset($item[$key]) && !is_string($item[$key])) return false; - } - } - return true; - }], - 'description' => ['required' => false, 'sanitize_callback' => 'sanitize_textarea_field'], - 'images' => ['required' => false, 'type' => 'array', 'validate_callback' => static function ($value): bool { - return is_array($value) && count($value) <= 3; - }], - ]], - ['methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'rest_list'], 'permission_callback' => [$this, 'require_user'], 'args' => [ - 'order_number' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'], - ]], - ['methods' => 'DELETE', 'callback' => [$this, 'rest_cancel'], 'permission_callback' => [$this, 'require_user'], 'args' => [ - 'order_number' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'], - ]], - ]); - // 售后举证图片上传:复用评论图片上传链路(大小 / 类型 / 内容审核同一套校验)。 - register_rest_route('aksrt/v1', '/orders/' . $number . '/after-sale/upload-image', [ - ['methods' => \WP_REST_Server::CREATABLE, 'callback' => [$this, 'rest_upload_image'], 'permission_callback' => [$this, 'require_after_sale_image']], - ]); - } - - /** 售后举证图片上传权限:登录 + nonce + 售后开启 + 图片开关开启 + 订单归属本人。 */ - public function require_after_sale_image(\WP_REST_Request $request) { - $user = $this->require_user($request); - if (is_wp_error($user)) { - return $user; - } - if (!class_exists('\AKSRT\Settings\PaymentRuntime')) { - return new \WP_Error('aksrt_after_sale_forbidden', __('售后功能暂未开启', 'aksrt'), ['status' => 403]); - } - $settings = \AKSRT\Settings\PaymentRuntime::get_after_sale_settings(); - if (empty($settings['enabled']) || empty($settings['img_enabled'])) { - return new \WP_Error('aksrt_after_sale_forbidden', __('售后图片上传未开启', 'aksrt'), ['status' => 403]); - } - $number = sanitize_text_field((string) $request->get_param('order_number')); - if (!OrderRepository::get_instance()->get_user_order(get_current_user_id(), $number)) { - return new \WP_Error('aksrt_order_missing', __('订单不存在', 'aksrt'), ['status' => 404]); - } - return true; - } - - public function rest_upload_image(\WP_REST_Request $request) { - if (!class_exists('\AKSRT\Admin\UserController')) { - return new \WP_Error('aksrt_after_sale_upload', __('上传服务不可用', 'aksrt'), ['status' => 500]); - } - return \AKSRT\Admin\UserController::get_instance()->rest_upload_comment_image($request); - } - - public function require_user(\WP_REST_Request $request) { - if (!is_user_logged_in()) return new \WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]); - $nonce = (string) $request->get_header('X-WP-Nonce'); - return $nonce !== '' && wp_verify_nonce($nonce, 'wp_rest') ? true : new \WP_Error('aksrt_rest_nonce', __('安全验证失败,请刷新页面后重试', 'aksrt'), ['status' => 403]); - } - - public function rest_submit(\WP_REST_Request $request) { - $images = $request->get_param('images'); - $materials = $request->get_param('materials'); - $result = $this->create( - get_current_user_id(), - (string) $request->get_param('order_number'), - (string) $request->get_param('reason'), - is_array($images) ? $images : [], - [ - 'reason_label' => (string) $request->get_param('reason_label'), - 'materials' => is_array($materials) ? $materials : [], - 'description' => (string) $request->get_param('description'), - ] - ); - return is_wp_error($result) ? $result : rest_ensure_response(['success' => true, 'data' => $result]); - } - - public function rest_list(\WP_REST_Request $request) { - $number = sanitize_text_field((string) $request->get_param('order_number')); - if (!OrderRepository::get_instance()->get_user_order(get_current_user_id(), $number)) return new \WP_Error('aksrt_order_missing', __('订单不存在', 'aksrt'), ['status' => 404]); - return rest_ensure_response(['success' => true, 'data' => $this->list_for_order($number)]); - } - - public function rest_cancel(\WP_REST_Request $request) { - $result = $this->cancel_for_user(get_current_user_id(), (string) $request->get_param('order_number')); - return is_wp_error($result) ? $result : rest_ensure_response(['success' => true, 'data' => $result]); - } - - /** @param array $apply_form */ - public function create(int $user_id, string $order_number, string $reason, array $images = [], array $apply_form = []) { - $reason = sanitize_textarea_field($reason); - $images = $this->sanitize_images($images); - $apply_form = $this->sanitize_apply_form($apply_form); - if ($user_id <= 0 || !preg_match('/^(?:\d{20}|\d{22})$/', $order_number) || $reason === '' || mb_strlen($reason) > 2000) { - return new \WP_Error('aksrt_after_sale_invalid', __('请填写有效的售后原因', 'aksrt'), ['status' => 400]); - } - global $wpdb; - $lock = 'aksrt_after_sale_' . md5($order_number); - if ((int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 5)', $lock)) !== 1) return new \WP_Error('aksrt_after_sale_busy', __('订单正在处理中,请稍后重试', 'aksrt'), ['status' => 409]); - try { - $order = OrderRepository::get_instance()->get_user_order($user_id, $order_number); - if (!$order || (string) ($order['status'] ?? '') !== Order::STATUS_PAID) return new \WP_Error('aksrt_after_sale_order', __('只有已支付订单可以申请售后', 'aksrt'), ['status' => 409]); - $active = $this->find_active($order_number); - if ($active) { - if ((int) ($active['user_id'] ?? 0) === $user_id && hash_equals((string) ($active['reason'] ?? ''), $reason)) { - return $active; - } - return new \WP_Error('aksrt_after_sale_exists', __('该订单已有未结束售后申请', 'aksrt'), ['status' => 409]); - } - $amount = $this->refund_amount($order); - if ($amount === null) return new \WP_Error('aksrt_after_sale_amount', __('订单退款金额无效', 'aksrt'), ['status' => 400]); - $inserted = $wpdb->insert($this->table(), [ - 'order_number' => $order_number, 'user_id' => $user_id, 'aftersale_type' => 'refund', - 'reason' => $reason, - 'apply_form' => $apply_form === [] ? null : wp_json_encode($apply_form, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), - 'images' => wp_json_encode($images), 'refund_amount' => $amount, - 'status' => self::STATUS_PENDING, 'created_at' => current_time('mysql'), - ], ['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s']); - return $inserted === 1 ? $this->get((int) $wpdb->insert_id) : new \WP_Error('aksrt_after_sale_db', __('售后申请保存失败', 'aksrt'), ['status' => 500]); - } finally { - $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock)); - } - } - - /** 售后举证图片:最多 3 条,统一走 esc_url_raw;非字符串与空值直接丢弃。 */ - private function sanitize_images(array $images): array { - $clean = []; - foreach ($images as $url) { - if (!is_string($url) || count($clean) >= 3) { - continue; - } - $url = esc_url_raw(trim($url)); - if ($url !== '') { - $clean[] = $url; - } - } - return $clean; - } - - /** - * 结构化申请表单(预置原因 / 申请资料 / 申请说明),供售后详细弹窗分行展示; - * 融合字符串 reason 保持原样入库,保证后台列表与历史数据兼容。 - * - * @param array $form - * @return array - */ - private function sanitize_apply_form(array $form): array { - $reason_label = sanitize_text_field((string) ($form['reason_label'] ?? '')); - if (mb_strlen($reason_label) > 120) $reason_label = mb_substr($reason_label, 0, 120); - $materials = []; - if (is_array($form['materials'] ?? null)) { - foreach ($form['materials'] as $item) { - if (!is_array($item) || count($materials) >= 10) continue; - $label = sanitize_text_field((string) ($item['label'] ?? '')); - $value = sanitize_text_field((string) ($item['value'] ?? '')); - if ($label === '' || $value === '') continue; - if (mb_strlen($label) > 120) $label = mb_substr($label, 0, 120); - if (mb_strlen($value) > 500) $value = mb_substr($value, 0, 500); - $materials[] = ['label' => $label, 'value' => $value]; - } - } - $description = sanitize_textarea_field((string) ($form['description'] ?? '')); - if (mb_strlen($description) > 2000) $description = mb_substr($description, 0, 2000); - $clean = []; - if ($reason_label !== '') $clean['reason_label'] = $reason_label; - if ($materials !== []) $clean['materials'] = $materials; - if ($description !== '') $clean['description'] = $description; - return $clean; - } - - /** 最新一条售后工单(列表视图只取最新态,避免整表拉取)。 */ - public function latest_for_order(string $order_number): ?array { - global $wpdb; - $row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE order_number = %s ORDER BY id DESC LIMIT 1", $order_number), ARRAY_A); - return is_array($row) ? $this->normalize($row) : null; - } - - public function cancel_for_user(int $user_id, string $order_number) { - if ($user_id <= 0 || !preg_match('/^(?:\d{20}|\d{22})$/', $order_number)) return new \WP_Error('aksrt_after_sale_invalid', __('售后申请无效', 'aksrt'), ['status' => 400]); - global $wpdb; - $lock = 'aksrt_after_sale_' . md5($order_number); - if ((int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 5)', $lock)) !== 1) return new \WP_Error('aksrt_after_sale_busy', __('售后申请正在处理中,请稍后重试', 'aksrt'), ['status' => 409]); - try { - $row = $this->find_active($order_number); - if (!$row || (int) ($row['user_id'] ?? 0) !== $user_id) return new \WP_Error('aksrt_after_sale_missing', __('没有可取消的售后申请', 'aksrt'), ['status' => 404]); - if ((string) ($row['status'] ?? '') !== self::STATUS_PENDING) return new \WP_Error('aksrt_after_sale_state', __('售后申请已进入处理阶段,无法取消', 'aksrt'), ['status' => 409]); - if (!$this->update((int) $row['id'], ['status' => self::STATUS_CANCELLED])) return new \WP_Error('aksrt_after_sale_db', __('取消售后申请失败', 'aksrt'), ['status' => 500]); - return $this->get((int) $row['id']); - } finally { - $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock)); - } - } - - /** @return list> */ - public function list_for_order(string $order_number): array { - global $wpdb; - $rows = $wpdb->get_results($wpdb->prepare("SELECT * FROM {$this->table()} WHERE order_number = %s ORDER BY id DESC", $order_number), ARRAY_A); - return array_map([$this, 'normalize'], (array) $rows); - } - - /** @return list> */ - public function admin_list(array $args = []): array { - global $wpdb; - $where = []; - $params = []; - $status = sanitize_key((string) ($args['status'] ?? '')); - $valid = [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_APPROVED, self::STATUS_REJECTED, self::STATUS_CANCELLED, self::STATUS_COMPLETED]; - if ($status !== '' && in_array($status, $valid, true)) { $where[] = 'status = %s'; $params[] = $status; } - foreach (['date_from' => '>=', 'date_to' => '<='] as $key => $operator) { - $date = sanitize_text_field((string) ($args[$key] ?? '')); - if ($date !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { - $where[] = 'created_at ' . $operator . ' %s'; - $params[] = $operator === '>=' ? $date . ' 00:00:00' : $date . ' 23:59:59'; - } - } - $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); - $offset = max(0, (int) ($args['offset'] ?? 0)); - $sql = "SELECT * FROM {$this->table()}" . ($where ? ' WHERE ' . implode(' AND ', $where) : '') . ' ORDER BY id DESC LIMIT %d OFFSET %d'; - $params[] = $limit; $params[] = $offset; - return array_map([$this, 'normalize'], (array) $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A)); - } - - public function count(array $args = []): int { - global $wpdb; - $status = sanitize_key((string) ($args['status'] ?? '')); - $where = []; - $params = []; - if ($status !== '') { $where[] = 'status = %s'; $params[] = $status; } - foreach (['date_from' => '>=', 'date_to' => '<='] as $key => $operator) { - $date = sanitize_text_field((string) ($args[$key] ?? '')); - if ($date !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { - $where[] = 'created_at ' . $operator . ' %s'; - $params[] = $operator === '>=' ? $date . ' 00:00:00' : $date . ' 23:59:59'; - } - } - $sql = "SELECT COUNT(*) FROM {$this->table()}" . ($where ? ' WHERE ' . implode(' AND ', $where) : ''); - return (int) ($params ? $wpdb->get_var($wpdb->prepare($sql, $params)) : $wpdb->get_var($sql)); - } - - public function count_user_open(int $user_id): int { - if ($user_id <= 0) return 0; - global $wpdb; - return (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$this->table()} WHERE user_id = %d AND status IN ('pending','processing','approved')", $user_id)); - } - - /** A refund status transition is valid only for its own active application. */ - public function can_finalize_order_refund(int $after_sale_id, string $order_number): bool { - if ($after_sale_id <= 0 || $order_number === '') return false; - $row = $this->get($after_sale_id); - return is_array($row) - && hash_equals((string) ($row['order_number'] ?? ''), $order_number) - && in_array((string) ($row['status'] ?? ''), [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_APPROVED], true); - } - - public function review(int $id, string $decision, string $note = '') { - if (!current_user_can('manage_options')) return new \WP_Error('aksrt_after_sale_forbidden', __('权限不足', 'aksrt'), ['status' => 403]); - $decision = sanitize_key($decision); - if (!in_array($decision, ['approve', 'reject', 'complete'], true)) return new \WP_Error('aksrt_after_sale_decision', __('审核动作无效', 'aksrt'), ['status' => 400]); - global $wpdb; - $initial = $this->get($id); - if (!$initial) return new \WP_Error('aksrt_after_sale_state', __('售后申请已处理或不存在', 'aksrt'), ['status' => 409]); - $order_number = (string) ($initial['order_number'] ?? ''); - $lock = 'aksrt_after_sale_' . md5($order_number); - if ((int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 5)', $lock)) !== 1) return new \WP_Error('aksrt_after_sale_busy', __('售后申请正在处理中,请稍后重试', 'aksrt'), ['status' => 409]); - try { - $row = $this->get($id); - if (!$row || !hash_equals($order_number, (string) ($row['order_number'] ?? '')) || !in_array((string) $row['status'], [self::STATUS_PENDING, self::STATUS_PROCESSING], true)) return new \WP_Error('aksrt_after_sale_state', __('售后申请已处理或不存在', 'aksrt'), ['status' => 409]); - $note = sanitize_textarea_field($note); - if ((string) $row['status'] === self::STATUS_PROCESSING) { - if ($decision === 'complete') { - $gateway_result = json_decode((string) ($row['gateway_result'] ?? ''), true); - if (!is_array($gateway_result) || sanitize_key((string) ($gateway_result['status'] ?? '')) !== 'manual') { - return new \WP_Error('aksrt_after_sale_manual_only', __('该申请不是待人工确认的退款', 'aksrt'), ['status' => 409]); - } - $order = OrderRepository::get_instance()->get((string) $row['order_number']); - if (!$order) return new \WP_Error('aksrt_after_sale_order', __('订单不存在', 'aksrt'), ['status' => 404]); - $finished = $this->finish_refund($row, $order, $note !== '' ? $note : (string) ($row['admin_note'] ?? '')); - return is_wp_error($finished) ? $finished : $this->get((int) $row['id']); - } - if ($decision === 'reject') { - $gateway_result = json_decode((string) ($row['gateway_result'] ?? ''), true); - if (!is_array($gateway_result) || sanitize_key((string) ($gateway_result['status'] ?? '')) !== 'manual') { - return new \WP_Error('aksrt_after_sale_processing', __('退款处理中,不能拒绝;请同步退款结果', 'aksrt'), ['status' => 409]); - } - $order = OrderRepository::get_instance()->get((string) $row['order_number']); - if (!$order) return new \WP_Error('aksrt_after_sale_order', __('订单不存在', 'aksrt'), ['status' => 404]); - $type = DomainModel::resolve_order_product_type((string) ($order['product_type'] ?? ''), (array) ($order['meta_payload'] ?? [])); - $revoke = $this->assets_to_revoke($order, $type, (array) ($order['meta_payload'] ?? [])); - if (is_wp_error($revoke)) return $revoke; - if ($revoke !== []) { - $compensated = $this->compensate_revoke($row, $order, $revoke, $note); - if (is_wp_error($compensated)) return $compensated; - } - if (!$this->update((int) $row['id'], ['status' => self::STATUS_REJECTED, 'admin_note' => $note !== '' ? $note : __('管理员确认未执行渠道退款,已恢复冻结资产', 'aksrt')])) { - return new \WP_Error('aksrt_after_sale_db', __('售后审核保存失败', 'aksrt'), ['status' => 500]); - } - return $this->get((int) $row['id']); - } - return $this->sync_gateway_refund($row, $note); - } - if ($decision === 'complete') return new \WP_Error('aksrt_after_sale_manual_only', __('只有待人工确认的退款可以直接完成', 'aksrt'), ['status' => 409]); - if ($decision === 'reject') { - if (!$this->update($id, ['status' => self::STATUS_REJECTED, 'admin_note' => $note, 'reviewed_at' => current_time('mysql')])) { - return new \WP_Error('aksrt_after_sale_db', __('售后审核保存失败', 'aksrt'), ['status' => 500]); - } - return $this->get($id); - } - return $this->approve($row, $note); - } finally { - $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock)); - } - } - - private function approve(array $row, string $note) { - $order_number = (string) $row['order_number']; - $order = OrderRepository::get_instance()->get($order_number); - if (!$order || (string) ($order['status'] ?? '') !== Order::STATUS_PAID) return new \WP_Error('aksrt_after_sale_order', __('订单已不是可退款状态', 'aksrt'), ['status' => 409]); - $type = DomainModel::resolve_order_product_type((string) ($order['product_type'] ?? ''), (array) ($order['meta_payload'] ?? [])); - $meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : []; - $method = sanitize_key((string) ($order['payment_method'] ?? '')); - if ($type === DomainModel::ORDER_PRODUCT_VIP && !$this->vip_reversal_is_safe($order, $meta)) return new \WP_Error('aksrt_after_sale_vip_snapshot', __('会员权益已变化或缺少快照,不能安全撤销', 'aksrt'), ['status' => 409]); - $revoke = $this->assets_to_revoke($order, $type, $meta); - if (is_wp_error($revoke)) return $revoke; - if ($method === 'card_pass') { - if (!$this->asset_entries_available($revoke)) return new \WP_Error('aksrt_after_sale_asset', __('当前资产不足,不能撤销卡密权益', 'aksrt'), ['status' => 409]); - return $this->commit_status_refund($row, $order, $note, $revoke); - } - if ($method === 'free') { - return $this->commit_status_refund($row, $order, $note, $revoke); - } - $refund_local = in_array($method, ['balance', 'points'], true); - if ($refund_local) { - $refund_asset = $method === 'points' ? AssetType::POINTS : AssetType::BALANCE; - $refund_minor = $refund_asset === AssetType::POINTS ? max(0, (int) ($order['amount'] ?? 0)) : $this->cash_minor((string) ($row['refund_amount'] ?? '0')); - if ($refund_minor <= 0) return new \WP_Error('aksrt_after_sale_amount', __('退款金额无效', 'aksrt'), ['status' => 400]); - $entries = $revoke; - // A points-purchase reversal already includes the original balance payment - // in assets_to_revoke(); do not return that payment twice. - if (!($type === DomainModel::ORDER_PRODUCT_POINTS && $method === 'balance')) { - $entries[] = ['user_id' => (int) $order['user_id'], 'asset' => $refund_asset->value, 'delta_minor' => $refund_minor, 'entry_type' => 'credit', 'remark' => sprintf(__('订单 %s 售后退款', 'aksrt'), $order_number)]; - } - return $this->commit_local_refund($row, $order, $entries, $note); - } - $gateway = Gateway::get_instance()->get_gateway($method); - if (!$gateway || !method_exists($gateway, 'refund')) return new \WP_Error('aksrt_after_sale_gateway', __('支付渠道不可用,无法退款', 'aksrt'), ['status' => 409]); - $request_id = (string) ($row['gateway_request_id'] ?? '') ?: 'AS_' . (int) $row['id']; - if (!$this->asset_entries_available($revoke)) { - return new \WP_Error('aksrt_after_sale_asset', __('当前资产不足,不能审核通过退款', 'aksrt'), ['status' => 409]); - } - if (!$this->update((int) $row['id'], ['status' => self::STATUS_PROCESSING, 'admin_note' => $note, 'gateway_request_id' => $request_id, 'reviewed_at' => current_time('mysql')])) { - return new \WP_Error('aksrt_after_sale_db', __('售后状态保存失败,未发起退款', 'aksrt'), ['status' => 500]); - } - if ($revoke !== []) { - $held = $this->post_asset_entries($row, $order, $revoke, 'after-sale-revoke', 'after_sale_revoke', $note); - if (is_wp_error($held)) { - $this->update((int) $row['id'], ['status' => self::STATUS_PENDING, 'gateway_request_id' => null, 'reviewed_at' => null]); - return $held; - } - } - try { - $result = $gateway->refund($order_number, (string) $row['refund_amount'], $request_id); - } catch (\Throwable $error) { - $result = ['success' => false, 'status' => 'unknown', 'message' => $error->getMessage()]; - } - if (!is_array($result)) { - $result = ['success' => false, 'status' => 'unknown', 'message' => __('支付渠道未返回有效退款结果', 'aksrt')]; - } - $this->update((int) $row['id'], ['gateway_result' => wp_json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'gateway_refund_id' => sanitize_text_field((string) ($result['refund_id'] ?? ''))]); - $status = sanitize_key((string) ($result['status'] ?? 'unknown')); - if ($status === 'failed') { - if ($revoke !== []) { - $compensated = $this->compensate_revoke($row, $order, $revoke, $note); - if (is_wp_error($compensated)) { - $this->update((int) $row['id'], ['admin_note' => __('渠道明确拒绝退款,但资产补偿失败;申请保持处理中,可再次同步重试补偿。', 'aksrt') . ' ' . $compensated->get_error_message()]); - return $this->get((int) $row['id']); - } - } - $this->update((int) $row['id'], ['status' => self::STATUS_REJECTED, 'admin_note' => sanitize_textarea_field((string) ($result['message'] ?? $note))]); - return $this->get((int) $row['id']); - } - // Missing, unknown, and pending provider outcomes are ambiguous. Keep the - // application processing and the asset clawback in place until query_refund - // explicitly confirms success or failure. - if (empty($result['success']) || $status !== 'succeeded') { - $this->update((int) $row['id'], ['admin_note' => sanitize_textarea_field((string) ($result['message'] ?? __('退款结果仍待支付渠道确认,请稍后同步或人工核对', 'aksrt')))]); - return $this->get((int) $row['id']); - } - $finished = $this->finish_refund($row, $order, $note); - return is_wp_error($finished) ? $finished : $this->get((int) $row['id']); - } - - private function commit_local_refund(array $row, array $order, array $entries, string $note) { - $number = (string) $order['order_number']; - $commission = CommissionLedger::get_instance()->prepare_full_refund($number); - if (is_wp_error($commission)) return $commission; - $guard = static fn() => CommissionLedger::get_instance()->guard_full_refund($commission); - $mutation = function () use ($row, $order, $note, $commission) { - $commission_guard = CommissionLedger::get_instance()->guard_full_refund($commission); - if (is_wp_error($commission_guard) || $commission_guard !== true) return is_wp_error($commission_guard) ? $commission_guard : new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]); - $finished = $this->finish_refund($row, $order, $note, false); - if ($finished === true) { - LedgerService::get_instance()->after_commit(static function (): void { - OrderRepository::get_instance()->dispatch_managed_status_events(); - }); - } - return $finished; - }; - $result = LedgerService::get_instance()->post(new LedgerCommand( - \AKSRT\Ledger\OperationKey::deterministic('after-sale-refund', [(int) $row['id']]), 'order_refund', $entries, - 'order', $number, get_current_user_id(), ['order_number' => $number] - ), $guard, $mutation); - if (is_wp_error($result)) return $result; - CommissionLedger::get_instance()->clawback_after_refund($commission); - return $this->get((int) $row['id']); - } - - private function post_asset_entries(array $row, array $order, array $entries, string $prefix, string $reason, string $note) { - return LedgerService::get_instance()->post(new LedgerCommand( - \AKSRT\Ledger\OperationKey::deterministic($prefix, [(int) $row['id']]), $reason, $entries, - 'order', (string) $order['order_number'], get_current_user_id(), ['order_number' => (string) $order['order_number'], 'after_sale_id' => (int) $row['id']] - )); - } - - private function commit_status_refund(array $row, array $order, string $note, array $entries = []) { - global $wpdb; - $number = (string) $order['order_number']; - $commission = CommissionLedger::get_instance()->prepare_full_refund($number); - if (is_wp_error($commission)) return $commission; - if ($entries !== []) { - $mutation = function () use ($row, $order, $note, $commission) { - $guard = CommissionLedger::get_instance()->guard_full_refund($commission); - if (is_wp_error($guard) || $guard !== true) return is_wp_error($guard) ? $guard : new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]); - $finished = $this->finish_refund($row, $order, $note, false); - if ($finished === true) { - LedgerService::get_instance()->after_commit(static function (): void { OrderRepository::get_instance()->dispatch_managed_status_events(); }); - } - return $finished; - }; - $result = LedgerService::get_instance()->post(new LedgerCommand( - \AKSRT\Ledger\OperationKey::deterministic('after-sale-card-refund', [(int) $row['id']]), 'order_refund', $entries, - 'order', $number, get_current_user_id(), ['order_number' => $number] - ), static fn() => CommissionLedger::get_instance()->guard_full_refund($commission), $mutation); - if (is_wp_error($result)) return $result; - CommissionLedger::get_instance()->clawback_after_refund($commission); - return $this->get((int) $row['id']); - } - if ($wpdb->query('START TRANSACTION') === false) return new \WP_Error('aksrt_after_sale_finish', __('无法开始退款状态更新', 'aksrt'), ['status' => 500]); - $guard = CommissionLedger::get_instance()->guard_full_refund($commission); - if (is_wp_error($guard) || $guard !== true) { $wpdb->query('ROLLBACK'); return is_wp_error($guard) ? $guard : new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]); } - $finished = $this->finish_order_and_application($row, $order, $note); - if (is_wp_error($finished) || !$finished) { $wpdb->query('ROLLBACK'); return is_wp_error($finished) ? $finished : new \WP_Error('aksrt_after_sale_finish', __('退款状态保存失败', 'aksrt'), ['status' => 500]); } - $applied = CommissionLedger::get_instance()->apply_full_refund($commission); - if (is_wp_error($applied) || $wpdb->query('COMMIT') === false) { $wpdb->query('ROLLBACK'); return is_wp_error($applied) ? $applied : new \WP_Error('aksrt_after_sale_finish', __('退款状态保存失败', 'aksrt'), ['status' => 500]); } - OrderRepository::get_instance()->dispatch_managed_status_events(); - CommissionLedger::get_instance()->clawback_after_refund($commission); - return $this->get((int) $row['id']); - } - - private function sync_gateway_refund(array $row, string $note) { - $order = OrderRepository::get_instance()->get((string) $row['order_number']); - $method = sanitize_key((string) ($order['payment_method'] ?? '')); - $gateway = Gateway::get_instance()->get_gateway($method); - $request_id = (string) ($row['gateway_request_id'] ?? ''); - if (!$order || !$gateway || !method_exists($gateway, 'query_refund') || $request_id === '') return new \WP_Error('aksrt_after_sale_sync_unavailable', __('该支付渠道暂不支持自动查询退款状态', 'aksrt'), ['status' => 409]); - try { - $result = $gateway->query_refund($request_id, (string) $row['order_number']); - } catch (\Throwable $error) { - $result = ['success' => false, 'status' => 'unknown', 'message' => $error->getMessage()]; - } - if (!is_array($result)) { - $result = ['success' => false, 'status' => 'unknown', 'message' => __('支付渠道未返回有效退款结果', 'aksrt')]; - } - $this->update((int) $row['id'], ['gateway_result' => wp_json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'gateway_refund_id' => sanitize_text_field((string) ($result['refund_id'] ?? $row['gateway_refund_id'] ?? ''))]); - $status = sanitize_key((string) ($result['status'] ?? 'unknown')); - if ($status === 'succeeded' && !empty($result['success'])) { - $finished = $this->finish_refund($row, $order, $note !== '' ? $note : (string) ($row['admin_note'] ?? '')); - return is_wp_error($finished) ? $finished : $this->get((int) $row['id']); - } - if ($status === 'failed' && !empty($result['success'])) { - $type = DomainModel::resolve_order_product_type((string) ($order['product_type'] ?? ''), (array) ($order['meta_payload'] ?? [])); - $revoke = $this->assets_to_revoke($order, $type, (array) ($order['meta_payload'] ?? [])); - if (is_wp_error($revoke)) { - $this->update((int) $row['id'], ['admin_note' => __('渠道明确拒绝退款,但无法确认资产补偿内容;申请保持处理中,请人工核对。', 'aksrt') . ' ' . $revoke->get_error_message()]); - return $this->get((int) $row['id']); - } - if ($revoke !== []) { - $compensated = $this->compensate_revoke($row, $order, $revoke, $note); - if (is_wp_error($compensated)) { - $this->update((int) $row['id'], ['admin_note' => __('渠道明确拒绝退款,但资产补偿失败;申请保持处理中,可再次同步重试补偿。', 'aksrt') . ' ' . $compensated->get_error_message()]); - return $this->get((int) $row['id']); - } - } - $this->update((int) $row['id'], ['status' => self::STATUS_REJECTED, 'admin_note' => $note !== '' ? $note : __('支付渠道退款失败,已恢复撤销的资产', 'aksrt')]); - } elseif ($status !== 'succeeded') { - $this->update((int) $row['id'], ['admin_note' => $note !== '' ? $note : __('退款结果仍待支付渠道确认,请稍后同步或人工核对', 'aksrt')]); - } - return $this->get((int) $row['id']); - } - - private function compensate_revoke(array $row, array $order, array $entries, string $note): bool|\WP_Error { - $compensate = array_map(static function (array $entry): array { $entry['delta_minor'] = -(int) $entry['delta_minor']; $entry['entry_type'] = 'credit'; $entry['remark'] = __('退款渠道失败,恢复已扣回资产', 'aksrt'); return $entry; }, $entries); - $result = $this->post_asset_entries($row, $order, $compensate, 'after-sale-revoke-compensate', 'after_sale_revoke_compensate', $note); - return is_wp_error($result) ? $result : true; - } - - private function assets_to_revoke(array $order, string $type, array $meta): array|\WP_Error { - $user_id = (int) ($order['user_id'] ?? 0); - if ($type === DomainModel::ORDER_PRODUCT_RECHARGE) { - $card_meta = is_array($meta['meta'] ?? null) ? $meta['meta'] : []; - try { $minor = AssetAmount::balance((string) ($meta['credited_amount'] ?? $meta['credit_amount'] ?? $card_meta['amount'] ?? ''))->minor(); } catch (\InvalidArgumentException $e) { return new \WP_Error('aksrt_after_sale_asset', __('充值到账金额缺失,不能退款', 'aksrt'), ['status' => 409]); } - return $this->debit_entry($user_id, AssetType::BALANCE, $minor, __('撤销充值余额', 'aksrt')); - } - if ($type === DomainModel::ORDER_PRODUCT_POINTS) { - $card_meta = is_array($meta['meta'] ?? null) ? $meta['meta'] : []; - $points = max(0, (int) ($meta['points'] ?? $card_meta['points'] ?? 0)); - if ($points <= 0) return new \WP_Error('aksrt_after_sale_asset', __('购买积分数量缺失,不能退款', 'aksrt'), ['status' => 409]); - $entries = $this->debit_entry($user_id, AssetType::POINTS, $points, __('撤销订单发放积分', 'aksrt')); - if (sanitize_key((string) ($order['payment_method'] ?? '')) === 'balance') { - try { $paid = AssetAmount::balance((string) ($order['amount'] ?? ''))->minor(); } catch (\InvalidArgumentException $e) { return new \WP_Error('aksrt_after_sale_amount', __('积分订单支付金额无效', 'aksrt'), ['status' => 409]); } - $entries[] = ['user_id' => $user_id, 'asset' => AssetType::BALANCE->value, 'delta_minor' => $paid, 'entry_type' => 'credit', 'remark' => __('退回购买积分使用的余额', 'aksrt')]; - } - return $entries; - } - return []; - } - - private function debit_entry(int $user_id, AssetType $asset, int $minor, string $remark): array|\WP_Error { - if ($user_id <= 0 || $minor <= 0) return new \WP_Error('aksrt_after_sale_asset', __('待撤销资产金额无效', 'aksrt'), ['status' => 409]); - return [['user_id' => $user_id, 'asset' => $asset->value, 'delta_minor' => -$minor, 'entry_type' => 'debit', 'remark' => $remark]]; - } - - private function finish_refund(array $row, array $order, string $note, bool $manage_transaction = true) { - global $wpdb; - $number = (string) $order['order_number']; - $commission = CommissionLedger::get_instance()->prepare_full_refund($number); - if (is_wp_error($commission)) return $commission; - if ($manage_transaction && $wpdb->query('START TRANSACTION') === false) return new \WP_Error('aksrt_after_sale_finish', __('无法开始退款状态更新', 'aksrt'), ['status' => 500]); - $guarded = CommissionLedger::get_instance()->guard_full_refund($commission); - if (is_wp_error($guarded) || $guarded !== true) { if ($manage_transaction) $wpdb->query('ROLLBACK'); return is_wp_error($guarded) ? $guarded : new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]); } - $finished = $this->finish_order_and_application($row, $order, $note); - if (is_wp_error($finished) || !$finished) { if ($manage_transaction) $wpdb->query('ROLLBACK'); return is_wp_error($finished) ? $finished : new \WP_Error('aksrt_after_sale_finish', __('退款成功但本地订单更新失败,请重试同步', 'aksrt'), ['status' => 500]); } - $applied = CommissionLedger::get_instance()->apply_full_refund($commission); - if (is_wp_error($applied) || ($manage_transaction && $wpdb->query('COMMIT') === false)) { if ($manage_transaction) $wpdb->query('ROLLBACK'); return is_wp_error($applied) ? $applied : new \WP_Error('aksrt_after_sale_finish', __('退款状态保存失败', 'aksrt'), ['status' => 500]); } - if ($manage_transaction) { - OrderRepository::get_instance()->dispatch_managed_status_events(); - CommissionLedger::get_instance()->clawback_after_refund($commission); - } - return true; - } - - private function finish_order_and_application(array $row, array $order, string $note): bool|\WP_Error { - $number = (string) $order['order_number']; - if (!$this->revoke_vip_if_needed($order)) return new \WP_Error('aksrt_after_sale_vip', __('会员权益撤销失败', 'aksrt'), ['status' => 409]); - if (!OrderRepository::get_instance()->update_status($number, Order::STATUS_REFUNDED, ['allow_refund' => true, 'after_sale_id' => (int) $row['id'], 'transaction_managed' => true, 'note' => $note !== '' ? $note : __('售后退款完成', 'aksrt')])) return new \WP_Error('aksrt_after_sale_order', __('订单退款状态更新失败', 'aksrt'), ['status' => 409]); - return $this->update((int) $row['id'], ['status' => self::STATUS_COMPLETED, 'admin_note' => $note, 'completed_at' => current_time('mysql'), 'reviewed_at' => current_time('mysql')]); - } - - private function vip_reversal_is_safe(array $order, array $meta): bool { - $before = is_array($meta['vip_before'] ?? null) ? $meta['vip_before'] : null; - $after = is_array($meta['vip_after'] ?? null) ? $meta['vip_after'] : null; - if (!$before || !$after || !class_exists('\\AKSRT\\Content\\VipService')) return false; - $current = \AKSRT\Content\VipService::get_instance()->get_membership_snapshot((int) $order['user_id']); - foreach (['level', 'expires', 'base_level', 'base_expires', 'expired_level'] as $key) { - if ((int) ($current[$key] ?? 0) !== (int) ($after[$key] ?? 0)) return false; - } - return true; - } - - private function revoke_vip_if_needed(array $order): bool { - $type = DomainModel::resolve_order_product_type((string) ($order['product_type'] ?? ''), (array) ($order['meta_payload'] ?? [])); - if ($type !== DomainModel::ORDER_PRODUCT_VIP) return true; - $meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : []; - if (!$this->vip_reversal_is_safe($order, $meta)) return false; - $before = $meta['vip_before']; - $service = \AKSRT\Content\VipService::get_instance(); - if ((int) ($before['level'] ?? 0) <= 0) { - $result = $service->mutate_membership((int) $order['user_id'], 'disable', [], ['reason' => 'order_refund', 'source' => 'after_sale']); - } elseif ((int) ($before['base_level'] ?? 0) === 0 && (int) ($before['base_expires'] ?? 0) === 0 && (int) ($before['expired_level'] ?? 0) === 0) { - $result = $service->mutate_membership((int) $order['user_id'], 'set', ['level' => (int) $before['level'], 'expires' => (int) $before['expires']], ['reason' => 'order_refund', 'source' => 'after_sale']); - } else { - return false; - } - return !is_wp_error($result); - } - - private function refund_amount(array $order): ?string { - if ((string) ($order['currency'] ?? '') === 'points') return (string) max(0, (int) ($order['amount'] ?? 0)); - try { return AssetAmount::balance((string) ($order['amount'] ?? '0'))->decimal(); } catch (\InvalidArgumentException $e) { return null; } - } - - private function cash_minor(string $value): int { try { return AssetAmount::balance($value)->minor(); } catch (\InvalidArgumentException $e) { return 0; } } - - /** @param array> $entries */ - private function asset_entries_available(array $entries): bool { - $required = []; - foreach ($entries as $entry) { - $delta = (int) ($entry['delta_minor'] ?? 0); - if ($delta < 0) { - $key = (int) ($entry['user_id'] ?? 0) . ':' . (string) ($entry['asset'] ?? ''); - $required[$key] = ($required[$key] ?? 0) + abs($delta); - } - } - foreach ($required as $key => $amount) { - [$user_id, $asset] = explode(':', $key, 2); - $balance = (string) $asset === AssetType::POINTS->value - ? \AKSRT\Credits\MetaAssetService::get_instance()->balance_points((int) $user_id) - : \AKSRT\Credits\MetaAssetService::get_instance()->balance_minor((int) $user_id); - if ($balance < (int) $amount) return false; - } - return true; - } - - /** @param array> $entries */ - private function find_active(string $number): ?array { - global $wpdb; - $row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE order_number = %s AND status IN ('pending','processing','approved') ORDER BY id DESC LIMIT 1", $number), ARRAY_A); - return is_array($row) ? $this->normalize($row) : null; - } - - private function get(int $id): ?array { - global $wpdb; - $row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE id = %d", $id), ARRAY_A); - return is_array($row) ? $this->normalize($row) : null; - } - - private function update(int $id, array $data): bool { - global $wpdb; - $formats = array_map(static fn($value): string => is_int($value) ? '%d' : '%s', array_values($data)); - return $wpdb->update($this->table(), $data, ['id' => $id], $formats, ['%d']) !== false; - } - - private function table(): string { return \AKSRT\Core\DBMigrator::table('order_aftersale'); } - - /** @param array $row */ - private function normalize(array $row): array { - $row['id'] = (int) ($row['id'] ?? 0); - $row['user_id'] = (int) ($row['user_id'] ?? 0); - $images = json_decode((string) ($row['images'] ?? ''), true); - $row['images'] = is_array($images) - ? array_values(array_filter($images, static fn($url): bool => is_string($url) && $url !== '')) - : []; - $apply_form = json_decode((string) ($row['apply_form'] ?? ''), true); - $row['apply_form'] = is_array($apply_form) ? $apply_form : null; - return $row; - } -} +[0-9]{20}|[0-9]{22})'; + register_rest_route('aksrt/v1', '/orders/' . $number . '/after-sale', [ + ['methods' => \WP_REST_Server::CREATABLE, 'callback' => [$this, 'rest_submit'], 'permission_callback' => [$this, 'require_user'], 'args' => [ + 'order_number' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'], + 'reason' => ['required' => true, 'sanitize_callback' => 'sanitize_textarea_field'], + 'reason_label' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'], + 'materials' => ['required' => false, 'type' => 'array', 'validate_callback' => static function ($value): bool { + if (!is_array($value) || count($value) > 10) return false; + foreach ($value as $item) { + if (!is_array($item)) return false; + foreach (['label', 'value'] as $key) { + if (isset($item[$key]) && !is_string($item[$key])) return false; + } + } + return true; + }], + 'description' => ['required' => false, 'sanitize_callback' => 'sanitize_textarea_field'], + 'images' => ['required' => false, 'type' => 'array', 'validate_callback' => static function ($value): bool { + return is_array($value) && count($value) <= 3; + }], + ]], + ['methods' => \WP_REST_Server::READABLE, 'callback' => [$this, 'rest_list'], 'permission_callback' => [$this, 'require_user'], 'args' => [ + 'order_number' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'], + ]], + ['methods' => 'DELETE', 'callback' => [$this, 'rest_cancel'], 'permission_callback' => [$this, 'require_user'], 'args' => [ + 'order_number' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'], + ]], + ]); + // 售后举证图片上传:复用评论图片上传链路(大小 / 类型 / 内容审核同一套校验)。 + register_rest_route('aksrt/v1', '/orders/' . $number . '/after-sale/upload-image', [ + ['methods' => \WP_REST_Server::CREATABLE, 'callback' => [$this, 'rest_upload_image'], 'permission_callback' => [$this, 'require_after_sale_image']], + ]); + } + + /** 售后举证图片上传权限:登录 + nonce + 售后开启 + 图片开关开启 + 订单归属本人。 */ + public function require_after_sale_image(\WP_REST_Request $request) { + $user = $this->require_user($request); + if (is_wp_error($user)) { + return $user; + } + if (!class_exists('\AKSRT\Settings\PaymentRuntime')) { + return new \WP_Error('aksrt_after_sale_forbidden', __('售后功能暂未开启', 'aksrt'), ['status' => 403]); + } + $settings = \AKSRT\Settings\PaymentRuntime::get_after_sale_settings(); + if (empty($settings['enabled']) || empty($settings['img_enabled'])) { + return new \WP_Error('aksrt_after_sale_forbidden', __('售后图片上传未开启', 'aksrt'), ['status' => 403]); + } + $number = sanitize_text_field((string) $request->get_param('order_number')); + if (!OrderRepository::get_instance()->get_user_order(get_current_user_id(), $number)) { + return new \WP_Error('aksrt_order_missing', __('订单不存在', 'aksrt'), ['status' => 404]); + } + return true; + } + + public function rest_upload_image(\WP_REST_Request $request) { + if (!class_exists('\AKSRT\Admin\UserController')) { + return new \WP_Error('aksrt_after_sale_upload', __('上传服务不可用', 'aksrt'), ['status' => 500]); + } + return \AKSRT\Admin\UserController::get_instance()->rest_upload_comment_image($request); + } + + public function require_user(\WP_REST_Request $request) { + if (!is_user_logged_in()) return new \WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]); + $nonce = (string) $request->get_header('X-WP-Nonce'); + return $nonce !== '' && wp_verify_nonce($nonce, 'wp_rest') ? true : new \WP_Error('aksrt_rest_nonce', __('安全验证失败,请刷新页面后重试', 'aksrt'), ['status' => 403]); + } + + public function rest_submit(\WP_REST_Request $request) { + $images = $request->get_param('images'); + $materials = $request->get_param('materials'); + $result = $this->create( + get_current_user_id(), + (string) $request->get_param('order_number'), + (string) $request->get_param('reason'), + is_array($images) ? $images : [], + [ + 'reason_label' => (string) $request->get_param('reason_label'), + 'materials' => is_array($materials) ? $materials : [], + 'description' => (string) $request->get_param('description'), + ] + ); + return is_wp_error($result) ? $result : rest_ensure_response(['success' => true, 'data' => $result]); + } + + public function rest_list(\WP_REST_Request $request) { + $number = sanitize_text_field((string) $request->get_param('order_number')); + if (!OrderRepository::get_instance()->get_user_order(get_current_user_id(), $number)) return new \WP_Error('aksrt_order_missing', __('订单不存在', 'aksrt'), ['status' => 404]); + return rest_ensure_response(['success' => true, 'data' => $this->list_for_order($number)]); + } + + public function rest_cancel(\WP_REST_Request $request) { + $result = $this->cancel_for_user(get_current_user_id(), (string) $request->get_param('order_number')); + return is_wp_error($result) ? $result : rest_ensure_response(['success' => true, 'data' => $result]); + } + + /** @param array $apply_form */ + public function create(int $user_id, string $order_number, string $reason, array $images = [], array $apply_form = []) { + $reason = sanitize_textarea_field($reason); + $images = $this->sanitize_images($images); + $apply_form = $this->sanitize_apply_form($apply_form); + if ($user_id <= 0 || !preg_match('/^(?:\d{20}|\d{22})$/', $order_number) || $reason === '' || mb_strlen($reason) > 2000) { + return new \WP_Error('aksrt_after_sale_invalid', __('请填写有效的售后原因', 'aksrt'), ['status' => 400]); + } + global $wpdb; + $lock = 'aksrt_after_sale_' . md5($order_number); + if ((int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 5)', $lock)) !== 1) return new \WP_Error('aksrt_after_sale_busy', __('订单正在处理中,请稍后重试', 'aksrt'), ['status' => 409]); + try { + $order = OrderRepository::get_instance()->get_user_order($user_id, $order_number); + if (!$order || (string) ($order['status'] ?? '') !== Order::STATUS_PAID) return new \WP_Error('aksrt_after_sale_order', __('只有已支付订单可以申请售后', 'aksrt'), ['status' => 409]); + $active = $this->find_active($order_number); + if ($active) { + if ((int) ($active['user_id'] ?? 0) === $user_id && hash_equals((string) ($active['reason'] ?? ''), $reason)) { + return $active; + } + return new \WP_Error('aksrt_after_sale_exists', __('该订单已有未结束售后申请', 'aksrt'), ['status' => 409]); + } + $amount = $this->refund_amount($order); + if ($amount === null) return new \WP_Error('aksrt_after_sale_amount', __('订单退款金额无效', 'aksrt'), ['status' => 400]); + $inserted = $wpdb->insert($this->table(), [ + 'order_number' => $order_number, 'user_id' => $user_id, 'aftersale_type' => 'refund', + 'reason' => $reason, + 'apply_form' => $apply_form === [] ? null : wp_json_encode($apply_form, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'images' => wp_json_encode($images), 'refund_amount' => $amount, + 'status' => self::STATUS_PENDING, 'created_at' => current_time('mysql'), + ], ['%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s']); + return $inserted === 1 ? $this->get((int) $wpdb->insert_id) : new \WP_Error('aksrt_after_sale_db', __('售后申请保存失败', 'aksrt'), ['status' => 500]); + } finally { + $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock)); + } + } + + /** 售后举证图片:最多 3 条,统一走 esc_url_raw;非字符串与空值直接丢弃。 */ + private function sanitize_images(array $images): array { + $clean = []; + foreach ($images as $url) { + if (!is_string($url) || count($clean) >= 3) { + continue; + } + $url = esc_url_raw(trim($url)); + if ($url !== '') { + $clean[] = $url; + } + } + return $clean; + } + + /** + * 结构化申请表单(预置原因 / 申请资料 / 申请说明),供售后详细弹窗分行展示; + * 融合字符串 reason 保持原样入库,保证后台列表与历史数据兼容。 + * + * @param array $form + * @return array + */ + private function sanitize_apply_form(array $form): array { + $reason_label = sanitize_text_field((string) ($form['reason_label'] ?? '')); + if (mb_strlen($reason_label) > 120) $reason_label = mb_substr($reason_label, 0, 120); + $materials = []; + if (is_array($form['materials'] ?? null)) { + foreach ($form['materials'] as $item) { + if (!is_array($item) || count($materials) >= 10) continue; + $label = sanitize_text_field((string) ($item['label'] ?? '')); + $value = sanitize_text_field((string) ($item['value'] ?? '')); + if ($label === '' || $value === '') continue; + if (mb_strlen($label) > 120) $label = mb_substr($label, 0, 120); + if (mb_strlen($value) > 500) $value = mb_substr($value, 0, 500); + $materials[] = ['label' => $label, 'value' => $value]; + } + } + $description = sanitize_textarea_field((string) ($form['description'] ?? '')); + if (mb_strlen($description) > 2000) $description = mb_substr($description, 0, 2000); + $clean = []; + if ($reason_label !== '') $clean['reason_label'] = $reason_label; + if ($materials !== []) $clean['materials'] = $materials; + if ($description !== '') $clean['description'] = $description; + return $clean; + } + + /** 最新一条售后工单(列表视图只取最新态,避免整表拉取)。 */ + public function latest_for_order(string $order_number): ?array { + global $wpdb; + $row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE order_number = %s ORDER BY id DESC LIMIT 1", $order_number), ARRAY_A); + return is_array($row) ? $this->normalize($row) : null; + } + + public function cancel_for_user(int $user_id, string $order_number) { + if ($user_id <= 0 || !preg_match('/^(?:\d{20}|\d{22})$/', $order_number)) return new \WP_Error('aksrt_after_sale_invalid', __('售后申请无效', 'aksrt'), ['status' => 400]); + global $wpdb; + $lock = 'aksrt_after_sale_' . md5($order_number); + if ((int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 5)', $lock)) !== 1) return new \WP_Error('aksrt_after_sale_busy', __('售后申请正在处理中,请稍后重试', 'aksrt'), ['status' => 409]); + try { + $row = $this->find_active($order_number); + if (!$row || (int) ($row['user_id'] ?? 0) !== $user_id) return new \WP_Error('aksrt_after_sale_missing', __('没有可取消的售后申请', 'aksrt'), ['status' => 404]); + if ((string) ($row['status'] ?? '') !== self::STATUS_PENDING) return new \WP_Error('aksrt_after_sale_state', __('售后申请已进入处理阶段,无法取消', 'aksrt'), ['status' => 409]); + if (!$this->update((int) $row['id'], ['status' => self::STATUS_CANCELLED])) return new \WP_Error('aksrt_after_sale_db', __('取消售后申请失败', 'aksrt'), ['status' => 500]); + return $this->get((int) $row['id']); + } finally { + $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock)); + } + } + + /** @return list> */ + public function list_for_order(string $order_number): array { + global $wpdb; + $rows = $wpdb->get_results($wpdb->prepare("SELECT * FROM {$this->table()} WHERE order_number = %s ORDER BY id DESC", $order_number), ARRAY_A); + return array_map([$this, 'normalize'], (array) $rows); + } + + /** @return list> */ + public function admin_list(array $args = []): array { + global $wpdb; + $where = []; + $params = []; + $status = sanitize_key((string) ($args['status'] ?? '')); + $valid = [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_APPROVED, self::STATUS_REJECTED, self::STATUS_CANCELLED, self::STATUS_COMPLETED]; + if ($status !== '' && in_array($status, $valid, true)) { $where[] = 'status = %s'; $params[] = $status; } + foreach (['date_from' => '>=', 'date_to' => '<='] as $key => $operator) { + $date = sanitize_text_field((string) ($args[$key] ?? '')); + if ($date !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { + $where[] = 'created_at ' . $operator . ' %s'; + $params[] = $operator === '>=' ? $date . ' 00:00:00' : $date . ' 23:59:59'; + } + } + $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); + $offset = max(0, (int) ($args['offset'] ?? 0)); + $sql = "SELECT * FROM {$this->table()}" . ($where ? ' WHERE ' . implode(' AND ', $where) : '') . ' ORDER BY id DESC LIMIT %d OFFSET %d'; + $params[] = $limit; $params[] = $offset; + return array_map([$this, 'normalize'], (array) $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A)); + } + + public function count(array $args = []): int { + global $wpdb; + $status = sanitize_key((string) ($args['status'] ?? '')); + $where = []; + $params = []; + if ($status !== '') { $where[] = 'status = %s'; $params[] = $status; } + foreach (['date_from' => '>=', 'date_to' => '<='] as $key => $operator) { + $date = sanitize_text_field((string) ($args[$key] ?? '')); + if ($date !== '' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { + $where[] = 'created_at ' . $operator . ' %s'; + $params[] = $operator === '>=' ? $date . ' 00:00:00' : $date . ' 23:59:59'; + } + } + $sql = "SELECT COUNT(*) FROM {$this->table()}" . ($where ? ' WHERE ' . implode(' AND ', $where) : ''); + return (int) ($params ? $wpdb->get_var($wpdb->prepare($sql, $params)) : $wpdb->get_var($sql)); + } + + public function count_user_open(int $user_id): int { + if ($user_id <= 0) return 0; + global $wpdb; + return (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$this->table()} WHERE user_id = %d AND status IN ('pending','processing','approved')", $user_id)); + } + + /** A refund status transition is valid only for its own active application. */ + public function can_finalize_order_refund(int $after_sale_id, string $order_number): bool { + if ($after_sale_id <= 0 || $order_number === '') return false; + $row = $this->get($after_sale_id); + return is_array($row) + && hash_equals((string) ($row['order_number'] ?? ''), $order_number) + && in_array((string) ($row['status'] ?? ''), [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_APPROVED], true); + } + + public function review(int $id, string $decision, string $note = '') { + if (!current_user_can('manage_options')) return new \WP_Error('aksrt_after_sale_forbidden', __('权限不足', 'aksrt'), ['status' => 403]); + $decision = sanitize_key($decision); + if (!in_array($decision, ['approve', 'reject', 'complete'], true)) return new \WP_Error('aksrt_after_sale_decision', __('审核动作无效', 'aksrt'), ['status' => 400]); + global $wpdb; + $initial = $this->get($id); + if (!$initial) return new \WP_Error('aksrt_after_sale_state', __('售后申请已处理或不存在', 'aksrt'), ['status' => 409]); + $order_number = (string) ($initial['order_number'] ?? ''); + $lock = 'aksrt_after_sale_' . md5($order_number); + if ((int) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s, 5)', $lock)) !== 1) return new \WP_Error('aksrt_after_sale_busy', __('售后申请正在处理中,请稍后重试', 'aksrt'), ['status' => 409]); + try { + $row = $this->get($id); + if (!$row || !hash_equals($order_number, (string) ($row['order_number'] ?? '')) || !in_array((string) $row['status'], [self::STATUS_PENDING, self::STATUS_PROCESSING], true)) return new \WP_Error('aksrt_after_sale_state', __('售后申请已处理或不存在', 'aksrt'), ['status' => 409]); + $note = sanitize_textarea_field($note); + if ((string) $row['status'] === self::STATUS_PROCESSING) { + if ($decision === 'complete') { + $gateway_result = json_decode((string) ($row['gateway_result'] ?? ''), true); + if (!is_array($gateway_result) || sanitize_key((string) ($gateway_result['status'] ?? '')) !== 'manual') { + return new \WP_Error('aksrt_after_sale_manual_only', __('该申请不是待人工确认的退款', 'aksrt'), ['status' => 409]); + } + $order = OrderRepository::get_instance()->get((string) $row['order_number']); + if (!$order) return new \WP_Error('aksrt_after_sale_order', __('订单不存在', 'aksrt'), ['status' => 404]); + $finished = $this->finish_refund($row, $order, $note !== '' ? $note : (string) ($row['admin_note'] ?? '')); + return is_wp_error($finished) ? $finished : $this->get((int) $row['id']); + } + if ($decision === 'reject') { + $gateway_result = json_decode((string) ($row['gateway_result'] ?? ''), true); + if (!is_array($gateway_result) || sanitize_key((string) ($gateway_result['status'] ?? '')) !== 'manual') { + return new \WP_Error('aksrt_after_sale_processing', __('退款处理中,不能拒绝;请同步退款结果', 'aksrt'), ['status' => 409]); + } + $order = OrderRepository::get_instance()->get((string) $row['order_number']); + if (!$order) return new \WP_Error('aksrt_after_sale_order', __('订单不存在', 'aksrt'), ['status' => 404]); + $type = DomainModel::resolve_order_product_type((string) ($order['product_type'] ?? ''), (array) ($order['meta_payload'] ?? [])); + $revoke = $this->assets_to_revoke($order, $type, (array) ($order['meta_payload'] ?? [])); + if (is_wp_error($revoke)) return $revoke; + if ($revoke !== []) { + $compensated = $this->compensate_revoke($row, $order, $revoke, $note); + if (is_wp_error($compensated)) return $compensated; + } + if (!$this->update((int) $row['id'], ['status' => self::STATUS_REJECTED, 'admin_note' => $note !== '' ? $note : __('管理员确认未执行渠道退款,已恢复冻结资产', 'aksrt')])) { + return new \WP_Error('aksrt_after_sale_db', __('售后审核保存失败', 'aksrt'), ['status' => 500]); + } + return $this->get((int) $row['id']); + } + return $this->sync_gateway_refund($row, $note); + } + if ($decision === 'complete') return new \WP_Error('aksrt_after_sale_manual_only', __('只有待人工确认的退款可以直接完成', 'aksrt'), ['status' => 409]); + if ($decision === 'reject') { + if (!$this->update($id, ['status' => self::STATUS_REJECTED, 'admin_note' => $note, 'reviewed_at' => current_time('mysql')])) { + return new \WP_Error('aksrt_after_sale_db', __('售后审核保存失败', 'aksrt'), ['status' => 500]); + } + return $this->get($id); + } + return $this->approve($row, $note); + } finally { + $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock)); + } + } + + private function approve(array $row, string $note) { + $order_number = (string) $row['order_number']; + $order = OrderRepository::get_instance()->get($order_number); + if (!$order || (string) ($order['status'] ?? '') !== Order::STATUS_PAID) return new \WP_Error('aksrt_after_sale_order', __('订单已不是可退款状态', 'aksrt'), ['status' => 409]); + $type = DomainModel::resolve_order_product_type((string) ($order['product_type'] ?? ''), (array) ($order['meta_payload'] ?? [])); + $meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : []; + $method = sanitize_key((string) ($order['payment_method'] ?? '')); + if ($type === DomainModel::ORDER_PRODUCT_VIP && !$this->vip_reversal_is_safe($order, $meta)) return new \WP_Error('aksrt_after_sale_vip_snapshot', __('会员权益已变化或缺少快照,不能安全撤销', 'aksrt'), ['status' => 409]); + $revoke = $this->assets_to_revoke($order, $type, $meta); + if (is_wp_error($revoke)) return $revoke; + if ($method === 'card_pass') { + if (!$this->asset_entries_available($revoke)) return new \WP_Error('aksrt_after_sale_asset', __('当前资产不足,不能撤销卡密权益', 'aksrt'), ['status' => 409]); + return $this->commit_status_refund($row, $order, $note, $revoke); + } + if ($method === 'free') { + return $this->commit_status_refund($row, $order, $note, $revoke); + } + $refund_local = in_array($method, ['balance', 'points'], true); + if ($refund_local) { + $refund_asset = $method === 'points' ? AssetType::POINTS : AssetType::BALANCE; + $refund_minor = $refund_asset === AssetType::POINTS ? max(0, (int) ($order['amount'] ?? 0)) : $this->cash_minor((string) ($row['refund_amount'] ?? '0')); + if ($refund_minor <= 0) return new \WP_Error('aksrt_after_sale_amount', __('退款金额无效', 'aksrt'), ['status' => 400]); + $entries = $revoke; + // A points-purchase reversal already includes the original balance payment + // in assets_to_revoke(); do not return that payment twice. + if (!($type === DomainModel::ORDER_PRODUCT_POINTS && $method === 'balance')) { + $entries[] = ['user_id' => (int) $order['user_id'], 'asset' => $refund_asset->value, 'delta_minor' => $refund_minor, 'entry_type' => 'credit', 'remark' => sprintf(__('订单 %s 售后退款', 'aksrt'), $order_number)]; + } + return $this->commit_local_refund($row, $order, $entries, $note); + } + $gateway = Gateway::get_instance()->get_gateway($method); + if (!$gateway || !method_exists($gateway, 'refund')) return new \WP_Error('aksrt_after_sale_gateway', __('支付渠道不可用,无法退款', 'aksrt'), ['status' => 409]); + $request_id = (string) ($row['gateway_request_id'] ?? '') ?: 'AS_' . (int) $row['id']; + if (!$this->asset_entries_available($revoke)) { + return new \WP_Error('aksrt_after_sale_asset', __('当前资产不足,不能审核通过退款', 'aksrt'), ['status' => 409]); + } + if (!$this->update((int) $row['id'], ['status' => self::STATUS_PROCESSING, 'admin_note' => $note, 'gateway_request_id' => $request_id, 'reviewed_at' => current_time('mysql')])) { + return new \WP_Error('aksrt_after_sale_db', __('售后状态保存失败,未发起退款', 'aksrt'), ['status' => 500]); + } + if ($revoke !== []) { + $held = $this->post_asset_entries($row, $order, $revoke, 'after-sale-revoke', 'after_sale_revoke', $note); + if (is_wp_error($held)) { + $this->update((int) $row['id'], ['status' => self::STATUS_PENDING, 'gateway_request_id' => null, 'reviewed_at' => null]); + return $held; + } + } + try { + $result = $gateway->refund($order_number, (string) $row['refund_amount'], $request_id); + } catch (\Throwable $error) { + $result = ['success' => false, 'status' => 'unknown', 'message' => $error->getMessage()]; + } + if (!is_array($result)) { + $result = ['success' => false, 'status' => 'unknown', 'message' => __('支付渠道未返回有效退款结果', 'aksrt')]; + } + $this->update((int) $row['id'], ['gateway_result' => wp_json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'gateway_refund_id' => sanitize_text_field((string) ($result['refund_id'] ?? ''))]); + $status = sanitize_key((string) ($result['status'] ?? 'unknown')); + if ($status === 'failed') { + if ($revoke !== []) { + $compensated = $this->compensate_revoke($row, $order, $revoke, $note); + if (is_wp_error($compensated)) { + $this->update((int) $row['id'], ['admin_note' => __('渠道明确拒绝退款,但资产补偿失败;申请保持处理中,可再次同步重试补偿。', 'aksrt') . ' ' . $compensated->get_error_message()]); + return $this->get((int) $row['id']); + } + } + $this->update((int) $row['id'], ['status' => self::STATUS_REJECTED, 'admin_note' => sanitize_textarea_field((string) ($result['message'] ?? $note))]); + return $this->get((int) $row['id']); + } + // Missing, unknown, and pending provider outcomes are ambiguous. Keep the + // application processing and the asset clawback in place until query_refund + // explicitly confirms success or failure. + if (empty($result['success']) || $status !== 'succeeded') { + $this->update((int) $row['id'], ['admin_note' => sanitize_textarea_field((string) ($result['message'] ?? __('退款结果仍待支付渠道确认,请稍后同步或人工核对', 'aksrt')))]); + return $this->get((int) $row['id']); + } + $finished = $this->finish_refund($row, $order, $note); + return is_wp_error($finished) ? $finished : $this->get((int) $row['id']); + } + + private function commit_local_refund(array $row, array $order, array $entries, string $note) { + $number = (string) $order['order_number']; + $commission = CommissionLedger::get_instance()->prepare_full_refund($number); + if (is_wp_error($commission)) return $commission; + $guard = static fn() => CommissionLedger::get_instance()->guard_full_refund($commission); + $mutation = function () use ($row, $order, $note, $commission) { + $commission_guard = CommissionLedger::get_instance()->guard_full_refund($commission); + if (is_wp_error($commission_guard) || $commission_guard !== true) return is_wp_error($commission_guard) ? $commission_guard : new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]); + $finished = $this->finish_refund($row, $order, $note, false); + if ($finished === true) { + LedgerService::get_instance()->after_commit(static function (): void { + OrderRepository::get_instance()->dispatch_managed_status_events(); + }); + } + return $finished; + }; + $result = LedgerService::get_instance()->post(new LedgerCommand( + \AKSRT\Ledger\OperationKey::deterministic('after-sale-refund', [(int) $row['id']]), 'order_refund', $entries, + 'order', $number, get_current_user_id(), ['order_number' => $number] + ), $guard, $mutation); + if (is_wp_error($result)) return $result; + CommissionLedger::get_instance()->clawback_after_refund($commission); + return $this->get((int) $row['id']); + } + + private function post_asset_entries(array $row, array $order, array $entries, string $prefix, string $reason, string $note) { + return LedgerService::get_instance()->post(new LedgerCommand( + \AKSRT\Ledger\OperationKey::deterministic($prefix, [(int) $row['id']]), $reason, $entries, + 'order', (string) $order['order_number'], get_current_user_id(), ['order_number' => (string) $order['order_number'], 'after_sale_id' => (int) $row['id']] + )); + } + + private function commit_status_refund(array $row, array $order, string $note, array $entries = []) { + global $wpdb; + $number = (string) $order['order_number']; + $commission = CommissionLedger::get_instance()->prepare_full_refund($number); + if (is_wp_error($commission)) return $commission; + if ($entries !== []) { + $mutation = function () use ($row, $order, $note, $commission) { + $guard = CommissionLedger::get_instance()->guard_full_refund($commission); + if (is_wp_error($guard) || $guard !== true) return is_wp_error($guard) ? $guard : new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]); + $finished = $this->finish_refund($row, $order, $note, false); + if ($finished === true) { + LedgerService::get_instance()->after_commit(static function (): void { OrderRepository::get_instance()->dispatch_managed_status_events(); }); + } + return $finished; + }; + $result = LedgerService::get_instance()->post(new LedgerCommand( + \AKSRT\Ledger\OperationKey::deterministic('after-sale-card-refund', [(int) $row['id']]), 'order_refund', $entries, + 'order', $number, get_current_user_id(), ['order_number' => $number] + ), static fn() => CommissionLedger::get_instance()->guard_full_refund($commission), $mutation); + if (is_wp_error($result)) return $result; + CommissionLedger::get_instance()->clawback_after_refund($commission); + return $this->get((int) $row['id']); + } + if ($wpdb->query('START TRANSACTION') === false) return new \WP_Error('aksrt_after_sale_finish', __('无法开始退款状态更新', 'aksrt'), ['status' => 500]); + $guard = CommissionLedger::get_instance()->guard_full_refund($commission); + if (is_wp_error($guard) || $guard !== true) { $wpdb->query('ROLLBACK'); return is_wp_error($guard) ? $guard : new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]); } + $finished = $this->finish_order_and_application($row, $order, $note); + if (is_wp_error($finished) || !$finished) { $wpdb->query('ROLLBACK'); return is_wp_error($finished) ? $finished : new \WP_Error('aksrt_after_sale_finish', __('退款状态保存失败', 'aksrt'), ['status' => 500]); } + $applied = CommissionLedger::get_instance()->apply_full_refund($commission); + if (is_wp_error($applied) || $wpdb->query('COMMIT') === false) { $wpdb->query('ROLLBACK'); return is_wp_error($applied) ? $applied : new \WP_Error('aksrt_after_sale_finish', __('退款状态保存失败', 'aksrt'), ['status' => 500]); } + OrderRepository::get_instance()->dispatch_managed_status_events(); + CommissionLedger::get_instance()->clawback_after_refund($commission); + return $this->get((int) $row['id']); + } + + private function sync_gateway_refund(array $row, string $note) { + $order = OrderRepository::get_instance()->get((string) $row['order_number']); + $method = sanitize_key((string) ($order['payment_method'] ?? '')); + $gateway = Gateway::get_instance()->get_gateway($method); + $request_id = (string) ($row['gateway_request_id'] ?? ''); + if (!$order || !$gateway || !method_exists($gateway, 'query_refund') || $request_id === '') return new \WP_Error('aksrt_after_sale_sync_unavailable', __('该支付渠道暂不支持自动查询退款状态', 'aksrt'), ['status' => 409]); + try { + $result = $gateway->query_refund($request_id, (string) $row['order_number']); + } catch (\Throwable $error) { + $result = ['success' => false, 'status' => 'unknown', 'message' => $error->getMessage()]; + } + if (!is_array($result)) { + $result = ['success' => false, 'status' => 'unknown', 'message' => __('支付渠道未返回有效退款结果', 'aksrt')]; + } + $this->update((int) $row['id'], ['gateway_result' => wp_json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), 'gateway_refund_id' => sanitize_text_field((string) ($result['refund_id'] ?? $row['gateway_refund_id'] ?? ''))]); + $status = sanitize_key((string) ($result['status'] ?? 'unknown')); + if ($status === 'succeeded' && !empty($result['success'])) { + $finished = $this->finish_refund($row, $order, $note !== '' ? $note : (string) ($row['admin_note'] ?? '')); + return is_wp_error($finished) ? $finished : $this->get((int) $row['id']); + } + if ($status === 'failed' && !empty($result['success'])) { + $type = DomainModel::resolve_order_product_type((string) ($order['product_type'] ?? ''), (array) ($order['meta_payload'] ?? [])); + $revoke = $this->assets_to_revoke($order, $type, (array) ($order['meta_payload'] ?? [])); + if (is_wp_error($revoke)) { + $this->update((int) $row['id'], ['admin_note' => __('渠道明确拒绝退款,但无法确认资产补偿内容;申请保持处理中,请人工核对。', 'aksrt') . ' ' . $revoke->get_error_message()]); + return $this->get((int) $row['id']); + } + if ($revoke !== []) { + $compensated = $this->compensate_revoke($row, $order, $revoke, $note); + if (is_wp_error($compensated)) { + $this->update((int) $row['id'], ['admin_note' => __('渠道明确拒绝退款,但资产补偿失败;申请保持处理中,可再次同步重试补偿。', 'aksrt') . ' ' . $compensated->get_error_message()]); + return $this->get((int) $row['id']); + } + } + $this->update((int) $row['id'], ['status' => self::STATUS_REJECTED, 'admin_note' => $note !== '' ? $note : __('支付渠道退款失败,已恢复撤销的资产', 'aksrt')]); + } elseif ($status !== 'succeeded') { + $this->update((int) $row['id'], ['admin_note' => $note !== '' ? $note : __('退款结果仍待支付渠道确认,请稍后同步或人工核对', 'aksrt')]); + } + return $this->get((int) $row['id']); + } + + private function compensate_revoke(array $row, array $order, array $entries, string $note): bool|\WP_Error { + $compensate = array_map(static function (array $entry): array { $entry['delta_minor'] = -(int) $entry['delta_minor']; $entry['entry_type'] = 'credit'; $entry['remark'] = __('退款渠道失败,恢复已扣回资产', 'aksrt'); return $entry; }, $entries); + $result = $this->post_asset_entries($row, $order, $compensate, 'after-sale-revoke-compensate', 'after_sale_revoke_compensate', $note); + return is_wp_error($result) ? $result : true; + } + + private function assets_to_revoke(array $order, string $type, array $meta): array|\WP_Error { + $user_id = (int) ($order['user_id'] ?? 0); + if ($type === DomainModel::ORDER_PRODUCT_RECHARGE) { + $card_meta = is_array($meta['meta'] ?? null) ? $meta['meta'] : []; + try { $minor = AssetAmount::balance((string) ($meta['credited_amount'] ?? $meta['credit_amount'] ?? $card_meta['amount'] ?? ''))->minor(); } catch (\InvalidArgumentException $e) { return new \WP_Error('aksrt_after_sale_asset', __('充值到账金额缺失,不能退款', 'aksrt'), ['status' => 409]); } + return $this->debit_entry($user_id, AssetType::BALANCE, $minor, __('撤销充值余额', 'aksrt')); + } + if ($type === DomainModel::ORDER_PRODUCT_POINTS) { + $card_meta = is_array($meta['meta'] ?? null) ? $meta['meta'] : []; + $points = max(0, (int) ($meta['points'] ?? $card_meta['points'] ?? 0)); + if ($points <= 0) return new \WP_Error('aksrt_after_sale_asset', __('购买积分数量缺失,不能退款', 'aksrt'), ['status' => 409]); + $entries = $this->debit_entry($user_id, AssetType::POINTS, $points, __('撤销订单发放积分', 'aksrt')); + if (sanitize_key((string) ($order['payment_method'] ?? '')) === 'balance') { + try { $paid = AssetAmount::balance((string) ($order['amount'] ?? ''))->minor(); } catch (\InvalidArgumentException $e) { return new \WP_Error('aksrt_after_sale_amount', __('积分订单支付金额无效', 'aksrt'), ['status' => 409]); } + $entries[] = ['user_id' => $user_id, 'asset' => AssetType::BALANCE->value, 'delta_minor' => $paid, 'entry_type' => 'credit', 'remark' => __('退回购买积分使用的余额', 'aksrt')]; + } + return $entries; + } + return []; + } + + private function debit_entry(int $user_id, AssetType $asset, int $minor, string $remark): array|\WP_Error { + if ($user_id <= 0 || $minor <= 0) return new \WP_Error('aksrt_after_sale_asset', __('待撤销资产金额无效', 'aksrt'), ['status' => 409]); + return [['user_id' => $user_id, 'asset' => $asset->value, 'delta_minor' => -$minor, 'entry_type' => 'debit', 'remark' => $remark]]; + } + + private function finish_refund(array $row, array $order, string $note, bool $manage_transaction = true) { + global $wpdb; + $number = (string) $order['order_number']; + $commission = CommissionLedger::get_instance()->prepare_full_refund($number); + if (is_wp_error($commission)) return $commission; + if ($manage_transaction && $wpdb->query('START TRANSACTION') === false) return new \WP_Error('aksrt_after_sale_finish', __('无法开始退款状态更新', 'aksrt'), ['status' => 500]); + $guarded = CommissionLedger::get_instance()->guard_full_refund($commission); + if (is_wp_error($guarded) || $guarded !== true) { if ($manage_transaction) $wpdb->query('ROLLBACK'); return is_wp_error($guarded) ? $guarded : new \WP_Error('aksrt_commission_state', __('返佣记录状态已变化', 'aksrt'), ['status' => 409]); } + $finished = $this->finish_order_and_application($row, $order, $note); + if (is_wp_error($finished) || !$finished) { if ($manage_transaction) $wpdb->query('ROLLBACK'); return is_wp_error($finished) ? $finished : new \WP_Error('aksrt_after_sale_finish', __('退款成功但本地订单更新失败,请重试同步', 'aksrt'), ['status' => 500]); } + $applied = CommissionLedger::get_instance()->apply_full_refund($commission); + if (is_wp_error($applied) || ($manage_transaction && $wpdb->query('COMMIT') === false)) { if ($manage_transaction) $wpdb->query('ROLLBACK'); return is_wp_error($applied) ? $applied : new \WP_Error('aksrt_after_sale_finish', __('退款状态保存失败', 'aksrt'), ['status' => 500]); } + if ($manage_transaction) { + OrderRepository::get_instance()->dispatch_managed_status_events(); + CommissionLedger::get_instance()->clawback_after_refund($commission); + } + return true; + } + + private function finish_order_and_application(array $row, array $order, string $note): bool|\WP_Error { + $number = (string) $order['order_number']; + if (!$this->revoke_vip_if_needed($order)) return new \WP_Error('aksrt_after_sale_vip', __('会员权益撤销失败', 'aksrt'), ['status' => 409]); + if (!OrderRepository::get_instance()->update_status($number, Order::STATUS_REFUNDED, ['allow_refund' => true, 'after_sale_id' => (int) $row['id'], 'transaction_managed' => true, 'note' => $note !== '' ? $note : __('售后退款完成', 'aksrt')])) return new \WP_Error('aksrt_after_sale_order', __('订单退款状态更新失败', 'aksrt'), ['status' => 409]); + return $this->update((int) $row['id'], ['status' => self::STATUS_COMPLETED, 'admin_note' => $note, 'completed_at' => current_time('mysql'), 'reviewed_at' => current_time('mysql')]); + } + + private function vip_reversal_is_safe(array $order, array $meta): bool { + $before = is_array($meta['vip_before'] ?? null) ? $meta['vip_before'] : null; + $after = is_array($meta['vip_after'] ?? null) ? $meta['vip_after'] : null; + if (!$before || !$after || !class_exists('\\AKSRT\\Content\\VipService')) return false; + $current = \AKSRT\Content\VipService::get_instance()->get_membership_snapshot((int) $order['user_id']); + foreach (['level', 'expires', 'base_level', 'base_expires', 'expired_level'] as $key) { + if ((int) ($current[$key] ?? 0) !== (int) ($after[$key] ?? 0)) return false; + } + return true; + } + + private function revoke_vip_if_needed(array $order): bool { + $type = DomainModel::resolve_order_product_type((string) ($order['product_type'] ?? ''), (array) ($order['meta_payload'] ?? [])); + if ($type !== DomainModel::ORDER_PRODUCT_VIP) return true; + $meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : []; + if (!$this->vip_reversal_is_safe($order, $meta)) return false; + $before = $meta['vip_before']; + $service = \AKSRT\Content\VipService::get_instance(); + if ((int) ($before['level'] ?? 0) <= 0) { + $result = $service->mutate_membership((int) $order['user_id'], 'disable', [], ['reason' => 'order_refund', 'source' => 'after_sale']); + } elseif ((int) ($before['base_level'] ?? 0) === 0 && (int) ($before['base_expires'] ?? 0) === 0 && (int) ($before['expired_level'] ?? 0) === 0) { + $result = $service->mutate_membership((int) $order['user_id'], 'set', ['level' => (int) $before['level'], 'expires' => (int) $before['expires']], ['reason' => 'order_refund', 'source' => 'after_sale']); + } else { + return false; + } + return !is_wp_error($result); + } + + private function refund_amount(array $order): ?string { + if ((string) ($order['currency'] ?? '') === 'points') return (string) max(0, (int) ($order['amount'] ?? 0)); + try { return AssetAmount::balance((string) ($order['amount'] ?? '0'))->decimal(); } catch (\InvalidArgumentException $e) { return null; } + } + + private function cash_minor(string $value): int { try { return AssetAmount::balance($value)->minor(); } catch (\InvalidArgumentException $e) { return 0; } } + + /** @param array> $entries */ + private function asset_entries_available(array $entries): bool { + $required = []; + foreach ($entries as $entry) { + $delta = (int) ($entry['delta_minor'] ?? 0); + if ($delta < 0) { + $key = (int) ($entry['user_id'] ?? 0) . ':' . (string) ($entry['asset'] ?? ''); + $required[$key] = ($required[$key] ?? 0) + abs($delta); + } + } + foreach ($required as $key => $amount) { + [$user_id, $asset] = explode(':', $key, 2); + $balance = (string) $asset === AssetType::POINTS->value + ? \AKSRT\Credits\MetaAssetService::get_instance()->balance_points((int) $user_id) + : \AKSRT\Credits\MetaAssetService::get_instance()->balance_minor((int) $user_id); + if ($balance < (int) $amount) return false; + } + return true; + } + + /** @param array> $entries */ + private function find_active(string $number): ?array { + global $wpdb; + $row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE order_number = %s AND status IN ('pending','processing','approved') ORDER BY id DESC LIMIT 1", $number), ARRAY_A); + return is_array($row) ? $this->normalize($row) : null; + } + + private function get(int $id): ?array { + global $wpdb; + $row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table()} WHERE id = %d", $id), ARRAY_A); + return is_array($row) ? $this->normalize($row) : null; + } + + private function update(int $id, array $data): bool { + global $wpdb; + $formats = array_map(static fn($value): string => is_int($value) ? '%d' : '%s', array_values($data)); + return $wpdb->update($this->table(), $data, ['id' => $id], $formats, ['%d']) !== false; + } + + private function table(): string { return \AKSRT\Core\DBMigrator::table('order_aftersale'); } + + /** @param array $row */ + private function normalize(array $row): array { + $row['id'] = (int) ($row['id'] ?? 0); + $row['user_id'] = (int) ($row['user_id'] ?? 0); + $images = json_decode((string) ($row['images'] ?? ''), true); + $row['images'] = is_array($images) + ? array_values(array_filter($images, static fn($url): bool => is_string($url) && $url !== '')) + : []; + $apply_form = json_decode((string) ($row['apply_form'] ?? ''), true); + $row['apply_form'] = is_array($apply_form) ? $apply_form : null; + return $row; + } +} diff --git a/src/Payment/UserOrders.php b/src/Payment/UserOrders.php index f3bd9b0c..52d7806d 100644 --- a/src/Payment/UserOrders.php +++ b/src/Payment/UserOrders.php @@ -1,552 +1,552 @@ - */ - private array $post_title_cache = []; - - /** @var array */ - private array $post_image_cache = []; - - /** @var array */ - private array $type_labels_cache = []; - - public static function get_instance(): self { - if (null === self::$instance) { - self::$instance = new self(); - } - return self::$instance; - } - - /** - * @param array $args - * @return array - */ - public function get_order_center_view(int $user_id, array $args = []): array { - $query = OrderCenterQuery::from_array($args); - $empty = $this->empty_view($query); - if ($user_id <= 0 || !class_exists(OrderRepository::class)) { - return $empty; - } - - $repo_args = $query->repository_args(); - $repo = OrderRepository::get_instance(); - $orders = $repo->get_user_orders($user_id, $repo_args); - $summary = $repo->get_user_order_center_summary($user_id, $repo_args); - $total = (int) ($summary['total'] ?? 0); - $total_pages = max(1, (int) ceil($total / $query->per_page())); - $page = min($query->page(), $total_pages); - - if ($page !== $query->page() && $total > 0) { - $repo_args['offset'] = ($page - 1) * $query->per_page(); - $orders = $repo->get_user_orders($user_id, $repo_args); - } - - return [ - 'items' => $this->decorate_order_views($orders), - 'total' => $total, - 'total_pages' => $total_pages, - 'page' => $page, - 'query' => array_merge($query->url_values(), ['order_page' => $page]), - 'stats' => is_array($summary['stats'] ?? null) ? $summary['stats'] : ['total' => 0, 'pending' => 0, 'paid' => 0], - 'invalidation_tags' => ['orders'], - 'product_types' => $this->product_type_filter_options(), - ]; - } - - /** @return array|null */ - public function get_order_detail_view(int $user_id, string $order_number, bool $reconcile = false): ?array { - if ($user_id <= 0 || $order_number === '' || !class_exists(OrderRepository::class)) { - return null; - } - - $order = OrderRepository::get_instance()->get_user_order($user_id, $order_number); - if (!$order) { - return null; - } - - $reconciliation = [ - 'state' => 'not_needed', - 'required' => false, - ]; - if (class_exists(Gateway::class)) { - $gateway = Gateway::get_instance(); - $required = $gateway->needs_reconciliation($order); - if ($required && $reconcile) { - $order = $gateway->reconcile_order($order_number, $order) ?: $order; - $required = $gateway->needs_reconciliation($order); - $reconciliation['state'] = $required ? 'refresh_available' : 'checked'; - } elseif ($required) { - $reconciliation['state'] = 'refresh_available'; - } - $reconciliation['required'] = $required; - } - - return $this->decorate_order_view($order, true, $reconciliation); - } - - /** @return array|null */ - public function refresh_order(int $user_id, string $order_number): ?array { - return $this->get_order_detail_view($user_id, $order_number, true); - } - - /** @return array */ - public function get_user_order_stats(int $user_id): array { - if ($user_id <= 0 || !class_exists(OrderRepository::class)) { - return ['total' => 0, 'pending' => 0, 'paid' => 0]; - } - - $summary = OrderRepository::get_instance()->get_user_order_center_summary($user_id); - return is_array($summary['stats'] ?? null) - ? $summary['stats'] - : ['total' => 0, 'pending' => 0, 'paid' => 0]; - } - - /** @return array */ - public function product_type_filter_options(): array { - if ($this->type_labels_cache === []) { - $this->type_labels_cache = DomainModel::order_product_type_labels(); - } - return array_merge(['' => __('全部类型', 'aksrt')], $this->type_labels_cache); - } - - /** - * Prime post and post-meta caches for all resource rows before decoration. - * - * @param array> $orders - */ - private function prime_resource_caches(array $orders): void { - $post_ids = []; - foreach ($orders as $order) { - $meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : []; - foreach ([(int) ($order['product_id'] ?? 0), (int) ($meta['post_id'] ?? 0)] as $post_id) { - if ($post_id > 0) { - $post_ids[$post_id] = $post_id; - } - } - } - if ($post_ids === []) { - return; - } - - _prime_post_caches(array_values($post_ids), false, true); - $thumbnail_ids = []; - foreach ($post_ids as $post_id) { - $thumbnail_id = (int) get_post_thumbnail_id($post_id); - if ($thumbnail_id > 0) { - $thumbnail_ids[$thumbnail_id] = $thumbnail_id; - } - } - if ($thumbnail_ids !== []) { - _prime_post_caches(array_values($thumbnail_ids), false, true); - } - } - - /** - * @param array> $orders - * @return array> - */ - private function decorate_order_views(array $orders): array { - $this->prime_resource_caches($orders); - return array_map(fn(array $order): array => $this->decorate_order_view($order, false), $orders); - } - - /** - * @param array $order - * @param array|null $reconciliation - * @return array - */ - public function decorate_order_view(array $order, bool $detail = true, ?array $reconciliation = null): array { - $meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : []; - $product_type = DomainModel::resolve_order_product_type(sanitize_key((string) ($order['product_type'] ?? '')), $meta); - $product_id = (int) ($order['product_id'] ?? 0); - $status = sanitize_key((string) ($order['status'] ?? Order::STATUS_PENDING)); - $currency = sanitize_key((string) ($order['currency'] ?? 'CNY')); - $is_points = $currency === 'points' - || sanitize_key((string) ($order['payment_method'] ?? '')) === 'points' - || sanitize_key((string) ($meta['pay_modo'] ?? '')) === 'points'; - if ($this->type_labels_cache === []) { - $this->type_labels_cache = DomainModel::order_product_type_labels(); - } - $type_label = $this->type_labels_cache[$product_type] ?? __('订单', 'aksrt'); - $title = $this->resolve_title($meta, $product_type, $product_id, $type_label); - $image = $this->resolve_image($meta, $product_id); - $group = $this->status_group($status); - $payment_method = sanitize_key((string) ($order['payment_method'] ?? '')); - // 卡密核销不发生任何真实支付(现金在售卡时已确认),实付恒为 0, - // 视图层统一归零以覆盖历史行(历史数据不做迁移)。 - $amount = (string) ($order['amount'] ?? '0.00'); - if ($payment_method === 'card_pass') { - $amount = $is_points ? '0' : '0.00'; - } - $can_pay = $group === OrderCenterQuery::STATUS_PENDING; - $can_cancel = $group === OrderCenterQuery::STATUS_PENDING; - $order_number = (string) ($order['order_number'] ?? ''); - $after_sale_service = class_exists(AfterSaleService::class) ? AfterSaleService::get_instance() : null; - $after_sales = $detail && $after_sale_service ? $after_sale_service->list_for_order($order_number) : []; - $after_sale_state = $this->after_sale_state($group, $order_number, (string) ($order['paid_at'] ?? ''), $after_sale_service, $after_sales); - $updated_at = $this->resolve_updated_at($order); - - $view = [ - 'id' => (int) ($order['id'] ?? 0), - 'order_number' => $order_number, - 'amount' => $amount, - 'currency' => $currency, - 'currency_symbol' => $is_points - ? (class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_currency_name() : __('积分', 'aksrt')) - : '¥', - 'is_points' => $is_points, - 'status' => $status, - 'status_group' => $group, - 'status_label' => $this->status_label($group), - 'product_type' => $product_type, - 'product_type_label' => $type_label, - 'product_id' => $product_id, - 'product_name' => $title, - 'image' => $image, - 'created_at' => (string) ($order['created_at'] ?? ''), - 'payment_method' => [ - 'value' => $payment_method, - 'label' => $this->payment_method_label($payment_method), - ], - 'allowed_actions' => [ - 'can_pay' => $can_pay, - 'can_cancel' => $can_cancel, - ], - 'after_sale' => $after_sale_state, - 'time_left' => $this->get_order_countdown((string) ($order['expires_at'] ?? ''), $status), - 'updated_at' => $updated_at, - 'status_version' => $this->resolve_status_version($order, $updated_at), - 'invalidation_tags' => array_values(array_filter([ - 'orders', - $order_number !== '' ? 'order:' . sanitize_key($order_number) : '', - ])), - ]; - if ($detail) { - // 前台订单详情只暴露统一流水号(order_number);账本流水号为后台口径, - // 不下发到前台,避免同一单据出现两个对外号码。 - $view['paid_at'] = (string) ($order['paid_at'] ?? ''); - $view['expires_at'] = (string) ($order['expires_at'] ?? ''); - $view['reconciliation'] = $reconciliation ?: ['state' => 'not_needed', 'required' => false]; - $view['after_sales'] = $after_sales; - } - return $view; - } - - /** - * 售后入口状态——前台「申请售后 / 售后详细」按钮与三个弹窗的唯一数据源。 - * - * 列表模式只拉最新一条工单(latest_for_order);详情模式直接复用已取到的 - * 全量工单(list_for_order 按 id DESC,首条即最新)。 - * - * @param array> $records - * @return array - */ - private function after_sale_state(string $group, string $order_number, string $paid_at, ?AfterSaleService $service, array $records = []): array { - $settings = class_exists('\AKSRT\Settings\PaymentRuntime') - ? \AKSRT\Settings\PaymentRuntime::get_after_sale_settings() - : []; - $settings = is_array($settings) ? $settings : []; - - $open_statuses = ['pending', 'processing', 'approved']; - $latest = null; - $open = false; - if ($service !== null) { - if ($records) { - foreach ($records as $record) { - if ($latest === null) { - $latest = $record; - } - if (in_array((string) ($record['status'] ?? ''), $open_statuses, true)) { - $open = true; - } - } - } elseif ($order_number !== '') { - $latest = $service->latest_for_order($order_number); - $open = $latest !== null && in_array((string) ($latest['status'] ?? ''), $open_statuses, true); - } - } - - // 售后只对已支付订单开放;开关关闭时整体不处理售后。 - $enabled = $group === OrderCenterQuery::STATUS_PAID && !empty($settings['enabled']); - $expired = $enabled && class_exists('\AKSRT\Settings\PaymentRuntime') - && \AKSRT\Settings\PaymentRuntime::after_sale_expired($paid_at); - $has_application = $latest !== null; - - // 可再次申请售后的状态判定(按工单状态推理,不再以「不能再申请」为常态): - // - pending / processing / approved:工单仍在流转(待审核 / 处理中 / 已通过待退款),不允许重复申请; - // - rejected:审核未通过,允许补充资料后再次申请; - // - cancelled:用户自行取消,允许重新发起; - // - completed:退款已完成,工单终结,不允许再次申请。 - $reapplyable_statuses = [AfterSaleService::STATUS_REJECTED, AfterSaleService::STATUS_CANCELLED]; - $latest_status = (string) ($latest['status'] ?? ''); - - return [ - 'enabled' => $enabled, - 'expired' => (bool) $expired, - 'has_application' => $has_application, - 'open' => $open, - 'can_apply' => $enabled && !$expired && !$open, - 'can_reapply' => $enabled && !$expired && $has_application && in_array($latest_status, $reapplyable_statuses, true), - 'latest' => $has_application ? [ - 'status' => (string) ($latest['status'] ?? ''), - 'reason' => (string) ($latest['reason'] ?? ''), - 'apply_form' => is_array($latest['apply_form'] ?? null) ? $latest['apply_form'] : null, - 'images' => array_values((array) ($latest['images'] ?? [])), - 'refund_amount' => (string) ($latest['refund_amount'] ?? '0'), - 'created_at' => (string) ($latest['created_at'] ?? ''), - 'reviewed_at' => (string) ($latest['reviewed_at'] ?? ''), - 'completed_at' => (string) ($latest['completed_at'] ?? ''), - 'admin_note' => (string) ($latest['admin_note'] ?? ''), - ] : null, - ]; - } - - /** @return array */ - private function empty_view(OrderCenterQuery $query): array { - return [ - 'items' => [], - 'total' => 0, - 'total_pages' => 1, - 'page' => 1, - 'query' => $query->url_values(), - 'stats' => ['total' => 0, 'pending' => 0, 'paid' => 0, 'refunded' => 0, 'closed' => 0, 'after_sale_pending' => 0], - 'invalidation_tags' => ['orders'], - 'product_types' => $this->product_type_filter_options(), - ]; - } - - /** @param array $order */ - private function resolve_updated_at(array $order): string { - $latest = ''; - $latest_timestamp = 0; - foreach (['created_at', 'paid_at', 'cancelled_at', 'refunded_at'] as $field) { - $value = trim((string) ($order[$field] ?? '')); - if ($value === '') { - continue; - } - $timestamp = strtotime($value); - if ($timestamp !== false && $timestamp >= $latest_timestamp) { - $latest_timestamp = $timestamp; - $latest = $value; - } - } - return $latest; - } - - /** @param array $order */ - private function resolve_status_version(array $order, string $updated_at): string { - return substr(hash('sha256', implode('|', [ - (int) ($order['id'] ?? 0), - (string) ($order['order_number'] ?? ''), - sanitize_key((string) ($order['status'] ?? '')), - $updated_at, - ])), 0, 16); - } - - private function status_group(string $status): string { - return isset(DomainModel::order_status_labels()[$status]) ? $status : ''; - } - - private function status_label(string $group): string { - return DomainModel::order_status_labels()[$group] ?? __('未知状态', 'aksrt'); - } - - /** @param array $meta */ - private function resolve_title(array $meta, string $type, int $product_id, string $fallback): string { - // 卡密核销订单:按 note.meta 生成可读名称(余额/积分卡记面额,会员卡记档位与时长)。 - if (!empty($meta['card_pass'])) { - $title = $this->resolve_card_pass_title($meta); - if ($title !== '') { - return $title; - } - } - foreach (['product_title', 'title', 'package_title'] as $key) { - if (!empty($meta[$key]) && is_string($meta[$key])) { - return (string) $meta[$key]; - } - } - if ($type === DomainModel::ORDER_PRODUCT_VIP) { - return __('VIP 会员', 'aksrt'); - } - if ($type === DomainModel::ORDER_PRODUCT_RECHARGE) { - return __('余额充值', 'aksrt'); - } - if ($type === DomainModel::ORDER_PRODUCT_POINTS) { - return __('积分购买', 'aksrt'); - } - if ($product_id > 0) { - $cache_key = (string) $product_id; - if (!array_key_exists($cache_key, $this->post_title_cache)) { - $title = get_the_title($product_id); - $this->post_title_cache[$cache_key] = is_string($title) ? $title : ''; - } - if ($this->post_title_cache[$cache_key] !== '') { - return $this->post_title_cache[$cache_key]; - } - } - return $fallback; - } - - /** - * 卡密核销订单名称: - * - 余额卡:卡密兑换-余额充值{面额} - * - 积分卡:卡密兑换-积分充值{点数} - * - 会员卡:购入[{会员档位名}]{N}天(永久卡为「永久」) - * - * @param array $meta - */ - private function resolve_card_pass_title(array $meta): string { - $card_meta = is_array($meta['meta'] ?? null) ? $meta['meta'] : []; - $card_type = sanitize_key((string) ($meta['card_type'] ?? '')); - if ($card_type === ChargeCard::TYPE_BALANCE) { - $amount = (string) ($card_meta['amount'] ?? ''); - if (str_contains($amount, '.')) { - $amount = rtrim(rtrim($amount, '0'), '.'); - } - return __('卡密兑换-余额充值', 'aksrt') . $amount; - } - if ($card_type === ChargeCard::TYPE_CREDITS) { - return __('卡密兑换-积分充值', 'aksrt') . (string) max(0, (int) ($card_meta['points'] ?? 0)); - } - if ($card_type === ChargeCard::TYPE_VIP) { - $level = max(1, min(2, (int) ($card_meta['level'] ?? 1))); - $days = max(0, (int) ($card_meta['days'] ?? 0)); - $levels = class_exists('\AKSRT\Settings\Settings') - ? (\AKSRT\Settings\Settings::get_payment_vip_settings()['levels'] ?? []) - : []; - $level_name = is_array($levels) ? (string) ($levels[$level]['name'] ?? '') : ''; - if ($level_name === '') { - $level_name = 'VIP' . $level; - } - $suffix = $days > 0 ? sprintf(__('%d天', 'aksrt'), $days) : __('永久', 'aksrt'); - return sprintf(__('购入[%1$s]%2$s', 'aksrt'), $level_name, $suffix); - } - return ''; - } - - /** @param array $meta */ - private function resolve_image(array $meta, int $product_id): string { - if (!empty($meta['thumb']) && is_string($meta['thumb'])) { - return esc_url_raw($meta['thumb']); - } - if ($product_id > 0) { - $cache_key = (string) $product_id; - if (!array_key_exists($cache_key, $this->post_image_cache)) { - $image = has_post_thumbnail($product_id) - ? get_the_post_thumbnail_url($product_id, 'thumbnail') - : ''; - $this->post_image_cache[$cache_key] = is_string($image) ? $image : ''; - } - return $this->post_image_cache[$cache_key]; - } - return ''; - } - - private function payment_method_label(string $method): string { - $labels = [ - 'balance' => __('站内余额', 'aksrt'), - 'points' => __('积分支付', 'aksrt'), - 'alipay' => __('支付宝', 'aksrt'), - 'wechatpay' => __('微信支付', 'aksrt'), - 'paypal' => 'PayPal', - 'stripe' => 'Stripe', - ]; - return $labels[$method] ?? ($method !== '' ? $method : __('—', 'aksrt')); - } - - private function get_order_countdown(string $expires_at, string $status): int { - if ($status !== Order::STATUS_PENDING || $expires_at === '') { - return 0; - } - $expires = strtotime($expires_at); - return $expires ? max(0, $expires - current_time('timestamp')) : 0; - } - - /** @return array{success:bool,message?:string,data?:array} */ - public function cancel_order(int $user_id, string $order_number): array { - if ($user_id <= 0 || $order_number === '' || !class_exists(OrderRepository::class)) { - return ['success' => false, 'message' => __('订单不存在。', 'aksrt')]; - } - $repo = OrderRepository::get_instance(); - $order = $repo->get_user_order($user_id, $order_number); - if (!$order || $this->status_group((string) ($order['status'] ?? '')) !== OrderCenterQuery::STATUS_PENDING) { - return ['success' => false, 'message' => __('当前订单不可关闭。', 'aksrt')]; - } - if (!$repo->update_status($order_number, Order::STATUS_CLOSED, [ - 'note' => __('用户在前台关闭订单。', 'aksrt'), - ])) { - return ['success' => false, 'message' => __('关闭订单失败,请稍后重试。', 'aksrt')]; - } - return ['success' => true, 'data' => ['order' => $this->get_order_detail_view($user_id, $order_number)]]; - } - - /** @return array{success:bool,message?:string,data?:array} */ - public function pay_order(int $user_id, string $order_number, string $payment_method = ''): array { - if ($user_id <= 0 || $order_number === '' || !class_exists(OrderRepository::class)) { - return ['success' => false, 'message' => __('订单不存在。', 'aksrt')]; - } - $repo = OrderRepository::get_instance(); - $order = $repo->get_user_order($user_id, $order_number); - if (!$order) { - return ['success' => false, 'message' => __('当前订单状态不可支付。', 'aksrt')]; - } - if ($this->status_group((string) ($order['status'] ?? '')) === OrderCenterQuery::STATUS_PAID) { - return ['success' => true, 'data' => [ - 'redirect' => false, - 'payment_url' => '', - 'order_number' => $order_number, - 'order' => $this->get_order_detail_view($user_id, $order_number), - 'replayed' => true, - 'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id), - 'message' => __('支付成功。', 'aksrt'), - ]]; - } - if ($this->status_group((string) ($order['status'] ?? '')) !== OrderCenterQuery::STATUS_PENDING) { - return ['success' => false, 'message' => __('当前订单状态不可支付。', 'aksrt')]; - } - $stored_method = sanitize_key((string) ($order['payment_method'] ?? '')); - $payment_method = sanitize_key($payment_method ?: ($stored_method ?: 'balance')); - if ($stored_method !== '' && $payment_method !== $stored_method) { - return ['success' => false, 'message' => __('支付方式与订单不一致,请重新下单。', 'aksrt')]; - } - $manager = class_exists(Gateway::class) ? Gateway::get_instance() : null; - $gateway = $manager ? $manager->get_gateway($payment_method) : null; - if (!$gateway || (method_exists($gateway, 'is_configured') && !$gateway->is_configured())) { - return ['success' => false, 'message' => __('当前支付方式不可用。', 'aksrt')]; - } - $order['subject'] = $order['subject'] ?? sprintf(__('订单 %s', 'aksrt'), $order_number); - $order['payment_method'] = $payment_method; - $result = PaymentOrchestrator::pay($gateway, $order, ['paid_note' => __('用户在前台完成支付。', 'aksrt')]); - if (empty($result['success'])) { - return [ - 'success' => false, - 'code' => (string) ($result['code'] ?? 'aksrt_pay_failed'), - 'status' => (int) ($result['status'] ?? 400), - 'message' => (string) ($result['message'] ?? __('支付失败。', 'aksrt')), - ]; - } - return ['success' => true, 'data' => [ - 'redirect' => !empty($result['redirect']), - 'payment_url' => (string) ($result['payment_url'] ?? ''), - 'order_number' => $order_number, - 'order' => empty($result['redirect']) ? $this->get_order_detail_view($user_id, $order_number) : null, - 'replayed' => !empty($result['replayed']), - 'balance' => $payment_method === 'balance' ? \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id) : null, - 'message' => (string) ($result['message'] ?? ''), - ]]; - } -} + */ + private array $post_title_cache = []; + + /** @var array */ + private array $post_image_cache = []; + + /** @var array */ + private array $type_labels_cache = []; + + public static function get_instance(): self { + if (null === self::$instance) { + self::$instance = new self(); + } + return self::$instance; + } + + /** + * @param array $args + * @return array + */ + public function get_order_center_view(int $user_id, array $args = []): array { + $query = OrderCenterQuery::from_array($args); + $empty = $this->empty_view($query); + if ($user_id <= 0 || !class_exists(OrderRepository::class)) { + return $empty; + } + + $repo_args = $query->repository_args(); + $repo = OrderRepository::get_instance(); + $orders = $repo->get_user_orders($user_id, $repo_args); + $summary = $repo->get_user_order_center_summary($user_id, $repo_args); + $total = (int) ($summary['total'] ?? 0); + $total_pages = max(1, (int) ceil($total / $query->per_page())); + $page = min($query->page(), $total_pages); + + if ($page !== $query->page() && $total > 0) { + $repo_args['offset'] = ($page - 1) * $query->per_page(); + $orders = $repo->get_user_orders($user_id, $repo_args); + } + + return [ + 'items' => $this->decorate_order_views($orders), + 'total' => $total, + 'total_pages' => $total_pages, + 'page' => $page, + 'query' => array_merge($query->url_values(), ['order_page' => $page]), + 'stats' => is_array($summary['stats'] ?? null) ? $summary['stats'] : ['total' => 0, 'pending' => 0, 'paid' => 0], + 'invalidation_tags' => ['orders'], + 'product_types' => $this->product_type_filter_options(), + ]; + } + + /** @return array|null */ + public function get_order_detail_view(int $user_id, string $order_number, bool $reconcile = false): ?array { + if ($user_id <= 0 || $order_number === '' || !class_exists(OrderRepository::class)) { + return null; + } + + $order = OrderRepository::get_instance()->get_user_order($user_id, $order_number); + if (!$order) { + return null; + } + + $reconciliation = [ + 'state' => 'not_needed', + 'required' => false, + ]; + if (class_exists(Gateway::class)) { + $gateway = Gateway::get_instance(); + $required = $gateway->needs_reconciliation($order); + if ($required && $reconcile) { + $order = $gateway->reconcile_order($order_number, $order) ?: $order; + $required = $gateway->needs_reconciliation($order); + $reconciliation['state'] = $required ? 'refresh_available' : 'checked'; + } elseif ($required) { + $reconciliation['state'] = 'refresh_available'; + } + $reconciliation['required'] = $required; + } + + return $this->decorate_order_view($order, true, $reconciliation); + } + + /** @return array|null */ + public function refresh_order(int $user_id, string $order_number): ?array { + return $this->get_order_detail_view($user_id, $order_number, true); + } + + /** @return array */ + public function get_user_order_stats(int $user_id): array { + if ($user_id <= 0 || !class_exists(OrderRepository::class)) { + return ['total' => 0, 'pending' => 0, 'paid' => 0]; + } + + $summary = OrderRepository::get_instance()->get_user_order_center_summary($user_id); + return is_array($summary['stats'] ?? null) + ? $summary['stats'] + : ['total' => 0, 'pending' => 0, 'paid' => 0]; + } + + /** @return array */ + public function product_type_filter_options(): array { + if ($this->type_labels_cache === []) { + $this->type_labels_cache = DomainModel::order_product_type_labels(); + } + return array_merge(['' => __('全部类型', 'aksrt')], $this->type_labels_cache); + } + + /** + * Prime post and post-meta caches for all resource rows before decoration. + * + * @param array> $orders + */ + private function prime_resource_caches(array $orders): void { + $post_ids = []; + foreach ($orders as $order) { + $meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : []; + foreach ([(int) ($order['product_id'] ?? 0), (int) ($meta['post_id'] ?? 0)] as $post_id) { + if ($post_id > 0) { + $post_ids[$post_id] = $post_id; + } + } + } + if ($post_ids === []) { + return; + } + + _prime_post_caches(array_values($post_ids), false, true); + $thumbnail_ids = []; + foreach ($post_ids as $post_id) { + $thumbnail_id = (int) get_post_thumbnail_id($post_id); + if ($thumbnail_id > 0) { + $thumbnail_ids[$thumbnail_id] = $thumbnail_id; + } + } + if ($thumbnail_ids !== []) { + _prime_post_caches(array_values($thumbnail_ids), false, true); + } + } + + /** + * @param array> $orders + * @return array> + */ + private function decorate_order_views(array $orders): array { + $this->prime_resource_caches($orders); + return array_map(fn(array $order): array => $this->decorate_order_view($order, false), $orders); + } + + /** + * @param array $order + * @param array|null $reconciliation + * @return array + */ + public function decorate_order_view(array $order, bool $detail = true, ?array $reconciliation = null): array { + $meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : []; + $product_type = DomainModel::resolve_order_product_type(sanitize_key((string) ($order['product_type'] ?? '')), $meta); + $product_id = (int) ($order['product_id'] ?? 0); + $status = sanitize_key((string) ($order['status'] ?? Order::STATUS_PENDING)); + $currency = sanitize_key((string) ($order['currency'] ?? 'CNY')); + $is_points = $currency === 'points' + || sanitize_key((string) ($order['payment_method'] ?? '')) === 'points' + || sanitize_key((string) ($meta['pay_modo'] ?? '')) === 'points'; + if ($this->type_labels_cache === []) { + $this->type_labels_cache = DomainModel::order_product_type_labels(); + } + $type_label = $this->type_labels_cache[$product_type] ?? __('订单', 'aksrt'); + $title = $this->resolve_title($meta, $product_type, $product_id, $type_label); + $image = $this->resolve_image($meta, $product_id); + $group = $this->status_group($status); + $payment_method = sanitize_key((string) ($order['payment_method'] ?? '')); + // 卡密核销不发生任何真实支付(现金在售卡时已确认),实付恒为 0, + // 视图层统一归零以覆盖历史行(历史数据不做迁移)。 + $amount = (string) ($order['amount'] ?? '0.00'); + if ($payment_method === 'card_pass') { + $amount = $is_points ? '0' : '0.00'; + } + $can_pay = $group === OrderCenterQuery::STATUS_PENDING; + $can_cancel = $group === OrderCenterQuery::STATUS_PENDING; + $order_number = (string) ($order['order_number'] ?? ''); + $after_sale_service = class_exists(AfterSaleService::class) ? AfterSaleService::get_instance() : null; + $after_sales = $detail && $after_sale_service ? $after_sale_service->list_for_order($order_number) : []; + $after_sale_state = $this->after_sale_state($group, $order_number, (string) ($order['paid_at'] ?? ''), $after_sale_service, $after_sales); + $updated_at = $this->resolve_updated_at($order); + + $view = [ + 'id' => (int) ($order['id'] ?? 0), + 'order_number' => $order_number, + 'amount' => $amount, + 'currency' => $currency, + 'currency_symbol' => $is_points + ? (class_exists('\AKSRT\Settings\Settings') ? \AKSRT\Settings\Settings::get_currency_name() : __('积分', 'aksrt')) + : '¥', + 'is_points' => $is_points, + 'status' => $status, + 'status_group' => $group, + 'status_label' => $this->status_label($group), + 'product_type' => $product_type, + 'product_type_label' => $type_label, + 'product_id' => $product_id, + 'product_name' => $title, + 'image' => $image, + 'created_at' => (string) ($order['created_at'] ?? ''), + 'payment_method' => [ + 'value' => $payment_method, + 'label' => $this->payment_method_label($payment_method), + ], + 'allowed_actions' => [ + 'can_pay' => $can_pay, + 'can_cancel' => $can_cancel, + ], + 'after_sale' => $after_sale_state, + 'time_left' => $this->get_order_countdown((string) ($order['expires_at'] ?? ''), $status), + 'updated_at' => $updated_at, + 'status_version' => $this->resolve_status_version($order, $updated_at), + 'invalidation_tags' => array_values(array_filter([ + 'orders', + $order_number !== '' ? 'order:' . sanitize_key($order_number) : '', + ])), + ]; + if ($detail) { + // 前台订单详情只暴露统一流水号(order_number);账本流水号为后台口径, + // 不下发到前台,避免同一单据出现两个对外号码。 + $view['paid_at'] = (string) ($order['paid_at'] ?? ''); + $view['expires_at'] = (string) ($order['expires_at'] ?? ''); + $view['reconciliation'] = $reconciliation ?: ['state' => 'not_needed', 'required' => false]; + $view['after_sales'] = $after_sales; + } + return $view; + } + + /** + * 售后入口状态——前台「申请售后 / 售后详细」按钮与三个弹窗的唯一数据源。 + * + * 列表模式只拉最新一条工单(latest_for_order);详情模式直接复用已取到的 + * 全量工单(list_for_order 按 id DESC,首条即最新)。 + * + * @param array> $records + * @return array + */ + private function after_sale_state(string $group, string $order_number, string $paid_at, ?AfterSaleService $service, array $records = []): array { + $settings = class_exists('\AKSRT\Settings\PaymentRuntime') + ? \AKSRT\Settings\PaymentRuntime::get_after_sale_settings() + : []; + $settings = is_array($settings) ? $settings : []; + + $open_statuses = ['pending', 'processing', 'approved']; + $latest = null; + $open = false; + if ($service !== null) { + if ($records) { + foreach ($records as $record) { + if ($latest === null) { + $latest = $record; + } + if (in_array((string) ($record['status'] ?? ''), $open_statuses, true)) { + $open = true; + } + } + } elseif ($order_number !== '') { + $latest = $service->latest_for_order($order_number); + $open = $latest !== null && in_array((string) ($latest['status'] ?? ''), $open_statuses, true); + } + } + + // 售后只对已支付订单开放;开关关闭时整体不处理售后。 + $enabled = $group === OrderCenterQuery::STATUS_PAID && !empty($settings['enabled']); + $expired = $enabled && class_exists('\AKSRT\Settings\PaymentRuntime') + && \AKSRT\Settings\PaymentRuntime::after_sale_expired($paid_at); + $has_application = $latest !== null; + + // 可再次申请售后的状态判定(按工单状态推理,不再以「不能再申请」为常态): + // - pending / processing / approved:工单仍在流转(待审核 / 处理中 / 已通过待退款),不允许重复申请; + // - rejected:审核未通过,允许补充资料后再次申请; + // - cancelled:用户自行取消,允许重新发起; + // - completed:退款已完成,工单终结,不允许再次申请。 + $reapplyable_statuses = [AfterSaleService::STATUS_REJECTED, AfterSaleService::STATUS_CANCELLED]; + $latest_status = (string) ($latest['status'] ?? ''); + + return [ + 'enabled' => $enabled, + 'expired' => (bool) $expired, + 'has_application' => $has_application, + 'open' => $open, + 'can_apply' => $enabled && !$expired && !$open, + 'can_reapply' => $enabled && !$expired && $has_application && in_array($latest_status, $reapplyable_statuses, true), + 'latest' => $has_application ? [ + 'status' => (string) ($latest['status'] ?? ''), + 'reason' => (string) ($latest['reason'] ?? ''), + 'apply_form' => is_array($latest['apply_form'] ?? null) ? $latest['apply_form'] : null, + 'images' => array_values((array) ($latest['images'] ?? [])), + 'refund_amount' => (string) ($latest['refund_amount'] ?? '0'), + 'created_at' => (string) ($latest['created_at'] ?? ''), + 'reviewed_at' => (string) ($latest['reviewed_at'] ?? ''), + 'completed_at' => (string) ($latest['completed_at'] ?? ''), + 'admin_note' => (string) ($latest['admin_note'] ?? ''), + ] : null, + ]; + } + + /** @return array */ + private function empty_view(OrderCenterQuery $query): array { + return [ + 'items' => [], + 'total' => 0, + 'total_pages' => 1, + 'page' => 1, + 'query' => $query->url_values(), + 'stats' => ['total' => 0, 'pending' => 0, 'paid' => 0, 'refunded' => 0, 'closed' => 0, 'after_sale_pending' => 0], + 'invalidation_tags' => ['orders'], + 'product_types' => $this->product_type_filter_options(), + ]; + } + + /** @param array $order */ + private function resolve_updated_at(array $order): string { + $latest = ''; + $latest_timestamp = 0; + foreach (['created_at', 'paid_at', 'cancelled_at', 'refunded_at'] as $field) { + $value = trim((string) ($order[$field] ?? '')); + if ($value === '') { + continue; + } + $timestamp = strtotime($value); + if ($timestamp !== false && $timestamp >= $latest_timestamp) { + $latest_timestamp = $timestamp; + $latest = $value; + } + } + return $latest; + } + + /** @param array $order */ + private function resolve_status_version(array $order, string $updated_at): string { + return substr(hash('sha256', implode('|', [ + (int) ($order['id'] ?? 0), + (string) ($order['order_number'] ?? ''), + sanitize_key((string) ($order['status'] ?? '')), + $updated_at, + ])), 0, 16); + } + + private function status_group(string $status): string { + return isset(DomainModel::order_status_labels()[$status]) ? $status : ''; + } + + private function status_label(string $group): string { + return DomainModel::order_status_labels()[$group] ?? __('未知状态', 'aksrt'); + } + + /** @param array $meta */ + private function resolve_title(array $meta, string $type, int $product_id, string $fallback): string { + // 卡密核销订单:按 note.meta 生成可读名称(余额/积分卡记面额,会员卡记档位与时长)。 + if (!empty($meta['card_pass'])) { + $title = $this->resolve_card_pass_title($meta); + if ($title !== '') { + return $title; + } + } + foreach (['product_title', 'title', 'package_title'] as $key) { + if (!empty($meta[$key]) && is_string($meta[$key])) { + return (string) $meta[$key]; + } + } + if ($type === DomainModel::ORDER_PRODUCT_VIP) { + return __('VIP 会员', 'aksrt'); + } + if ($type === DomainModel::ORDER_PRODUCT_RECHARGE) { + return __('余额充值', 'aksrt'); + } + if ($type === DomainModel::ORDER_PRODUCT_POINTS) { + return __('积分购买', 'aksrt'); + } + if ($product_id > 0) { + $cache_key = (string) $product_id; + if (!array_key_exists($cache_key, $this->post_title_cache)) { + $title = get_the_title($product_id); + $this->post_title_cache[$cache_key] = is_string($title) ? $title : ''; + } + if ($this->post_title_cache[$cache_key] !== '') { + return $this->post_title_cache[$cache_key]; + } + } + return $fallback; + } + + /** + * 卡密核销订单名称: + * - 余额卡:卡密兑换-余额充值{面额} + * - 积分卡:卡密兑换-积分充值{点数} + * - 会员卡:购入[{会员档位名}]{N}天(永久卡为「永久」) + * + * @param array $meta + */ + private function resolve_card_pass_title(array $meta): string { + $card_meta = is_array($meta['meta'] ?? null) ? $meta['meta'] : []; + $card_type = sanitize_key((string) ($meta['card_type'] ?? '')); + if ($card_type === ChargeCard::TYPE_BALANCE) { + $amount = (string) ($card_meta['amount'] ?? ''); + if (str_contains($amount, '.')) { + $amount = rtrim(rtrim($amount, '0'), '.'); + } + return __('卡密兑换-余额充值', 'aksrt') . $amount; + } + if ($card_type === ChargeCard::TYPE_CREDITS) { + return __('卡密兑换-积分充值', 'aksrt') . (string) max(0, (int) ($card_meta['points'] ?? 0)); + } + if ($card_type === ChargeCard::TYPE_VIP) { + $level = max(1, min(2, (int) ($card_meta['level'] ?? 1))); + $days = max(0, (int) ($card_meta['days'] ?? 0)); + $levels = class_exists('\AKSRT\Settings\Settings') + ? (\AKSRT\Settings\Settings::get_payment_vip_settings()['levels'] ?? []) + : []; + $level_name = is_array($levels) ? (string) ($levels[$level]['name'] ?? '') : ''; + if ($level_name === '') { + $level_name = 'VIP' . $level; + } + $suffix = $days > 0 ? sprintf(__('%d天', 'aksrt'), $days) : __('永久', 'aksrt'); + return sprintf(__('购入[%1$s]%2$s', 'aksrt'), $level_name, $suffix); + } + return ''; + } + + /** @param array $meta */ + private function resolve_image(array $meta, int $product_id): string { + if (!empty($meta['thumb']) && is_string($meta['thumb'])) { + return esc_url_raw($meta['thumb']); + } + if ($product_id > 0) { + $cache_key = (string) $product_id; + if (!array_key_exists($cache_key, $this->post_image_cache)) { + $image = has_post_thumbnail($product_id) + ? get_the_post_thumbnail_url($product_id, 'thumbnail') + : ''; + $this->post_image_cache[$cache_key] = is_string($image) ? $image : ''; + } + return $this->post_image_cache[$cache_key]; + } + return ''; + } + + private function payment_method_label(string $method): string { + $labels = [ + 'balance' => __('站内余额', 'aksrt'), + 'points' => __('积分支付', 'aksrt'), + 'alipay' => __('支付宝', 'aksrt'), + 'wechatpay' => __('微信支付', 'aksrt'), + 'paypal' => 'PayPal', + 'stripe' => 'Stripe', + ]; + return $labels[$method] ?? ($method !== '' ? $method : __('—', 'aksrt')); + } + + private function get_order_countdown(string $expires_at, string $status): int { + if ($status !== Order::STATUS_PENDING || $expires_at === '') { + return 0; + } + $expires = strtotime($expires_at); + return $expires ? max(0, $expires - current_time('timestamp')) : 0; + } + + /** @return array{success:bool,message?:string,data?:array} */ + public function cancel_order(int $user_id, string $order_number): array { + if ($user_id <= 0 || $order_number === '' || !class_exists(OrderRepository::class)) { + return ['success' => false, 'message' => __('订单不存在。', 'aksrt')]; + } + $repo = OrderRepository::get_instance(); + $order = $repo->get_user_order($user_id, $order_number); + if (!$order || $this->status_group((string) ($order['status'] ?? '')) !== OrderCenterQuery::STATUS_PENDING) { + return ['success' => false, 'message' => __('当前订单不可关闭。', 'aksrt')]; + } + if (!$repo->update_status($order_number, Order::STATUS_CLOSED, [ + 'note' => __('用户在前台关闭订单。', 'aksrt'), + ])) { + return ['success' => false, 'message' => __('关闭订单失败,请稍后重试。', 'aksrt')]; + } + return ['success' => true, 'data' => ['order' => $this->get_order_detail_view($user_id, $order_number)]]; + } + + /** @return array{success:bool,message?:string,data?:array} */ + public function pay_order(int $user_id, string $order_number, string $payment_method = ''): array { + if ($user_id <= 0 || $order_number === '' || !class_exists(OrderRepository::class)) { + return ['success' => false, 'message' => __('订单不存在。', 'aksrt')]; + } + $repo = OrderRepository::get_instance(); + $order = $repo->get_user_order($user_id, $order_number); + if (!$order) { + return ['success' => false, 'message' => __('当前订单状态不可支付。', 'aksrt')]; + } + if ($this->status_group((string) ($order['status'] ?? '')) === OrderCenterQuery::STATUS_PAID) { + return ['success' => true, 'data' => [ + 'redirect' => false, + 'payment_url' => '', + 'order_number' => $order_number, + 'order' => $this->get_order_detail_view($user_id, $order_number), + 'replayed' => true, + 'balance' => \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id), + 'message' => __('支付成功。', 'aksrt'), + ]]; + } + if ($this->status_group((string) ($order['status'] ?? '')) !== OrderCenterQuery::STATUS_PENDING) { + return ['success' => false, 'message' => __('当前订单状态不可支付。', 'aksrt')]; + } + $stored_method = sanitize_key((string) ($order['payment_method'] ?? '')); + $payment_method = sanitize_key($payment_method ?: ($stored_method ?: 'balance')); + if ($stored_method !== '' && $payment_method !== $stored_method) { + return ['success' => false, 'message' => __('支付方式与订单不一致,请重新下单。', 'aksrt')]; + } + $manager = class_exists(Gateway::class) ? Gateway::get_instance() : null; + $gateway = $manager ? $manager->get_gateway($payment_method) : null; + if (!$gateway || (method_exists($gateway, 'is_configured') && !$gateway->is_configured())) { + return ['success' => false, 'message' => __('当前支付方式不可用。', 'aksrt')]; + } + $order['subject'] = $order['subject'] ?? sprintf(__('订单 %s', 'aksrt'), $order_number); + $order['payment_method'] = $payment_method; + $result = PaymentOrchestrator::pay($gateway, $order, ['paid_note' => __('用户在前台完成支付。', 'aksrt')]); + if (empty($result['success'])) { + return [ + 'success' => false, + 'code' => (string) ($result['code'] ?? 'aksrt_pay_failed'), + 'status' => (int) ($result['status'] ?? 400), + 'message' => (string) ($result['message'] ?? __('支付失败。', 'aksrt')), + ]; + } + return ['success' => true, 'data' => [ + 'redirect' => !empty($result['redirect']), + 'payment_url' => (string) ($result['payment_url'] ?? ''), + 'order_number' => $order_number, + 'order' => empty($result['redirect']) ? $this->get_order_detail_view($user_id, $order_number) : null, + 'replayed' => !empty($result['replayed']), + 'balance' => $payment_method === 'balance' ? \AKSRT\Ledger\LedgerService::get_instance()->balance_decimal($user_id) : null, + 'message' => (string) ($result['message'] ?? ''), + ]]; + } +} diff --git a/tests/widget-system/after-sale-policy-contract.test.cjs b/tests/widget-system/after-sale-policy-contract.test.cjs index 496bc00d..341d5ba3 100644 --- a/tests/widget-system/after-sale-policy-contract.test.cjs +++ b/tests/widget-system/after-sale-policy-contract.test.cjs @@ -1,389 +1,389 @@ -'use strict' - -/** - * 「售后策略」功能契约: - * 1) 后台 schema(PHP SchemaPayment after_sale 分区)与 SPA JS schema 的键、顺序、 - * 类型、默认值 1:1 对齐,且 schema 与侧边页均已注册(/payment/after-sale 可达); - * 2) 运行时(PaymentRuntime)缺键回退 schema 默认、HTML 走 kses、时效边界正确; - * 3) 售后工单:images 列、create 图片清洗与上限、上传路由权限、latest_for_order; - * 4) 订单视图模型输出 after_sale 状态(按钮矩阵的唯一数据源); - * 5) 前台:申请/超时效/售后详细三个独立弹窗、modal-mini + 蓝色炫彩头、placeholder 与 - * 图片上传配置化、不可再申请提示原文、订单详情弹窗不再内嵌售后表单(死代码清理)。 - */ - -const assert = require('node:assert/strict') -const fs = require('node:fs') -const path = require('node:path') -const { spawnSync } = require('node:child_process') -const test = require('node:test') - -const root = path.resolve(__dirname, '..', '..') -const read = (file) => fs.readFileSync(path.join(root, file), 'utf8') - -const FIELD_ORDER = [ - 'after_sale_s', - 'after_sale_time_limit', - 'after_sale_expired_text', - 'after_sale_reasons', - 'after_sale_desc', - 'after_sale_materials', - 'after_sale_reason_placeholder', - 'after_sale_img_s', -] - -/** Extract the balanced [...] block assigned to a PHP array key inside a section body. */ -function phpSection(src, sectionKey) { - const start = src.indexOf(`'${sectionKey}' => [`) - assert.notEqual(start, -1, `PHP schema has a ${sectionKey} section`) - const open = src.indexOf('[', start) - let depth = 0 - for (let i = open; i < src.length; i++) { - const ch = src[i] - if (ch === "'" || ch === '"') { - const quote = ch - i++ - while (i < src.length) { - if (src[i] === '\\') { i += 2; continue } - if (src[i] === quote) break - i++ - } - continue - } - if (ch === '[') depth++ - else if (ch === ']') { - depth-- - if (depth === 0) return src.slice(open, i + 1) - } - } - throw new Error(`unbalanced PHP section: ${sectionKey}`) -} - -test('after-sale schema is 1:1 between PHP and SPA', () => { - const php = phpSection(read('src/Admin/Schema/SchemaPayment.php'), 'after_sale') - const js = read('assets/admin/src/schema/sections/payment/afterSale.js') - - assert.match(php, /'key'\s*=>\s*'after_sale'/) - assert.match(php, /'title'\s*=>\s*'售后策略'/) - assert.match(php, /'option_key'\s*=>\s*'aksrt_settings'/) - - // 字段键、顺序、类型一致(JS 侧 defaults 与 groups 同序,按首次出现去重) - const phpKeys = [...php.matchAll(/'key'\s*=>\s*'([a-z0-9_]+)'/g)].map((m) => m[1]) - const jsKeys = [...js.matchAll(/key:\s*'([a-z0-9_]+)'/g)].map((m) => m[1]) - const sectionKeys = (keys) => [...new Set(keys.filter((key) => FIELD_ORDER.includes(key)))] - assert.deepEqual(sectionKeys(phpKeys), FIELD_ORDER) - assert.deepEqual(sectionKeys(jsKeys), FIELD_ORDER) - - for (const [phpType, jsType] of [ - ['switch', 'switch'], - ['number', 'number'], - ['textarea', 'textarea'], - ['group', 'group'], - ['input', 'input'], - ]) { - assert.ok(php.includes(`'type' => '${phpType}'`), `missing PHP field type ${phpType}`) - assert.ok(js.includes(`type: '${jsType}'`), `missing JS field type ${jsType}`) - } - - // 默认值一致(开关/时效/占位文案/预置原因/所需材料) - assert.match(php, /'after_sale_s', 'label' => '售后功能', 'type' => 'switch', 'default' => true/) - assert.match(js, /after_sale_s:\s*true/) - assert.match(js, /key: 'after_sale_s', label: '售后功能', type: 'switch', default: true/) - assert.match(php, /'after_sale_time_limit', 'label' => '申请售后时效\(天\)', 'type' => 'number', 'default' => 7, 'min' => 0, 'max' => 3650/) - assert.match(js, /key: 'after_sale_time_limit', label: '申请售后时效\(天\)', type: 'number', default: 7, min: 0, max: 3650/) - assert.match(php, /'after_sale_reason_placeholder', 'label' => '申请说明占位文案', 'type' => 'input', 'default' => '必填,请您详细填写申请原因'/) - assert.match(js, /key: 'after_sale_reason_placeholder', label: '申请说明占位文案', type: 'input', default: '必填,请您详细填写申请原因'/) - assert.match(php, /'after_sale_img_s', 'label' => '申请售后可上传图片', 'type' => 'switch', 'default' => true/) - assert.match(js, /key: 'after_sale_img_s', label: '申请售后可上传图片', type: 'switch', default: true/) - - for (const reason of ['质量问题', '假冒品牌', '其他原因']) { - assert.ok(php.includes(`'t' => '${reason}'`), `missing PHP preset reason ${reason}`) - assert.ok(js.includes(`{ t: '${reason}' }`), `missing JS preset reason ${reason}`) - } - for (const material of ['联系邮箱', '联系电话']) { - assert.ok(php.includes(`'t' => '${material}'`), `missing PHP material ${material}`) - assert.ok(js.includes(`{ t: '${material}' }`), `missing JS material ${material}`) - } - - // HTML 配置项(超时效提示 / 申请说明)走 kses 通道 - assert.match(php, /'after_sale_expired_text', 'label' => '超过时效提示', 'type' => 'textarea', 'sanitize_type' => 'html'/) - assert.match(php, /'after_sale_desc', 'label' => '申请售后说明', 'type' => 'textarea', 'sanitize_type' => 'html'/) - assert.match(js, /key: 'after_sale_expired_text', label: '超过时效提示', type: 'textarea', sanitize_type: 'html'/) - assert.match(js, /key: 'after_sale_desc', label: '申请售后说明', type: 'textarea', sanitize_type: 'html'/) -}) - -test('after-sale schema and sidebar page are registered under 支付&付费', () => { - const schemaIndex = read('assets/admin/src/schema/sections/payment/index.js') - assert.match(schemaIndex, /import \{ afterSaleSchema \} from '\.\/afterSale\.js'/) - assert.match(schemaIndex, /after_sale:\s*afterSaleSchema/) - - const section = read('assets/admin/src/constants/sections/payment.js') - assert.match(section, /key: 'payment-after-sale'/) - assert.match(section, /name: 'PaymentAfterSale'/) - assert.match(section, /label: '售后策略'/) - assert.match(section, /route: '\/payment\/after-sale'/) - assert.match(section, /canonicalDomain: 'payment'/) - assert.match(section, /section: 'after_sale'/) - - // 侧栏条目位于提现策略之后(支付&付费末位) - assert.ok(section.indexOf("key: 'payment-after-sale'") > section.indexOf("key: 'payment-withdrawal'")) - - const schemaRegistry = read('assets/admin/src/schema/index.js') - assert.match(schemaRegistry, /paymentSchemas/) -}) - -test('after-sale runtime resolves defaults, overrides and the expiry boundary matrix', () => { - const result = spawnSync(process.env.PHP_BINARY || 'php', [path.join(__dirname, 'after-sale-policy-harness.php')], { - cwd: root, - encoding: 'utf8', - }) - assert.equal(result.status, 0, result.stderr || result.stdout) - const payload = JSON.parse(result.stdout) - - assert.deepEqual(payload.defaults, { - enabled: true, - time_limit: 7, - expired_text: '
抱歉,您的订单已超过售后申请时效
', - reasons: [ - { slug: 'c440cdf71825', name: '质量问题' }, - { slug: '2ead35a456a0', name: '假冒品牌' }, - { slug: 'fba26eab6b2b', name: '其他原因' }, - ], - materials: ['联系邮箱', '联系电话'], - desc: '', - img_enabled: true, - reason_placeholder: '必填,请您详细填写申请原因', - }) - - assert.equal(payload.overridden.enabled, false) - assert.equal(payload.overridden.time_limit, 30) - assert.equal(payload.overridden.img_enabled, false) - assert.equal(payload.overridden.reason_placeholder, '请描述问题') - assert.deepEqual(payload.overridden.reasons, [ - { slug: 'c440cdf71825', name: '质量问题' }, - { slug: '327e0ab68567', name: '发错货' }, - ]) - assert.deepEqual(payload.overridden.materials, ['联系邮箱']) - // HTML 配置项剥离 script,保留 div - assert.equal(payload.overridden.expired_text, '
已超过时效
') - assert.equal(payload.overridden.desc, '
请保持商品完好
') - - assert.deepEqual(payload.expired_ok, { - within: true, - exact_boundary: true, - just_expired: true, - long_expired: true, - no_limit: true, - missing_paid: true, - }) -}) - -test('after-sale tickets persist evidence images and expose the latest row', () => { - const schema = read('src/Core/DBMigratorPart2.php') - assert.match(schema, /CREATE TABLE \{\$prefix\}aksrt_order_aftersale[\s\S]*?images LONGTEXT DEFAULT NULL[\s\S]*?refund_amount/) - - // images 列随 schema 2.5.1 引入:dbDelta 只在版本变化时重跑,列变更必须同步升版本, - // 否则老安装不会 ALTER TABLE,写入直接报 Unknown column 'images'。 - const migrator = read('src/Core/DBMigrator.php') - const version = migrator.match(/const SCHEMA_VERSION = '(\d+)\.(\d+)\.(\d+)'/) - assert.ok(version, 'DBMigrator exposes a numeric SCHEMA_VERSION') - const [major, minor, patch] = version.slice(1).map(Number) - assert.ok( - major > 2 || (major === 2 && minor > 5) || (major === 2 && minor === 5 && patch >= 1), - `SCHEMA_VERSION must be at least 2.5.1 after adding order_aftersale.images, got ${major}.${minor}.${patch}` - ) - - const service = read('src/Payment/AfterSaleService.php') - assert.match(service, /public function create\(int \$user_id, string \$order_number, string \$reason, array \$images = \[\], array \$apply_form = \[\]\)/) - // 结构化申请表单:预置原因 / 申请资料 / 申请说明独立入库(reason 融合串保持原样) - assert.match(service, /\$apply_form = \$this->sanitize_apply_form\(\$apply_form\);/) - assert.match(service, /private function sanitize_apply_form\(array \$form\): array/) - assert.match(service, /'apply_form' => \$apply_form === \[\] \? null : wp_json_encode\(\$apply_form/) - assert.match(service, /\$images = \$this->sanitize_images\(\$images\);/) - assert.match(service, /'images' => wp_json_encode\(\$images\)/) - assert.match(service, /private function sanitize_images\(array \$images\): array \{[\s\S]*?count\(\$clean\) >= 3[\s\S]*?esc_url_raw/) - assert.match(service, /public function latest_for_order\(string \$order_number\): \?array \{[\s\S]*?ORDER BY id DESC LIMIT 1/) - // normalize 输出 images 数组(坏 JSON / 空串回落空数组) - assert.match(service, /json_decode\(\(string\) \(\$row\['images'\] \?\? ''\), true\)/) - - // 图片上传路由:登录 + nonce + 售后/图片开关 + 订单归属;回调委托评论图片上传 - assert.match(service, /\/orders\/' \. \$number \. '\/after-sale\/upload-image'/) - assert.match(service, /public function require_after_sale_image\(\\WP_REST_Request \$request\)/) - assert.match(service, /get_after_sale_settings\(\)[\s\S]*?empty\(\$settings\['enabled'\]\)[\s\S]*?empty\(\$settings\['img_enabled'\]\)/) - assert.match(service, /get_user_order\(get_current_user_id\(\), \$number\)/) - assert.match(service, /UserController::get_instance\(\)->rest_upload_comment_image\(\$request\)/) - assert.match(service, /'images' => \['required' => false, 'type' => 'array'[\s\S]*?count\(\$value\) <= 3/) - - // 提交 REST 透传 images - // rest_submit 同步上报结构化申请表单(reason_label / materials / description) - assert.match(service, /'reason_label' => \(string\) \$request->get_param\('reason_label'\),/) - assert.match(service, /'materials' +\=> is_array\(\$materials\) \? \$materials : \[\],/) - assert.match(service, /'description' +\=> \(string\) \$request->get_param\('description'\),/) - - // 既有售后资产契约(锁 / 退款 / 完成)不受影响 - assert.match(service, /GET_LOCK\(%s, 5\)/) - assert.match(service, /Order::STATUS_PAID/) -}) - -test('order view model exposes the after-sale button matrix', () => { - const userOrders = read('src/Payment/UserOrders.php') - assert.match(userOrders, /private function after_sale_state\(string \$group, string \$order_number, string \$paid_at, \?AfterSaleService \$service, array \$records = \[\]\): array \{/) - - assert.match(userOrders, /'after_sale' => \$after_sale_state,/) - assert.match(userOrders, /'enabled'\s*=> \$enabled,/) - assert.match(userOrders, /'expired'\s*=> \(bool\) \$expired,/) - assert.match(userOrders, /'has_application' => \$has_application,/) - assert.match(userOrders, /'open'\s*=> \$open,/) - assert.match(userOrders, /'can_apply'\s*=> \$enabled && !\$expired && !\$open,/) - // 可再次申请售后按工单状态推理:rejected(被拒绝)/ cancelled(已取消)允许再次申请; - // pending / processing / approved(流转中)与 completed(已完结)不允许 - assert.match(userOrders, /\$reapplyable_statuses = \[AfterSaleService::STATUS_REJECTED, AfterSaleService::STATUS_CANCELLED\];/) - assert.match(userOrders, /'can_reapply'\s*=> \$enabled && !\$expired && \$has_application && in_array\(\$latest_status, \$reapplyable_statuses, true\),/) - assert.match(userOrders, /'latest'\s*=> \$has_application \? \[/) - assert.match(userOrders, /'apply_form'\s*=> is_array\(\$latest\['apply_form'\] \?\? null\) \? \$latest\['apply_form'\] : null,/) - - // 仅已支付订单开放;开关关闭整体不处理售后 - assert.match(userOrders, /\$enabled = \$group === OrderCenterQuery::STATUS_PAID && !empty\(\$settings\['enabled'\]\);/) - // 时效以支付时间为准 - assert.match(userOrders, /\$this->after_sale_state\(\$group, \$order_number, \(string\) \(\$order\['paid_at'\] \?\? ''\), \$after_sale_service, \$after_sales\)/) - // 列表模式单行查询,详情模式复用全量工单 - assert.match(userOrders, /\$after_sales = \$detail && \$after_sale_service \? \$after_sale_service->list_for_order\(\$order_number\) : \[\];/) - assert.match(userOrders, /\$latest = \$service->latest_for_order\(\$order_number\);/) - assert.match(userOrders, /\$view\['after_sales'\] = \$after_sales;/) - - // 旧 can_after_sale 双路已移除 - assert.ok(!userOrders.includes('can_after_sale'), 'can_after_sale must be replaced by the after_sale state') -}) - -test('frontend after-sale modals are standalone and config-driven', () => { - const js = read('assets/js/order-after-sale.js') - - // 三个独立弹窗 + modal-mini 几何 + 蓝色炫彩标题区 - assert.match(js, /className: 'aksrt-after-sale-apply-modal'/) - assert.match(js, /className: 'aksrt-after-sale-detail-modal'/) - assert.match(js, /className: 'aksrt-after-sale-expired-modal'/) - assert.match(js, /modal-colorful-header colorful-bg jb-blue/) - assert.match(js, /AksrtModalRuntime\.open\(\{/) - - // 滚动容器与禁封申诉弹窗同口径:no-scrollbar 完全隐藏滚动条(含细滚动条), - // 避免 modal-mini 弹窗边缘出现可见滚动栏。 - assert.match(js, /'
'/) - assert.match(js, /'
'/) - assert.ok(!js.includes('mini-scrollbar'), 'after-sale modals must not render a visible scrollbar') - - // 申请表单:预置原因徽章、所需资料、必填申请说明(placeholder 取后台配置) - assert.match(js, /reasonOptions\(\)/) - assert.match(js, /materialOptions\(\)/) - assert.match(js, /data-aksrt-after-sale-reason=/) - assert.match(js, /data-aksrt-after-sale-material=/) - assert.match(js, /var placeholder = String\(cfg\.reasonPlaceholder \|\| '必填,请您详细填写申请原因'\)/) - assert.match(js, /name="reason"/) - assert.match(js, /notyf\('请填写申请说明', 'danger'\)/) - assert.match(js, /notyf\('请选择售后原因', 'danger'\)/) - - // 图片上传:开关控制渲染,最多 3 张,上传/删除/提交 - assert.match(js, /if \(cfg\.imgEnabled\) \{/) - assert.match(js, /MAX_IMAGES = 3/) - assert.match(js, /\/after-sale\/upload-image'/) - assert.match(js, /data-aksrt-after-sale-image-remove/) - assert.match(js, /images: collectImages\(shell\)/) - assert.match(js, /reason_label: selectedReasonLabel\(shell\)/) - assert.match(js, /materials: collectMaterials\(shell\)/) - assert.match(js, /description: reasonText,/) - - // 申请说明(HTML)+ 超时效提示(HTML)由后台配置直出 - assert.match(js, /var desc = String\(cfg\.desc \|\| ''\)/) - assert.match(js, /var text = String\(settings\(\)\.expiredText \|\| '抱歉,您的订单已超过售后申请时效'\)/) - - // 售后详细:状态徽章 / 明细行 / 图片缩略图;不可再申请提示原文 + 我知道了 - // 申请表单分行展示:原因 / 申请资料 / 申请说明各自独立成行(与申请时间同格式); - // 老数据无 apply_form 时兜底展示融合串 - assert.match(js, /detailRow\('原因', String\(applyForm\.reason_label \|\| ''\)\)/) - assert.match(js, /detailRow\('申请说明', String\(applyForm\.description \|\| ''\)\)/) - assert.match(js, /STATUS_LABELS = \{[\s\S]*?pending: '待审核'[\s\S]*?completed: '已完成',/) - // 不可再申请:不再展示阻断文案,仅保留关闭入口;可再申请:仅保留单个主按钮(无「关闭」) - assert.ok(!js.includes('暂不支持再次申请售后'), 'blocked notice copy must be removed') - assert.ok(!js.includes('REAPPLY_BLOCKED_TEXT'), 'blocked text constant must be removed') - assert.ok(!js.includes('>关闭'), 'detail modal must not render a 关闭 button') - assert.match(js, /data-aksrt-modal-close>我知道了 { - const js = read('assets/js/order-center.js') - - // 内嵌售后表单与两个 submit 处理器已彻底移除(死代码清理) - assert.ok(!js.includes('data-aksrt-after-sale-form'), 'embedded after-sale form must be removed') - assert.ok(!js.includes('提交售后申请'), 'embedded after-sale submit copy must be removed') - assert.ok(!js.includes('onDocumentSubmit'), 'document submit handler for the after-sale form must be removed') - - // 列表卡片与订单详情弹窗底部共用新的售后按钮 - assert.match(js, /function afterSaleButtons\(order\)/) - assert.match(js, /function afterSalePayload\(order\)/) - assert.match(js, /data-aksrt-after-sale-detail="' \+ payload \+ '"/) - assert.match(js, /data-aksrt-after-sale-apply="' \+ payload \+ '"/) - assert.match(js, /if \(afterSale\.enabled\) html \+= afterSaleButtons\(order\);/) - assert.ok(!js.includes('\n html += afterSaleButtons(order);\n'), 'detail modal footer must not render the shared after-sale buttons') - // 底部按钮与禁封申诉弹窗同式(but-average modal-buts);「售后详细」入口收拢到记录卡片 - assert.match(js, /aksrt-order-detail__actions but-average modal-buts/) - assert.match(js, /data-aksrt-after-sale-apply="' \+ afterSalePayload\(order\) \+ '"/) - assert.ok(js.includes('查看详细'), 'record card has a view-detail entry') - assert.match(js, /data-aksrt-after-sale-detail="' \+ recordPayload \+ '"/) - // 记录卡片:原因 / 申请理由分行(新数据走 apply_form,老数据兜底融合串) - assert.ok(js.includes('aksrt-order-detail__after-sale-field">原因:'), 'record card shows 原因:') - assert.ok(js.includes('aksrt-order-detail__after-sale-field">申请理由:'), 'record card shows 申请理由:') - assert.match(js, /data-aksrt-after-sale-detail="/) - assert.match(js, /data-aksrt-after-sale-apply="/) - - // 售后记录区与取消申请保留在订单详情弹窗 - assert.match(js, /售后记录/) - assert.match(js, /data-aksrt-after-sale-cancel/) - - // refresh 供售后弹窗提交后刷新列表与统计 - assert.match(js, /function refresh\(orderNumber\) \{/) - assert.match(js, /window\.AKSRTOrderCenter = \{ init: init, destroy: destroy, refresh: refresh \}/) - assert.match(js, /liveStates\.forEach\(function \(state\) \{/) -}) - -test('after-sale assets, template buttons and runtime data are wired', () => { - const scripts = read('src/Core/ScriptsPart1b.php') - assert.match(scripts, /'aksrt-order-after-sale'/) - assert.match(scripts, /\/assets\/js\/order-after-sale\.js'/) - - const scripts1c = read('src/Core/ScriptsPart1c.php') - assert.match(scripts1c, /'afterSale'\s*=> \(function \(\) \{/) - assert.match(scripts1c, /PaymentRuntime::get_after_sale_settings\(\)/) - assert.match(scripts1c, /'maxImages'\s*=> 3/) - - const template = read('inc/helpers/user-center-renderers/profile-orders.php') - assert.match(template, /\$after_sale = is_array\(\$order\['after_sale'\] \?\? null\) \? \$order\['after_sale'\] : \[\];/) - // 载荷随按钮渲染(order-after_sale.js 同步消费),点击即弹窗、无前置请求 - assert.match(template, /\$after_sale_payload = wp_json_encode\(\[/) - assert.match(template, /'after_sale'\s*=> \$after_sale,/) - assert.match(template, /data-aksrt-after-sale-detail="<\?php echo esc_attr\(\$after_sale_payload\);/) - assert.match(template, /data-aksrt-after-sale-apply="<\?php echo esc_attr\(\$after_sale_payload\);/) - assert.match(template, /esc_html_e\('售后详细', 'aksrt'\)/) - assert.ok(!template.includes('can_after_sale'), 'template must not use the removed can_after_sale flag') - - const css = read('assets/css/frontend/modal-runtime.css') - assert.match(css, /\.aksrt-after-sale-apply-modal:not\(\.aksrt-modal-runtime--bottom\) \.aksrt-modal-runtime__dialog/) - assert.match(css, /\.aksrt-after-sale-detail-modal:not\(\.aksrt-modal-runtime--bottom\) \.aksrt-modal-runtime__dialog/) - assert.match(css, /\.aksrt-after-sale-expired-modal:not\(\.aksrt-modal-runtime--bottom\) \.aksrt-modal-runtime__dialog/) - assert.match(css, /width: min\(400px, calc\(100vw - 32px\)\)/) - assert.match(css, /\.aksrt-after-sale-apply-modal \.modal-colorful-header/) - assert.match(css, /\.aksrt-after-sale-apply-modal \.colorful-bg > \.colorful-make/) - - const field = read('assets/admin/src/components/form/AksField.vue') - assert.match(field, /import draggable from 'vuedraggable'/) - assert.match(field, /v-model="repeaterDragList"/) - assert.match(field, /handle="\.aksrt-field__repeater-handle"/) - assert.match(field, /function repeaterDragKey\(item\)/) -}) +'use strict' + +/** + * 「售后策略」功能契约: + * 1) 后台 schema(PHP SchemaPayment after_sale 分区)与 SPA JS schema 的键、顺序、 + * 类型、默认值 1:1 对齐,且 schema 与侧边页均已注册(/payment/after-sale 可达); + * 2) 运行时(PaymentRuntime)缺键回退 schema 默认、HTML 走 kses、时效边界正确; + * 3) 售后工单:images 列、create 图片清洗与上限、上传路由权限、latest_for_order; + * 4) 订单视图模型输出 after_sale 状态(按钮矩阵的唯一数据源); + * 5) 前台:申请/超时效/售后详细三个独立弹窗、modal-mini + 蓝色炫彩头、placeholder 与 + * 图片上传配置化、不可再申请提示原文、订单详情弹窗不再内嵌售后表单(死代码清理)。 + */ + +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const { spawnSync } = require('node:child_process') +const test = require('node:test') + +const root = path.resolve(__dirname, '..', '..') +const read = (file) => fs.readFileSync(path.join(root, file), 'utf8') + +const FIELD_ORDER = [ + 'after_sale_s', + 'after_sale_time_limit', + 'after_sale_expired_text', + 'after_sale_reasons', + 'after_sale_desc', + 'after_sale_materials', + 'after_sale_reason_placeholder', + 'after_sale_img_s', +] + +/** Extract the balanced [...] block assigned to a PHP array key inside a section body. */ +function phpSection(src, sectionKey) { + const start = src.indexOf(`'${sectionKey}' => [`) + assert.notEqual(start, -1, `PHP schema has a ${sectionKey} section`) + const open = src.indexOf('[', start) + let depth = 0 + for (let i = open; i < src.length; i++) { + const ch = src[i] + if (ch === "'" || ch === '"') { + const quote = ch + i++ + while (i < src.length) { + if (src[i] === '\\') { i += 2; continue } + if (src[i] === quote) break + i++ + } + continue + } + if (ch === '[') depth++ + else if (ch === ']') { + depth-- + if (depth === 0) return src.slice(open, i + 1) + } + } + throw new Error(`unbalanced PHP section: ${sectionKey}`) +} + +test('after-sale schema is 1:1 between PHP and SPA', () => { + const php = phpSection(read('src/Admin/Schema/SchemaPayment.php'), 'after_sale') + const js = read('assets/admin/src/schema/sections/payment/afterSale.js') + + assert.match(php, /'key'\s*=>\s*'after_sale'/) + assert.match(php, /'title'\s*=>\s*'售后策略'/) + assert.match(php, /'option_key'\s*=>\s*'aksrt_settings'/) + + // 字段键、顺序、类型一致(JS 侧 defaults 与 groups 同序,按首次出现去重) + const phpKeys = [...php.matchAll(/'key'\s*=>\s*'([a-z0-9_]+)'/g)].map((m) => m[1]) + const jsKeys = [...js.matchAll(/key:\s*'([a-z0-9_]+)'/g)].map((m) => m[1]) + const sectionKeys = (keys) => [...new Set(keys.filter((key) => FIELD_ORDER.includes(key)))] + assert.deepEqual(sectionKeys(phpKeys), FIELD_ORDER) + assert.deepEqual(sectionKeys(jsKeys), FIELD_ORDER) + + for (const [phpType, jsType] of [ + ['switch', 'switch'], + ['number', 'number'], + ['textarea', 'textarea'], + ['group', 'group'], + ['input', 'input'], + ]) { + assert.ok(php.includes(`'type' => '${phpType}'`), `missing PHP field type ${phpType}`) + assert.ok(js.includes(`type: '${jsType}'`), `missing JS field type ${jsType}`) + } + + // 默认值一致(开关/时效/占位文案/预置原因/所需材料) + assert.match(php, /'after_sale_s', 'label' => '售后功能', 'type' => 'switch', 'default' => true/) + assert.match(js, /after_sale_s:\s*true/) + assert.match(js, /key: 'after_sale_s', label: '售后功能', type: 'switch', default: true/) + assert.match(php, /'after_sale_time_limit', 'label' => '申请售后时效\(天\)', 'type' => 'number', 'default' => 7, 'min' => 0, 'max' => 3650/) + assert.match(js, /key: 'after_sale_time_limit', label: '申请售后时效\(天\)', type: 'number', default: 7, min: 0, max: 3650/) + assert.match(php, /'after_sale_reason_placeholder', 'label' => '申请说明占位文案', 'type' => 'input', 'default' => '必填,请您详细填写申请原因'/) + assert.match(js, /key: 'after_sale_reason_placeholder', label: '申请说明占位文案', type: 'input', default: '必填,请您详细填写申请原因'/) + assert.match(php, /'after_sale_img_s', 'label' => '申请售后可上传图片', 'type' => 'switch', 'default' => true/) + assert.match(js, /key: 'after_sale_img_s', label: '申请售后可上传图片', type: 'switch', default: true/) + + for (const reason of ['质量问题', '假冒品牌', '其他原因']) { + assert.ok(php.includes(`'t' => '${reason}'`), `missing PHP preset reason ${reason}`) + assert.ok(js.includes(`{ t: '${reason}' }`), `missing JS preset reason ${reason}`) + } + for (const material of ['联系邮箱', '联系电话']) { + assert.ok(php.includes(`'t' => '${material}'`), `missing PHP material ${material}`) + assert.ok(js.includes(`{ t: '${material}' }`), `missing JS material ${material}`) + } + + // HTML 配置项(超时效提示 / 申请说明)走 kses 通道 + assert.match(php, /'after_sale_expired_text', 'label' => '超过时效提示', 'type' => 'textarea', 'sanitize_type' => 'html'/) + assert.match(php, /'after_sale_desc', 'label' => '申请售后说明', 'type' => 'textarea', 'sanitize_type' => 'html'/) + assert.match(js, /key: 'after_sale_expired_text', label: '超过时效提示', type: 'textarea', sanitize_type: 'html'/) + assert.match(js, /key: 'after_sale_desc', label: '申请售后说明', type: 'textarea', sanitize_type: 'html'/) +}) + +test('after-sale schema and sidebar page are registered under 支付&付费', () => { + const schemaIndex = read('assets/admin/src/schema/sections/payment/index.js') + assert.match(schemaIndex, /import \{ afterSaleSchema \} from '\.\/afterSale\.js'/) + assert.match(schemaIndex, /after_sale:\s*afterSaleSchema/) + + const section = read('assets/admin/src/constants/sections/payment.js') + assert.match(section, /key: 'payment-after-sale'/) + assert.match(section, /name: 'PaymentAfterSale'/) + assert.match(section, /label: '售后策略'/) + assert.match(section, /route: '\/payment\/after-sale'/) + assert.match(section, /canonicalDomain: 'payment'/) + assert.match(section, /section: 'after_sale'/) + + // 侧栏条目位于提现策略之后(支付&付费末位) + assert.ok(section.indexOf("key: 'payment-after-sale'") > section.indexOf("key: 'payment-withdrawal'")) + + const schemaRegistry = read('assets/admin/src/schema/index.js') + assert.match(schemaRegistry, /paymentSchemas/) +}) + +test('after-sale runtime resolves defaults, overrides and the expiry boundary matrix', () => { + const result = spawnSync(process.env.PHP_BINARY || 'php', [path.join(__dirname, 'after-sale-policy-harness.php')], { + cwd: root, + encoding: 'utf8', + }) + assert.equal(result.status, 0, result.stderr || result.stdout) + const payload = JSON.parse(result.stdout) + + assert.deepEqual(payload.defaults, { + enabled: true, + time_limit: 7, + expired_text: '
抱歉,您的订单已超过售后申请时效
', + reasons: [ + { slug: 'c440cdf71825', name: '质量问题' }, + { slug: '2ead35a456a0', name: '假冒品牌' }, + { slug: 'fba26eab6b2b', name: '其他原因' }, + ], + materials: ['联系邮箱', '联系电话'], + desc: '', + img_enabled: true, + reason_placeholder: '必填,请您详细填写申请原因', + }) + + assert.equal(payload.overridden.enabled, false) + assert.equal(payload.overridden.time_limit, 30) + assert.equal(payload.overridden.img_enabled, false) + assert.equal(payload.overridden.reason_placeholder, '请描述问题') + assert.deepEqual(payload.overridden.reasons, [ + { slug: 'c440cdf71825', name: '质量问题' }, + { slug: '327e0ab68567', name: '发错货' }, + ]) + assert.deepEqual(payload.overridden.materials, ['联系邮箱']) + // HTML 配置项剥离 script,保留 div + assert.equal(payload.overridden.expired_text, '
已超过时效
') + assert.equal(payload.overridden.desc, '
请保持商品完好
') + + assert.deepEqual(payload.expired_ok, { + within: true, + exact_boundary: true, + just_expired: true, + long_expired: true, + no_limit: true, + missing_paid: true, + }) +}) + +test('after-sale tickets persist evidence images and expose the latest row', () => { + const schema = read('src/Core/DBMigratorPart2.php') + assert.match(schema, /CREATE TABLE \{\$prefix\}aksrt_order_aftersale[\s\S]*?images LONGTEXT DEFAULT NULL[\s\S]*?refund_amount/) + + // images 列随 schema 2.5.1 引入:dbDelta 只在版本变化时重跑,列变更必须同步升版本, + // 否则老安装不会 ALTER TABLE,写入直接报 Unknown column 'images'。 + const migrator = read('src/Core/DBMigrator.php') + const version = migrator.match(/const SCHEMA_VERSION = '(\d+)\.(\d+)\.(\d+)'/) + assert.ok(version, 'DBMigrator exposes a numeric SCHEMA_VERSION') + const [major, minor, patch] = version.slice(1).map(Number) + assert.ok( + major > 2 || (major === 2 && minor > 5) || (major === 2 && minor === 5 && patch >= 1), + `SCHEMA_VERSION must be at least 2.5.1 after adding order_aftersale.images, got ${major}.${minor}.${patch}` + ) + + const service = read('src/Payment/AfterSaleService.php') + assert.match(service, /public function create\(int \$user_id, string \$order_number, string \$reason, array \$images = \[\], array \$apply_form = \[\]\)/) + // 结构化申请表单:预置原因 / 申请资料 / 申请说明独立入库(reason 融合串保持原样) + assert.match(service, /\$apply_form = \$this->sanitize_apply_form\(\$apply_form\);/) + assert.match(service, /private function sanitize_apply_form\(array \$form\): array/) + assert.match(service, /'apply_form' => \$apply_form === \[\] \? null : wp_json_encode\(\$apply_form/) + assert.match(service, /\$images = \$this->sanitize_images\(\$images\);/) + assert.match(service, /'images' => wp_json_encode\(\$images\)/) + assert.match(service, /private function sanitize_images\(array \$images\): array \{[\s\S]*?count\(\$clean\) >= 3[\s\S]*?esc_url_raw/) + assert.match(service, /public function latest_for_order\(string \$order_number\): \?array \{[\s\S]*?ORDER BY id DESC LIMIT 1/) + // normalize 输出 images 数组(坏 JSON / 空串回落空数组) + assert.match(service, /json_decode\(\(string\) \(\$row\['images'\] \?\? ''\), true\)/) + + // 图片上传路由:登录 + nonce + 售后/图片开关 + 订单归属;回调委托评论图片上传 + assert.match(service, /\/orders\/' \. \$number \. '\/after-sale\/upload-image'/) + assert.match(service, /public function require_after_sale_image\(\\WP_REST_Request \$request\)/) + assert.match(service, /get_after_sale_settings\(\)[\s\S]*?empty\(\$settings\['enabled'\]\)[\s\S]*?empty\(\$settings\['img_enabled'\]\)/) + assert.match(service, /get_user_order\(get_current_user_id\(\), \$number\)/) + assert.match(service, /UserController::get_instance\(\)->rest_upload_comment_image\(\$request\)/) + assert.match(service, /'images' => \['required' => false, 'type' => 'array'[\s\S]*?count\(\$value\) <= 3/) + + // 提交 REST 透传 images + // rest_submit 同步上报结构化申请表单(reason_label / materials / description) + assert.match(service, /'reason_label' => \(string\) \$request->get_param\('reason_label'\),/) + assert.match(service, /'materials' +\=> is_array\(\$materials\) \? \$materials : \[\],/) + assert.match(service, /'description' +\=> \(string\) \$request->get_param\('description'\),/) + + // 既有售后资产契约(锁 / 退款 / 完成)不受影响 + assert.match(service, /GET_LOCK\(%s, 5\)/) + assert.match(service, /Order::STATUS_PAID/) +}) + +test('order view model exposes the after-sale button matrix', () => { + const userOrders = read('src/Payment/UserOrders.php') + assert.match(userOrders, /private function after_sale_state\(string \$group, string \$order_number, string \$paid_at, \?AfterSaleService \$service, array \$records = \[\]\): array \{/) + + assert.match(userOrders, /'after_sale' => \$after_sale_state,/) + assert.match(userOrders, /'enabled'\s*=> \$enabled,/) + assert.match(userOrders, /'expired'\s*=> \(bool\) \$expired,/) + assert.match(userOrders, /'has_application' => \$has_application,/) + assert.match(userOrders, /'open'\s*=> \$open,/) + assert.match(userOrders, /'can_apply'\s*=> \$enabled && !\$expired && !\$open,/) + // 可再次申请售后按工单状态推理:rejected(被拒绝)/ cancelled(已取消)允许再次申请; + // pending / processing / approved(流转中)与 completed(已完结)不允许 + assert.match(userOrders, /\$reapplyable_statuses = \[AfterSaleService::STATUS_REJECTED, AfterSaleService::STATUS_CANCELLED\];/) + assert.match(userOrders, /'can_reapply'\s*=> \$enabled && !\$expired && \$has_application && in_array\(\$latest_status, \$reapplyable_statuses, true\),/) + assert.match(userOrders, /'latest'\s*=> \$has_application \? \[/) + assert.match(userOrders, /'apply_form'\s*=> is_array\(\$latest\['apply_form'\] \?\? null\) \? \$latest\['apply_form'\] : null,/) + + // 仅已支付订单开放;开关关闭整体不处理售后 + assert.match(userOrders, /\$enabled = \$group === OrderCenterQuery::STATUS_PAID && !empty\(\$settings\['enabled'\]\);/) + // 时效以支付时间为准 + assert.match(userOrders, /\$this->after_sale_state\(\$group, \$order_number, \(string\) \(\$order\['paid_at'\] \?\? ''\), \$after_sale_service, \$after_sales\)/) + // 列表模式单行查询,详情模式复用全量工单 + assert.match(userOrders, /\$after_sales = \$detail && \$after_sale_service \? \$after_sale_service->list_for_order\(\$order_number\) : \[\];/) + assert.match(userOrders, /\$latest = \$service->latest_for_order\(\$order_number\);/) + assert.match(userOrders, /\$view\['after_sales'\] = \$after_sales;/) + + // 旧 can_after_sale 双路已移除 + assert.ok(!userOrders.includes('can_after_sale'), 'can_after_sale must be replaced by the after_sale state') +}) + +test('frontend after-sale modals are standalone and config-driven', () => { + const js = read('assets/js/order-after-sale.js') + + // 三个独立弹窗 + modal-mini 几何 + 蓝色炫彩标题区 + assert.match(js, /className: 'aksrt-after-sale-apply-modal'/) + assert.match(js, /className: 'aksrt-after-sale-detail-modal'/) + assert.match(js, /className: 'aksrt-after-sale-expired-modal'/) + assert.match(js, /modal-colorful-header colorful-bg jb-blue/) + assert.match(js, /AksrtModalRuntime\.open\(\{/) + + // 滚动容器与禁封申诉弹窗同口径:no-scrollbar 完全隐藏滚动条(含细滚动条), + // 避免 modal-mini 弹窗边缘出现可见滚动栏。 + assert.match(js, /'
'/) + assert.match(js, /'
'/) + assert.ok(!js.includes('mini-scrollbar'), 'after-sale modals must not render a visible scrollbar') + + // 申请表单:预置原因徽章、所需资料、必填申请说明(placeholder 取后台配置) + assert.match(js, /reasonOptions\(\)/) + assert.match(js, /materialOptions\(\)/) + assert.match(js, /data-aksrt-after-sale-reason=/) + assert.match(js, /data-aksrt-after-sale-material=/) + assert.match(js, /var placeholder = String\(cfg\.reasonPlaceholder \|\| '必填,请您详细填写申请原因'\)/) + assert.match(js, /name="reason"/) + assert.match(js, /notyf\('请填写申请说明', 'danger'\)/) + assert.match(js, /notyf\('请选择售后原因', 'danger'\)/) + + // 图片上传:开关控制渲染,最多 3 张,上传/删除/提交 + assert.match(js, /if \(cfg\.imgEnabled\) \{/) + assert.match(js, /MAX_IMAGES = 3/) + assert.match(js, /\/after-sale\/upload-image'/) + assert.match(js, /data-aksrt-after-sale-image-remove/) + assert.match(js, /images: collectImages\(shell\)/) + assert.match(js, /reason_label: selectedReasonLabel\(shell\)/) + assert.match(js, /materials: collectMaterials\(shell\)/) + assert.match(js, /description: reasonText,/) + + // 申请说明(HTML)+ 超时效提示(HTML)由后台配置直出 + assert.match(js, /var desc = String\(cfg\.desc \|\| ''\)/) + assert.match(js, /var text = String\(settings\(\)\.expiredText \|\| '抱歉,您的订单已超过售后申请时效'\)/) + + // 售后详细:状态徽章 / 明细行 / 图片缩略图;不可再申请提示原文 + 我知道了 + // 申请表单分行展示:原因 / 申请资料 / 申请说明各自独立成行(与申请时间同格式); + // 老数据无 apply_form 时兜底展示融合串 + assert.match(js, /detailRow\('原因', String\(applyForm\.reason_label \|\| ''\)\)/) + assert.match(js, /detailRow\('申请说明', String\(applyForm\.description \|\| ''\)\)/) + assert.match(js, /STATUS_LABELS = \{[\s\S]*?pending: '待审核'[\s\S]*?completed: '已完成',/) + // 不可再申请:不再展示阻断文案,仅保留关闭入口;可再申请:仅保留单个主按钮(无「关闭」) + assert.ok(!js.includes('暂不支持再次申请售后'), 'blocked notice copy must be removed') + assert.ok(!js.includes('REAPPLY_BLOCKED_TEXT'), 'blocked text constant must be removed') + assert.ok(!js.includes('>关闭'), 'detail modal must not render a 关闭 button') + assert.match(js, /data-aksrt-modal-close>我知道了 { + const js = read('assets/js/order-center.js') + + // 内嵌售后表单与两个 submit 处理器已彻底移除(死代码清理) + assert.ok(!js.includes('data-aksrt-after-sale-form'), 'embedded after-sale form must be removed') + assert.ok(!js.includes('提交售后申请'), 'embedded after-sale submit copy must be removed') + assert.ok(!js.includes('onDocumentSubmit'), 'document submit handler for the after-sale form must be removed') + + // 列表卡片与订单详情弹窗底部共用新的售后按钮 + assert.match(js, /function afterSaleButtons\(order\)/) + assert.match(js, /function afterSalePayload\(order\)/) + assert.match(js, /data-aksrt-after-sale-detail="' \+ payload \+ '"/) + assert.match(js, /data-aksrt-after-sale-apply="' \+ payload \+ '"/) + assert.match(js, /if \(afterSale\.enabled\) html \+= afterSaleButtons\(order\);/) + assert.ok(!js.includes('\n html += afterSaleButtons(order);\n'), 'detail modal footer must not render the shared after-sale buttons') + // 底部按钮与禁封申诉弹窗同式(but-average modal-buts);「售后详细」入口收拢到记录卡片 + assert.match(js, /aksrt-order-detail__actions but-average modal-buts/) + assert.match(js, /data-aksrt-after-sale-apply="' \+ afterSalePayload\(order\) \+ '"/) + assert.ok(js.includes('查看详细'), 'record card has a view-detail entry') + assert.match(js, /data-aksrt-after-sale-detail="' \+ recordPayload \+ '"/) + // 记录卡片:原因 / 申请理由分行(新数据走 apply_form,老数据兜底融合串) + assert.ok(js.includes('aksrt-order-detail__after-sale-field">原因:'), 'record card shows 原因:') + assert.ok(js.includes('aksrt-order-detail__after-sale-field">申请理由:'), 'record card shows 申请理由:') + assert.match(js, /data-aksrt-after-sale-detail="/) + assert.match(js, /data-aksrt-after-sale-apply="/) + + // 售后记录区与取消申请保留在订单详情弹窗 + assert.match(js, /售后记录/) + assert.match(js, /data-aksrt-after-sale-cancel/) + + // refresh 供售后弹窗提交后刷新列表与统计 + assert.match(js, /function refresh\(orderNumber\) \{/) + assert.match(js, /window\.AKSRTOrderCenter = \{ init: init, destroy: destroy, refresh: refresh \}/) + assert.match(js, /liveStates\.forEach\(function \(state\) \{/) +}) + +test('after-sale assets, template buttons and runtime data are wired', () => { + const scripts = read('src/Core/ScriptsPart1b.php') + assert.match(scripts, /'aksrt-order-after-sale'/) + assert.match(scripts, /\/assets\/js\/order-after-sale\.js'/) + + const scripts1c = read('src/Core/ScriptsPart1c.php') + assert.match(scripts1c, /'afterSale'\s*=> \(function \(\) \{/) + assert.match(scripts1c, /PaymentRuntime::get_after_sale_settings\(\)/) + assert.match(scripts1c, /'maxImages'\s*=> 3/) + + const template = read('inc/helpers/user-center-renderers/profile-orders.php') + assert.match(template, /\$after_sale = is_array\(\$order\['after_sale'\] \?\? null\) \? \$order\['after_sale'\] : \[\];/) + // 载荷随按钮渲染(order-after_sale.js 同步消费),点击即弹窗、无前置请求 + assert.match(template, /\$after_sale_payload = wp_json_encode\(\[/) + assert.match(template, /'after_sale'\s*=> \$after_sale,/) + assert.match(template, /data-aksrt-after-sale-detail="<\?php echo esc_attr\(\$after_sale_payload\);/) + assert.match(template, /data-aksrt-after-sale-apply="<\?php echo esc_attr\(\$after_sale_payload\);/) + assert.match(template, /esc_html_e\('售后详细', 'aksrt'\)/) + assert.ok(!template.includes('can_after_sale'), 'template must not use the removed can_after_sale flag') + + const css = read('assets/css/frontend/modal-runtime.css') + assert.match(css, /\.aksrt-after-sale-apply-modal:not\(\.aksrt-modal-runtime--bottom\) \.aksrt-modal-runtime__dialog/) + assert.match(css, /\.aksrt-after-sale-detail-modal:not\(\.aksrt-modal-runtime--bottom\) \.aksrt-modal-runtime__dialog/) + assert.match(css, /\.aksrt-after-sale-expired-modal:not\(\.aksrt-modal-runtime--bottom\) \.aksrt-modal-runtime__dialog/) + assert.match(css, /width: min\(400px, calc\(100vw - 32px\)\)/) + assert.match(css, /\.aksrt-after-sale-apply-modal \.modal-colorful-header/) + assert.match(css, /\.aksrt-after-sale-apply-modal \.colorful-bg > \.colorful-make/) + + const field = read('assets/admin/src/components/form/AksField.vue') + assert.match(field, /import draggable from 'vuedraggable'/) + assert.match(field, /v-model="repeaterDragList"/) + assert.match(field, /handle="\.aksrt-field__repeater-handle"/) + assert.match(field, /function repeaterDragKey\(item\)/) +})