feat(ticket): replace the after-sale flow with a general ticket system
The old 售后 flow was an order-refund appendage: tickets were hard-bound to an order number, users could only file them from 「我的订单」against a paid order, and the admin action bottomed out at 「标记订单已退款」. It could not carry account questions or feature requests that have nothing to do with an order. Replace it with a general ticket system modelled on 魔方财务云's idcsmart_ticket: title + department + description are required, linking an order is optional (user's own paid orders only). Add departments, custom statuses, preset replies and internal admin notes as four configuration surfaces. Conversation history moves off the ticket row's JSON log into dedicated ticket_reply rows, which unlocks quoted replies. Front end gets a 工单中心 panel under 我的服务; back end moves from 支付中心 → 售后服务 to users.php → 工单管理. Per CLAUDE.md's rewrite-over-compatibility rule: drop the old table, build new ones, delete the old code. No dual paths, no data migration. The upstream plugin's upstream/downstream delivery and internal-ticket conversion are deliberately left out — both are multi-provider platform concerns with no meaning on a single WordPress site. Data: order_aftersale becomes six aksrt_ticket* tables (ticket, ticket_reply, ticket_status, ticket_type, ticket_prereply, ticket_note). SCHEMA_VERSION 2.6.0 -> 3.0.0. Status and department seeds are idempotent; the default status 「待接单」is flagged as non-editable. Domain: new src/Ticket/ (13 files, all under 460 lines). Attachments reuse UserController::rest_upload_comment_image with a ticket-specific permission callback, mirroring the existing /report/upload-image route rather than growing a second upload path. Notifications go through AKSRT\Messages\Message::send() and deep-link to the right surface; email/SMS stay out of scope since the theme's mail and SMS stacks are separate implementations. Refund gate opens up: marking an order refunded no longer requires an in-flight ticket, only that the order is paid. Also generalise a hardcoded special case in tools/audit-admin-contracts.php — schema domain inference now reads the parent directory instead of matching "PostList" by name, so new leaf schema files need no per-name exception. Verification: php tools/audit-admin-contracts.php exit 0, errors=0 node --test (widget-system + admin) 664 pass / 0 fail php -l over all new/changed files clean node --check over all JS clean npm run build + build:public clean Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
761c84d70f
commit
dbb7de41e9
81 files changed
+5812
-4179
No files matched your search
@@ -38,7 +38,7 @@ AKSRT-theme/
|
||||
│ ├── Extensions/ # MeiliSearch / 推送 / IP / 内容审核
|
||||
│ ├── Frontend/ # 前端渲染辅助
|
||||
│ ├── Messages/ # 站内信 / 公告 / 举报
|
||||
│ ├── Payment/ # 订单 / 网关 / 售后 / 物流 / 分成
|
||||
│ ├── Payment/ # 订单 / 网关 / 物流 / 分成
|
||||
│ │ └── Gateways/ # 支付宝 / 微信 / PayPal / Stripe
|
||||
│ ├── Settings/ # 设置门面 + 运行时 delegates
|
||||
│ ├── Shop/ # 商城服务 + meta box
|
||||
|
||||
@@ -88,16 +88,5 @@ export const paymentSection = {
|
||||
canonicalDomain: 'payment',
|
||||
section: 'withdrawal',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'payment-after-sale',
|
||||
name: 'PaymentAfterSale',
|
||||
label: '售后策略',
|
||||
route: '/payment/after-sale',
|
||||
status: 'available',
|
||||
title: '售后策略',
|
||||
desc: '售后功能开关、申请时效、超时效提示与申请表单(预置原因、所需材料、图片上传)配置。',
|
||||
canonicalDomain: 'payment',
|
||||
section: 'after_sale',
|
||||
}),
|
||||
],
|
||||
}
|
||||
@@ -130,5 +130,15 @@ export const usersSection = {
|
||||
canonicalDomain: 'users',
|
||||
section: 'report',
|
||||
}),
|
||||
schemaPage({
|
||||
key: 'users-ticket',
|
||||
name: 'UsersTicket',
|
||||
label: '工单中心',
|
||||
route: '/users/ticket',
|
||||
title: '工单中心',
|
||||
desc: '工单功能开关、工单号前缀、提交公告与提交表单(图片上传、关联订单、催单冷却)配置;部门、状态与预设回复在 WordPress 用户菜单的「工单管理」中维护。',
|
||||
canonicalDomain: 'users',
|
||||
section: 'ticket',
|
||||
}),
|
||||
],
|
||||
}
|
||||
@@ -79,6 +79,7 @@ function createSectionRedirectRoutes() {
|
||||
const NATIVE_OPS_REDIRECTS = [
|
||||
{ path: '/users/messages', title: '消息中心', nativeAdminPath: 'users.php?page=aksrt_messages' },
|
||||
{ path: '/users/report', title: '禁封/举报', nativeAdminPath: 'users.php?page=aksrt_user_ban' },
|
||||
{ path: '/users/tickets', title: '工单管理', nativeAdminPath: 'users.php?page=aksrt_tickets' },
|
||||
]
|
||||
|
||||
const routes = [
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* 售后策略 — 售后开关、申请时效、超时效提示与申请表单配置。
|
||||
*
|
||||
* 字段键、顺序、类型与默认值需与 src/Admin/Schema/SchemaPayment.php 的
|
||||
* after_sale 分区保持 1:1(tests/widget-system/after-sale-policy-contract.test.cjs 守护)。
|
||||
*/
|
||||
export const afterSaleSchema = {
|
||||
key: 'payment.after_sale',
|
||||
title: '售后策略',
|
||||
icon: 'fa fas fa-undo-alt',
|
||||
description: '售后功能开关、申请时效、超时效提示与申请表单(预置原因、申请说明、所需材料、图片上传)配置。',
|
||||
defaults: {
|
||||
after_sale_s: true,
|
||||
after_sale_time_limit: 7,
|
||||
after_sale_expired_text: '<div>抱歉,您的订单已超过售后申请时效</div>',
|
||||
after_sale_reasons: [
|
||||
{ t: '质量问题' },
|
||||
{ t: '假冒品牌' },
|
||||
{ t: '其他原因' },
|
||||
],
|
||||
after_sale_desc: '',
|
||||
after_sale_materials: [
|
||||
{ t: '联系邮箱' },
|
||||
{ t: '联系电话' },
|
||||
],
|
||||
after_sale_reason_placeholder: '必填,请您详细填写申请原因',
|
||||
after_sale_img_s: true,
|
||||
},
|
||||
groups: [
|
||||
{
|
||||
key: 'main',
|
||||
title: '售后功能',
|
||||
icon: 'fa fas fa-undo-alt',
|
||||
fields: [
|
||||
{ key: 'after_sale_s', label: '售后功能', type: 'switch', default: true, help: '开启后用户可以申请售后;关闭后不再处理售后,前台「我的订单」不再显示申请售后按钮' },
|
||||
{ key: 'after_sale_time_limit', label: '申请售后时效(天)', type: 'number', default: 7, min: 0, max: 3650, help: '订单支付后多少天内可以申请售后,超过时效将无法申请;0 表示不限制' },
|
||||
{ key: 'after_sale_expired_text', label: '超过时效提示', type: 'textarea', sanitize_type: 'html', default: '<div>抱歉,您的订单已超过售后申请时效</div>', rows: 3, help: '订单超过售后时效后,用户点击申请售后时弹窗提示的内容(支持HTML代码,请注意代码规范)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'form',
|
||||
title: '申请表单',
|
||||
icon: 'fa fas fa-file-alt',
|
||||
fields: [
|
||||
{
|
||||
key: 'after_sale_reasons',
|
||||
label: '预置原因',
|
||||
type: 'group',
|
||||
button_title: '添加原因',
|
||||
help: '用户申请售后时可选择的原因',
|
||||
default: [
|
||||
{ t: '质量问题' },
|
||||
{ t: '假冒品牌' },
|
||||
{ t: '其他原因' },
|
||||
],
|
||||
fields: [
|
||||
{ key: 't', label: '原因', type: 'input', default: '' },
|
||||
],
|
||||
},
|
||||
{ key: 'after_sale_desc', label: '申请售后说明', type: 'textarea', sanitize_type: 'html', default: '', rows: 5, help: '用户申请售后时显示的内容(支持HTML代码,请注意代码规范)' },
|
||||
{
|
||||
key: 'after_sale_materials',
|
||||
label: '申请所需材料',
|
||||
type: 'group',
|
||||
button_title: '添加材料',
|
||||
help: '用户提交售后申请时需要提供的资料,如联系邮箱、联系电话;此处的信息收集,仅用于售后联系与审核',
|
||||
default: [
|
||||
{ t: '联系邮箱' },
|
||||
{ t: '联系电话' },
|
||||
],
|
||||
fields: [
|
||||
{ key: 't', label: '资料项', type: 'input', default: '' },
|
||||
],
|
||||
},
|
||||
{ key: 'after_sale_reason_placeholder', label: '申请说明占位文案', type: 'input', default: '必填,请您详细填写申请原因', help: '前台申请售后弹窗中「申请说明」输入框的提示文字' },
|
||||
{ key: 'after_sale_img_s', label: '申请售后可上传图片', type: 'switch', default: true, help: '开启后申请售后弹窗中出现图片上传入口(最多上传3张)' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import { revenueSchema } from './revenue.js'
|
||||
import { affiliateSchema } from './affiliate.js'
|
||||
import { gatewaySchema } from './gateway.js'
|
||||
import { withdrawalSchema } from './withdrawal.js'
|
||||
import { afterSaleSchema } from './afterSale.js'
|
||||
|
||||
export const paymentSchemas = {
|
||||
knowledge: knowledgeSchema,
|
||||
@@ -19,5 +18,4 @@ export const paymentSchemas = {
|
||||
affiliate: affiliateSchema,
|
||||
gateway: gatewaySchema,
|
||||
withdrawal: withdrawalSchema,
|
||||
after_sale: afterSaleSchema,
|
||||
}
|
||||
@@ -85,8 +85,8 @@ export const permissionsSchemas = {
|
||||
{ label: '每日签到', value: 'signin' },
|
||||
{ label: '前台投稿', value: 'front_submit' },
|
||||
{ label: '打赏用户', value: 'tip_user' },
|
||||
{ label: '申请售后', value: 'after_sale_apply' },
|
||||
{ label: '撤销售后', value: 'after_sale_cancel' },
|
||||
{ label: '提交工单', value: 'ticket_create' },
|
||||
{ label: '回复工单', value: 'ticket_reply' },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -11,6 +11,7 @@ import { verifySchema } from './verify.js'
|
||||
import { levelSchema } from './level.js'
|
||||
import { badgeSchema } from './badge.js'
|
||||
import { reportSchema } from './report.js'
|
||||
import { ticketSchema } from './ticket.js'
|
||||
import { smsSchema } from './sms.js'
|
||||
|
||||
export const usersSchemas = {
|
||||
@@ -23,5 +24,6 @@ export const usersSchemas = {
|
||||
level: levelSchema,
|
||||
badge: badgeSchema,
|
||||
report: reportSchema,
|
||||
ticket: ticketSchema,
|
||||
sms: smsSchema,
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 工单中心 — 工单开关、工单号前缀、提交公告、图片上传、订单关联与催单冷却。
|
||||
*
|
||||
* 字段键、顺序、类型与默认值需与 src/Admin/Schema/Users/SchemaUsersTicket.php
|
||||
* 保持 1:1(tests/widget-system/ticket-schema-contract.test.cjs 守护)。
|
||||
*/
|
||||
export const ticketSchema = {
|
||||
key: 'users.ticket',
|
||||
title: '工单中心',
|
||||
icon: 'fa fas fa-headset',
|
||||
description: '工单功能开关、工单号前缀、提交公告与提交表单(图片上传、关联订单、催单冷却)配置。部门、状态与预设回复在「用户 → 工单管理」中维护。',
|
||||
defaults: {
|
||||
ticket_s: true,
|
||||
ticket_num_prefix: 'T',
|
||||
ticket_notice_open: true,
|
||||
ticket_notice_text: '<div>提交工单后请耐心等待处理</div>',
|
||||
ticket_attachment_s: true,
|
||||
ticket_attachment_max: 5,
|
||||
ticket_order_link_s: true,
|
||||
ticket_urge_interval: 15,
|
||||
},
|
||||
groups: [
|
||||
{
|
||||
key: 'main',
|
||||
title: '工单功能',
|
||||
icon: 'fa fas fa-headset',
|
||||
fields: [
|
||||
{ key: 'ticket_s', label: '工单功能', type: 'switch', default: true, help: '开启后用户可在用户中心提交工单;关闭后工单中心不再开放,后台工单列表仍可查看历史工单' },
|
||||
{ key: 'ticket_num_prefix', label: '工单号前缀', type: 'input', default: 'T', help: '工单号形如「前缀+年月日+6位随机数」,仅支持字母与数字' },
|
||||
{ key: 'ticket_notice_open', label: '提交后显示平台公告', type: 'switch', default: true, help: '开启后用户提交工单成功时展示下方公告内容' },
|
||||
{ key: 'ticket_notice_text', label: '公告内容', type: 'textarea', sanitize_type: 'html', rows: 3, default: '<div>提交工单后请耐心等待处理</div>', help: '用户提交工单后展示的内容(支持HTML代码,请注意代码规范)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'form',
|
||||
title: '提交设置',
|
||||
icon: 'fa fas fa-file-alt',
|
||||
fields: [
|
||||
{ key: 'ticket_attachment_s', label: '允许上传图片', type: 'switch', default: true, help: '开启后工单内容与回复可上传图片(复用评论图片上传的大小与类型校验)' },
|
||||
{ key: 'ticket_attachment_max', label: '单个工单图片上限', type: 'number', default: 5, min: 1, max: 9, help: '工单内容与每条回复各自可上传的图片数量上限' },
|
||||
{ key: 'ticket_order_link_s', label: '允许关联订单', type: 'switch', default: true, help: '开启后新建工单时可选择关联本人一个已支付订单;关闭后工单与订单完全解耦' },
|
||||
{ key: 'ticket_urge_interval', label: '催单冷却(分钟)', type: 'number', default: 15, min: 1, max: 1440, help: '同一工单两次催单之间的最小间隔' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
+5
-316
@@ -6175,15 +6175,14 @@ body.aksrt-modal-runtime-open {
|
||||
color: var(--color-ink, #111);
|
||||
}
|
||||
|
||||
:is(.order-comment-from, .aksrt-report-modal, .aksrt-after-sale-apply-modal) .form-upload .preview {
|
||||
:is(.order-comment-from, .aksrt-report-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 {
|
||||
:is(.order-comment-from, .aksrt-report-modal) .form-upload .preview .preview-item {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 10px;
|
||||
@@ -6199,14 +6198,14 @@ body.aksrt-modal-runtime-open {
|
||||
此前这里的 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 {
|
||||
:is(.order-comment-from, .aksrt-report-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 {
|
||||
:is(.order-comment-from, .aksrt-report-modal) .form-upload .preview .preview-item .remove {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
@@ -6221,7 +6220,7 @@ body.aksrt-modal-runtime-open {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:is(.order-comment-from, .aksrt-report-modal, .aksrt-after-sale-apply-modal) .form-upload .hide {
|
||||
:is(.order-comment-from, .aksrt-report-modal) .form-upload .hide {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -6237,316 +6236,6 @@ body.aksrt-modal-runtime-open {
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 售后弹窗(申请 / 售后详细 / 超时效提示,参考主题 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,
|
||||
.aksrt-after-sale-withdraw-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,
|
||||
.aksrt-after-sale-withdraw-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-after-sale-withdraw-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-after-sale-withdraw-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-after-sale-withdraw-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-after-sale-withdraw-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-after-sale-withdraw-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-after-sale-withdraw-modal .colorful-bg > .colorful-make,
|
||||
.aksrt-order-detail-modal .colorful-bg > .colorful-make,
|
||||
.aksrt-after-sale-expired-modal .colorful-bg > .colorful-make::before,
|
||||
.aksrt-after-sale-withdraw-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-after-sale-withdraw-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-after-sale-withdraw-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-after-sale-withdraw-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-after-sale-withdraw-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); }
|
||||
|
||||
/* 售后处理记录时间线:[节点][处理状态][时间] 一行,处理备注另起一行并对齐状态文字。
|
||||
售后详细弹窗与订单详情弹窗的「售后记录」共用同一套标记;竖线为条目的
|
||||
::before,末项不收口,避免出现悬空的线段。 */
|
||||
.aksrt-after-sale-log { display: grid; gap: 6px; }
|
||||
.aksrt-after-sale-log__empty { color: var(--modal-muted); font-size: 12px; }
|
||||
.aksrt-after-sale-timeline { display: grid; margin: 0; padding: 0; list-style: none; }
|
||||
.aksrt-after-sale-timeline__item { --as-accent: var(--modal-muted); position: relative; display: grid; grid-template-columns: 10px minmax(0, 1fr); gap: 10px; padding: 0 0 14px; }
|
||||
.aksrt-after-sale-timeline__item:last-child { padding-bottom: 0; }
|
||||
.aksrt-after-sale-timeline__item.is-pending { --as-accent: var(--modal-warning); }
|
||||
.aksrt-after-sale-timeline__item.is-processing { --as-accent: var(--modal-primary); }
|
||||
.aksrt-after-sale-timeline__item.is-completed { --as-accent: var(--modal-success); }
|
||||
.aksrt-after-sale-timeline__item.is-rejected,
|
||||
.aksrt-after-sale-timeline__item.is-cancelled { --as-accent: var(--modal-danger, #ef4444); }
|
||||
.aksrt-after-sale-timeline__item::before { content: ""; position: absolute; top: 14px; bottom: -2px; left: 4px; width: 2px; border-radius: 1px; background: color-mix(in srgb, var(--as-accent) 30%, var(--modal-line)); }
|
||||
.aksrt-after-sale-timeline__item:last-child::before { display: none; }
|
||||
.aksrt-after-sale-timeline__knot { position: relative; z-index: 1; width: 10px; height: 10px; margin-top: 4px; border: 2px solid var(--as-accent); border-radius: 50%; background: var(--modal-surface, #fff); }
|
||||
.aksrt-after-sale-timeline__body { display: grid; gap: 3px; min-width: 0; }
|
||||
.aksrt-after-sale-timeline__head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; }
|
||||
.aksrt-after-sale-timeline__status { color: var(--as-accent); font-size: 12px; font-weight: 700; line-height: 1.5; }
|
||||
.aksrt-after-sale-timeline__time { color: var(--modal-muted); font-size: 11px; line-height: 1.5; font-variant-numeric: tabular-nums; }
|
||||
.aksrt-after-sale-timeline__note { margin: 0; color: var(--modal-ink); font-size: 12.5px; line-height: 1.65; word-break: break-word; }
|
||||
|
||||
/* 状态行右侧单元格:状态徽章 + 「撤销售后」入口(仅待审核工单渲染)。 */
|
||||
.aksrt-after-sale-detail__statusCell { display: inline-flex; align-items: center; gap: 10px; }
|
||||
.aksrt-after-sale-withdraw { display: inline-flex; align-items: center; gap: 3px; padding: 0; border: 0; background: transparent; color: var(--modal-danger, #ef4444); font-size: 12px; font-weight: 600; line-height: 1.4; cursor: pointer; transition: color .15s ease; }
|
||||
.aksrt-after-sale-withdraw .fa { font-size: 12px; }
|
||||
.aksrt-after-sale-withdraw:hover { color: color-mix(in srgb, var(--modal-danger) 78%, var(--modal-ink)); }
|
||||
|
||||
/* 售后提示弹窗(撤销售后确认)正文。 */
|
||||
.aksrt-after-sale-withdraw__body { margin-bottom: 16px; color: var(--color-ink-2, #4e5358); line-height: 1.7; }
|
||||
|
||||
/* 售后弹窗底部按钮:全宽平铺贴底(负 margin 抵消 __content 内边距)+ 分隔线,
|
||||
子按钮去独立背景/边框/圆角(文字色区分主次),覆盖上方全局按钮网格的胶囊观感;
|
||||
仅在售后弹窗家族生效,其他 runtime 弹窗仍走全局网格样式。
|
||||
footer 自身不留垂直内边距——上下 12px 并入按钮(min-height 62 = 12+38+12),
|
||||
按钮盒即整个底部区域(hover 底色/分界线随之满幅)。 */
|
||||
.aksrt-after-sale-apply-modal .but-average.modal-buts,
|
||||
.aksrt-after-sale-detail-modal .but-average.modal-buts,
|
||||
.aksrt-after-sale-expired-modal .but-average.modal-buts,
|
||||
.aksrt-after-sale-withdraw-modal .but-average.modal-buts {
|
||||
gap: 0;
|
||||
margin: 16px -18px -16px;
|
||||
padding: 0;
|
||||
border-top: 1px solid var(--modal-line-soft, rgba(50, 50, 50, 0.06));
|
||||
background: var(--main-bg-color, #fff);
|
||||
}
|
||||
|
||||
.aksrt-after-sale-apply-modal .but-average.modal-buts > .but,
|
||||
.aksrt-after-sale-detail-modal .but-average.modal-buts > .but,
|
||||
.aksrt-after-sale-expired-modal .but-average.modal-buts > .but,
|
||||
.aksrt-after-sale-withdraw-modal .but-average.modal-buts > .but {
|
||||
min-height: 62px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
color: var(--modal-muted, #777);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
--this-bg: transparent;
|
||||
--this-border: transparent;
|
||||
--this-shadow: none;
|
||||
}
|
||||
|
||||
.aksrt-after-sale-apply-modal .but-average.modal-buts > .but.c-blue,
|
||||
.aksrt-after-sale-detail-modal .but-average.modal-buts > .but.c-blue,
|
||||
.aksrt-after-sale-expired-modal .but-average.modal-buts > .but.c-blue,
|
||||
.aksrt-after-sale-withdraw-modal .but-average.modal-buts > .but.c-blue {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--modal-primary, #f04494);
|
||||
}
|
||||
|
||||
/* 相邻按钮分界线:legacy footer 自带 .but + .but 左分隔线,上方平铺规则的 border:0
|
||||
会把它抹掉,这里按家族分隔色还原(单按钮无相邻兄弟,天然无分界线)。
|
||||
需排在 .c-blue 规则之后:确认提交等主按钮同受 border:0 影响,后者同名优先级。 */
|
||||
.aksrt-after-sale-apply-modal .but-average.modal-buts > .but + .but,
|
||||
.aksrt-after-sale-detail-modal .but-average.modal-buts > .but + .but,
|
||||
.aksrt-after-sale-expired-modal .but-average.modal-buts > .but + .but,
|
||||
.aksrt-after-sale-withdraw-modal .but-average.modal-buts > .but + .but {
|
||||
border-left: 1px solid var(--modal-line-soft, rgba(50, 50, 50, 0.06));
|
||||
}
|
||||
|
||||
/* 悬停反馈(参考 Element 卡片 footer 的 hover 语言):整块区域浅灰底 + 文字转主色,
|
||||
次级按钮 hover 升为主色、主按钮略加深;平铺按钮已满幅,底色即铺满整个区域,
|
||||
基础 .but 的 0.15s 过渡自动生效,并压掉 compat 层的 hover 半透明。 */
|
||||
.aksrt-after-sale-apply-modal .but-average.modal-buts > .but:hover,
|
||||
.aksrt-after-sale-detail-modal .but-average.modal-buts > .but:hover,
|
||||
.aksrt-after-sale-expired-modal .but-average.modal-buts > .but:hover,
|
||||
.aksrt-after-sale-withdraw-modal .but-average.modal-buts > .but:hover {
|
||||
opacity: 1;
|
||||
background: color-mix(in srgb, var(--modal-ink, #333333) 5%, transparent);
|
||||
color: var(--modal-primary, #f04494);
|
||||
}
|
||||
|
||||
.aksrt-after-sale-apply-modal .but-average.modal-buts > .but.c-blue:hover,
|
||||
.aksrt-after-sale-detail-modal .but-average.modal-buts > .but.c-blue:hover,
|
||||
.aksrt-after-sale-expired-modal .but-average.modal-buts > .but.c-blue:hover,
|
||||
.aksrt-after-sale-withdraw-modal .but-average.modal-buts > .but.c-blue:hover {
|
||||
color: color-mix(in srgb, var(--modal-primary, #f04494) 82%, #000000);
|
||||
}
|
||||
|
||||
/* 单按钮 footer 占满整行(全局网格是 1fr 1fr,单按钮会只占一半)。 */
|
||||
.aksrt-after-sale-apply-modal .but-average.modal-buts > .but:only-child,
|
||||
.aksrt-after-sale-detail-modal .but-average.modal-buts > .but:only-child,
|
||||
.aksrt-after-sale-expired-modal .but-average.modal-buts > .but:only-child,
|
||||
.aksrt-after-sale-withdraw-modal .but-average.modal-buts > .but:only-child { grid-column: 1 / -1; }
|
||||
|
||||
/* source: assets/css/frontend/notyf.css */
|
||||
/**
|
||||
* legacy-compatible frontend notifications.
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
:root{--color-primary: #f04494;--color-primary-hover: #e0307e;--color-primary-active: #c9206d;--color-primary-soft: rgba(240, 68, 148, .1);--color-primary-softer: rgba(240, 68, 148, .05);--color-on-primary: #FFFFFF;--color-ink: #333333;--color-ink-2: #4e5358;--color-ink-3: #777777;--color-ink-4: #999999;--surface-page: #f5f6f7;--surface-1: #FFFFFF;--surface-2: #eeeeee;--surface-3: #e9ecef;--surface-4: #f8f8f8;--color-line: rgba(50, 50, 50, .06);--color-line-soft: rgba(0, 0, 0, .03);--color-line-stronger: rgba(50, 50, 50, .12);--color-success: #10B981;--color-warning: #F59E0B;--color-error: #EF4444;--color-info: #3B82F6;--color-accent: #18a058;--color-accent-soft: rgba(24, 160, 88, .12);--color-accent-shadow: rgba(24, 160, 88, .28);--font-display: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;--font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;--font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;--text-xs: .75rem;--text-sm: .875rem;--text-base: .875rem;--text-md: 1.125rem;--text-lg: 1.25rem;--text-xl: 1.5rem;--text-2xl: 1.875rem;--text-3xl: 2.25rem;--text-4xl: 3rem;--text-5xl: 3.75rem;--leading-tight: 1.2;--leading-snug: 1.4;--leading-normal: 1.6;--leading-relaxed: 1.6;--space-1: 4px;--space-2: 8px;--space-3: 12px;--space-4: 16px;--space-5: 20px;--space-6: 24px;--space-7: 32px;--space-8: 40px;--space-9: 48px;--space-10: 64px;--space-11: 80px;--space-12: 96px;--radius-xs: 3px;--radius-sm: 4px;--radius: 8px;--radius-lg: 8px;--radius-xl: 12px;--radius-2xl: 16px;--radius-pill: 100px;--radius-avatar: 50%;--shadow-xs: 0 1px 2px rgba(0, 0, 0, .03);--shadow-sm: 0 0 10px rgba(116, 116, 116, .08);--shadow: 0 0 10px rgba(116, 116, 116, .12);--shadow-lg: 0 12px 32px rgba(18, 18, 18, .1);--shadow-drawer: 0 18px 60px rgba(0, 0, 0, .2);--nav-height: 60px;--container: 1200px;--container-wide: 1440px;--container-read: 760px;--container-page: 880px;--sidebar-width: 311px;--content-gap: 24px;--section-gap: 24px;--sidebar-bp: 992px;--ease: cubic-bezier(.4, 0, .2, 1);--ease-out: cubic-bezier(.16, 1, .3, 1);--ease-spring: cubic-bezier(.34, 1.56, .64, 1);--dur-fast: .15s;--dur: .25s;--dur-slow: .4s;--focus-ring: 0 0 2px 3px rgba(253, 83, 161, .4);--z-dropdown: 100;--z-sticky: 200;--z-fixed: 300;--z-overlay: 400;--z-modal: 500;--z-popover: 600;--z-toast: 700;--z-tooltip: 800;--radius-btn: 4px;--btn-sm-h: 28px;--btn-md-h: 34px;--btn-lg-h: 44px;--card-padding: 15px;--card-padding-sm: 10px;--card-gap: 16px;--card-gap-lg: 20px;--color-primary-light: var(--color-primary-soft);--color-surface: var(--surface-1);--color-line-strong: var(--color-line);--color-bg-hover: var(--surface-2);--color-border-light: var(--color-line-soft);--color-fill: var(--surface-2);--color-warning-bg: #fffbe6;--color-warning-text: #ad6800;--text-secondary: var(--color-ink-3);--text-tertiary: var(--color-ink-4);--surface-active: var(--surface-2);--surface-card: var(--surface-1);--surface-hover: var(--surface-2);--surface-secondary: var(--surface-2);--radius-code: var(--radius);--radius-full: 100px;--shadow-xl: 0 0 10px rgba(116, 116, 116, .12);--space-16: 64px;--z-floating: 900;--transition-fast: .15s ease;--transition-base: .25s ease;--header-bg: var(--blur-bg);--header-color: var(--color-ink-2);--footer-bg: var(--surface-1);--footer-color: var(--color-ink-4);--blur-bg: rgba(255, 255, 255, .8);--float-btn-bg: rgba(200, 200, 200, .4);--font-heading: var(--font-ui);--radius-card: var(--radius);--radius-md: var(--radius);--color-primary-50: var(--color-primary-soft);--color-primary-dark: var(--color-primary-active);--shadow-md: var(--shadow);--mini-radius: calc(var(--radius) / 1.4);--aksrt-avatar-size-xs: 24px;--aksrt-avatar-size-sm: 32px;--aksrt-avatar-size: 40px;--aksrt-avatar-size-lg: 56px;--aksrt-avatar-size-xl: 80px;--aksrt-card-thumb-scale: 56.25%;--aksrt-rose: #f43f5e;--aksrt-rose-soft: rgba(244, 63, 94, .12);--aksrt-blue: #3b82f6;--aksrt-blue-soft: rgba(59, 130, 246, .12);--aksrt-green: #10b981;--aksrt-green-soft: rgba(16, 185, 129, .12);--aksrt-orange: #f97316;--aksrt-orange-soft:rgba(249, 115, 22, .12);--aksrt-purple: #8b5cf6;--aksrt-purple-soft:rgba(139, 92, 246, .12);--aksrt-cyan: #06b6d4;--aksrt-cyan-soft: rgba(6, 182, 212, .12);--aksrt-yellow: #eab308;--aksrt-yellow-soft:rgba(234, 179, 8, .12);--aksrt-red: #ef4444;--aksrt-red-soft: rgba(239, 68, 68, .12);--aksrt-teal: #14b8a6;--aksrt-teal-soft: rgba(20, 184, 166, .12);--aksrt-indigo: #6366f1;--aksrt-indigo-soft:rgba(99, 102, 241, .12);--aksrt-pink: #ec4899;--aksrt-pink-soft: rgba(236, 72, 153, .12);--aksrt-vip-gold-1: #f6d365;--aksrt-vip-gold-2: #fda085;--aksrt-grad-sunset-1: #f093fb;--aksrt-grad-sunset-2: #f5576c;--aksrt-graLine truncated
|
||||
:root{--color-primary: #f04494;--color-primary-hover: #e0307e;--color-primary-active: #c9206d;--color-primary-soft: rgba(240, 68, 148, .1);--color-primary-softer: rgba(240, 68, 148, .05);--color-on-primary: #FFFFFF;--color-ink: #333333;--color-ink-2: #4e5358;--color-ink-3: #777777;--color-ink-4: #999999;--surface-page: #f5f6f7;--surface-1: #FFFFFF;--surface-2: #eeeeee;--surface-3: #e9ecef;--surface-4: #f8f8f8;--color-line: rgba(50, 50, 50, .06);--color-line-soft: rgba(0, 0, 0, .03);--color-line-stronger: rgba(50, 50, 50, .12);--color-success: #10B981;--color-warning: #F59E0B;--color-error: #EF4444;--color-info: #3B82F6;--color-accent: #18a058;--color-accent-soft: rgba(24, 160, 88, .12);--color-accent-shadow: rgba(24, 160, 88, .28);--font-display: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;--font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;--font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;--font-mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace;--text-xs: .75rem;--text-sm: .875rem;--text-base: .875rem;--text-md: 1.125rem;--text-lg: 1.25rem;--text-xl: 1.5rem;--text-2xl: 1.875rem;--text-3xl: 2.25rem;--text-4xl: 3rem;--text-5xl: 3.75rem;--leading-tight: 1.2;--leading-snug: 1.4;--leading-normal: 1.6;--leading-relaxed: 1.6;--space-1: 4px;--space-2: 8px;--space-3: 12px;--space-4: 16px;--space-5: 20px;--space-6: 24px;--space-7: 32px;--space-8: 40px;--space-9: 48px;--space-10: 64px;--space-11: 80px;--space-12: 96px;--radius-xs: 3px;--radius-sm: 4px;--radius: 8px;--radius-lg: 8px;--radius-xl: 12px;--radius-2xl: 16px;--radius-pill: 100px;--radius-avatar: 50%;--shadow-xs: 0 1px 2px rgba(0, 0, 0, .03);--shadow-sm: 0 0 10px rgba(116, 116, 116, .08);--shadow: 0 0 10px rgba(116, 116, 116, .12);--shadow-lg: 0 12px 32px rgba(18, 18, 18, .1);--shadow-drawer: 0 18px 60px rgba(0, 0, 0, .2);--nav-height: 60px;--container: 1200px;--container-wide: 1440px;--container-read: 760px;--container-page: 880px;--sidebar-width: 311px;--content-gap: 24px;--section-gap: 24px;--sidebar-bp: 992px;--ease: cubic-bezier(.4, 0, .2, 1);--ease-out: cubic-bezier(.16, 1, .3, 1);--ease-spring: cubic-bezier(.34, 1.56, .64, 1);--dur-fast: .15s;--dur: .25s;--dur-slow: .4s;--focus-ring: 0 0 2px 3px rgba(253, 83, 161, .4);--z-dropdown: 100;--z-sticky: 200;--z-fixed: 300;--z-overlay: 400;--z-modal: 500;--z-popover: 600;--z-toast: 700;--z-tooltip: 800;--radius-btn: 4px;--btn-sm-h: 28px;--btn-md-h: 34px;--btn-lg-h: 44px;--card-padding: 15px;--card-padding-sm: 10px;--card-gap: 16px;--card-gap-lg: 20px;--color-primary-light: var(--color-primary-soft);--color-surface: var(--surface-1);--color-line-strong: var(--color-line);--color-bg-hover: var(--surface-2);--color-border-light: var(--color-line-soft);--color-fill: var(--surface-2);--color-warning-bg: #fffbe6;--color-warning-text: #ad6800;--text-secondary: var(--color-ink-3);--text-tertiary: var(--color-ink-4);--surface-active: var(--surface-2);--surface-card: var(--surface-1);--surface-hover: var(--surface-2);--surface-secondary: var(--surface-2);--radius-code: var(--radius);--radius-full: 100px;--shadow-xl: 0 0 10px rgba(116, 116, 116, .12);--space-16: 64px;--z-floating: 900;--transition-fast: .15s ease;--transition-base: .25s ease;--header-bg: var(--blur-bg);--header-color: var(--color-ink-2);--footer-bg: var(--surface-1);--footer-color: var(--color-ink-4);--blur-bg: rgba(255, 255, 255, .8);--float-btn-bg: rgba(200, 200, 200, .4);--font-heading: var(--font-ui);--radius-card: var(--radius);--radius-md: var(--radius);--color-primary-50: var(--color-primary-soft);--color-primary-dark: var(--color-primary-active);--shadow-md: var(--shadow);--mini-radius: calc(var(--radius) / 1.4);--aksrt-avatar-size-xs: 24px;--aksrt-avatar-size-sm: 32px;--aksrt-avatar-size: 40px;--aksrt-avatar-size-lg: 56px;--aksrt-avatar-size-xl: 80px;--aksrt-card-thumb-scale: 56.25%;--aksrt-rose: #f43f5e;--aksrt-rose-soft: rgba(244, 63, 94, .12);--aksrt-blue: #3b82f6;--aksrt-blue-soft: rgba(59, 130, 246, .12);--aksrt-green: #10b981;--aksrt-green-soft: rgba(16, 185, 129, .12);--aksrt-orange: #f97316;--aksrt-orange-soft:rgba(249, 115, 22, .12);--aksrt-purple: #8b5cf6;--aksrt-purple-soft:rgba(139, 92, 246, .12);--aksrt-cyan: #06b6d4;--aksrt-cyan-soft: rgba(6, 182, 212, .12);--aksrt-yellow: #eab308;--aksrt-yellow-soft:rgba(234, 179, 8, .12);--aksrt-red: #ef4444;--aksrt-red-soft: rgba(239, 68, 68, .12);--aksrt-teal: #14b8a6;--aksrt-teal-soft: rgba(20, 184, 166, .12);--aksrt-indigo: #6366f1;--aksrt-indigo-soft:rgba(99, 102, 241, .12);--aksrt-pink: #ec4899;--aksrt-pink-soft: rgba(236, 72, 153, .12);--aksrt-vip-gold-1: #f6d365;--aksrt-vip-gold-2: #fda085;--aksrt-grad-sunset-1: #f093fb;--aksrt-grad-sunset-2: #f5576c;--aksrt-graLine truncated
|
||||
@@ -552,15 +552,14 @@ body.aksrt-modal-runtime-open {
|
||||
color: var(--color-ink, #111);
|
||||
}
|
||||
|
||||
:is(.order-comment-from, .aksrt-report-modal, .aksrt-after-sale-apply-modal) .form-upload .preview {
|
||||
:is(.order-comment-from, .aksrt-report-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 {
|
||||
:is(.order-comment-from, .aksrt-report-modal) .form-upload .preview .preview-item {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 10px;
|
||||
@@ -576,14 +575,14 @@ body.aksrt-modal-runtime-open {
|
||||
此前这里的 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 {
|
||||
:is(.order-comment-from, .aksrt-report-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 {
|
||||
:is(.order-comment-from, .aksrt-report-modal) .form-upload .preview .preview-item .remove {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
@@ -598,7 +597,7 @@ body.aksrt-modal-runtime-open {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:is(.order-comment-from, .aksrt-report-modal, .aksrt-after-sale-apply-modal) .form-upload .hide {
|
||||
:is(.order-comment-from, .aksrt-report-modal) .form-upload .hide {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@@ -613,313 +612,3 @@ body.aksrt-modal-runtime-open {
|
||||
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,
|
||||
.aksrt-after-sale-withdraw-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,
|
||||
.aksrt-after-sale-withdraw-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-after-sale-withdraw-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-after-sale-withdraw-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-after-sale-withdraw-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-after-sale-withdraw-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-after-sale-withdraw-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-after-sale-withdraw-modal .colorful-bg > .colorful-make,
|
||||
.aksrt-order-detail-modal .colorful-bg > .colorful-make,
|
||||
.aksrt-after-sale-expired-modal .colorful-bg > .colorful-make::before,
|
||||
.aksrt-after-sale-withdraw-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-after-sale-withdraw-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-after-sale-withdraw-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-after-sale-withdraw-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-after-sale-withdraw-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); }
|
||||
|
||||
/* 售后处理记录时间线:[节点][处理状态][时间] 一行,处理备注另起一行并对齐状态文字。
|
||||
售后详细弹窗与订单详情弹窗的「售后记录」共用同一套标记;竖线为条目的
|
||||
::before,末项不收口,避免出现悬空的线段。 */
|
||||
.aksrt-after-sale-log { display: grid; gap: 6px; }
|
||||
.aksrt-after-sale-log__empty { color: var(--modal-muted); font-size: 12px; }
|
||||
.aksrt-after-sale-timeline { display: grid; margin: 0; padding: 0; list-style: none; }
|
||||
.aksrt-after-sale-timeline__item { --as-accent: var(--modal-muted); position: relative; display: grid; grid-template-columns: 10px minmax(0, 1fr); gap: 10px; padding: 0 0 14px; }
|
||||
.aksrt-after-sale-timeline__item:last-child { padding-bottom: 0; }
|
||||
.aksrt-after-sale-timeline__item.is-pending { --as-accent: var(--modal-warning); }
|
||||
.aksrt-after-sale-timeline__item.is-processing { --as-accent: var(--modal-primary); }
|
||||
.aksrt-after-sale-timeline__item.is-completed { --as-accent: var(--modal-success); }
|
||||
.aksrt-after-sale-timeline__item.is-rejected,
|
||||
.aksrt-after-sale-timeline__item.is-cancelled { --as-accent: var(--modal-danger, #ef4444); }
|
||||
.aksrt-after-sale-timeline__item::before { content: ""; position: absolute; top: 14px; bottom: -2px; left: 4px; width: 2px; border-radius: 1px; background: color-mix(in srgb, var(--as-accent) 30%, var(--modal-line)); }
|
||||
.aksrt-after-sale-timeline__item:last-child::before { display: none; }
|
||||
.aksrt-after-sale-timeline__knot { position: relative; z-index: 1; width: 10px; height: 10px; margin-top: 4px; border: 2px solid var(--as-accent); border-radius: 50%; background: var(--modal-surface, #fff); }
|
||||
.aksrt-after-sale-timeline__body { display: grid; gap: 3px; min-width: 0; }
|
||||
.aksrt-after-sale-timeline__head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; }
|
||||
.aksrt-after-sale-timeline__status { color: var(--as-accent); font-size: 12px; font-weight: 700; line-height: 1.5; }
|
||||
.aksrt-after-sale-timeline__time { color: var(--modal-muted); font-size: 11px; line-height: 1.5; font-variant-numeric: tabular-nums; }
|
||||
.aksrt-after-sale-timeline__note { margin: 0; color: var(--modal-ink); font-size: 12.5px; line-height: 1.65; word-break: break-word; }
|
||||
|
||||
/* 状态行右侧单元格:状态徽章 + 「撤销售后」入口(仅待审核工单渲染)。 */
|
||||
.aksrt-after-sale-detail__statusCell { display: inline-flex; align-items: center; gap: 10px; }
|
||||
.aksrt-after-sale-withdraw { display: inline-flex; align-items: center; gap: 3px; padding: 0; border: 0; background: transparent; color: var(--modal-danger, #ef4444); font-size: 12px; font-weight: 600; line-height: 1.4; cursor: pointer; transition: color .15s ease; }
|
||||
.aksrt-after-sale-withdraw .fa { font-size: 12px; }
|
||||
.aksrt-after-sale-withdraw:hover { color: color-mix(in srgb, var(--modal-danger) 78%, var(--modal-ink)); }
|
||||
|
||||
/* 售后提示弹窗(撤销售后确认)正文。 */
|
||||
.aksrt-after-sale-withdraw__body { margin-bottom: 16px; color: var(--color-ink-2, #4e5358); line-height: 1.7; }
|
||||
|
||||
/* 售后弹窗底部按钮:全宽平铺贴底(负 margin 抵消 __content 内边距)+ 分隔线,
|
||||
子按钮去独立背景/边框/圆角(文字色区分主次),覆盖上方全局按钮网格的胶囊观感;
|
||||
仅在售后弹窗家族生效,其他 runtime 弹窗仍走全局网格样式。
|
||||
footer 自身不留垂直内边距——上下 12px 并入按钮(min-height 62 = 12+38+12),
|
||||
按钮盒即整个底部区域(hover 底色/分界线随之满幅)。 */
|
||||
.aksrt-after-sale-apply-modal .but-average.modal-buts,
|
||||
.aksrt-after-sale-detail-modal .but-average.modal-buts,
|
||||
.aksrt-after-sale-expired-modal .but-average.modal-buts,
|
||||
.aksrt-after-sale-withdraw-modal .but-average.modal-buts {
|
||||
gap: 0;
|
||||
margin: 16px -18px -16px;
|
||||
padding: 0;
|
||||
border-top: 1px solid var(--modal-line-soft, rgba(50, 50, 50, 0.06));
|
||||
background: var(--main-bg-color, #fff);
|
||||
}
|
||||
|
||||
.aksrt-after-sale-apply-modal .but-average.modal-buts > .but,
|
||||
.aksrt-after-sale-detail-modal .but-average.modal-buts > .but,
|
||||
.aksrt-after-sale-expired-modal .but-average.modal-buts > .but,
|
||||
.aksrt-after-sale-withdraw-modal .but-average.modal-buts > .but {
|
||||
min-height: 62px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
color: var(--modal-muted, #777);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
--this-bg: transparent;
|
||||
--this-border: transparent;
|
||||
--this-shadow: none;
|
||||
}
|
||||
|
||||
.aksrt-after-sale-apply-modal .but-average.modal-buts > .but.c-blue,
|
||||
.aksrt-after-sale-detail-modal .but-average.modal-buts > .but.c-blue,
|
||||
.aksrt-after-sale-expired-modal .but-average.modal-buts > .but.c-blue,
|
||||
.aksrt-after-sale-withdraw-modal .but-average.modal-buts > .but.c-blue {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--modal-primary, #f04494);
|
||||
}
|
||||
|
||||
/* 相邻按钮分界线:legacy footer 自带 .but + .but 左分隔线,上方平铺规则的 border:0
|
||||
会把它抹掉,这里按家族分隔色还原(单按钮无相邻兄弟,天然无分界线)。
|
||||
需排在 .c-blue 规则之后:确认提交等主按钮同受 border:0 影响,后者同名优先级。 */
|
||||
.aksrt-after-sale-apply-modal .but-average.modal-buts > .but + .but,
|
||||
.aksrt-after-sale-detail-modal .but-average.modal-buts > .but + .but,
|
||||
.aksrt-after-sale-expired-modal .but-average.modal-buts > .but + .but,
|
||||
.aksrt-after-sale-withdraw-modal .but-average.modal-buts > .but + .but {
|
||||
border-left: 1px solid var(--modal-line-soft, rgba(50, 50, 50, 0.06));
|
||||
}
|
||||
|
||||
/* 悬停反馈(参考 Element 卡片 footer 的 hover 语言):整块区域浅灰底 + 文字转主色,
|
||||
次级按钮 hover 升为主色、主按钮略加深;平铺按钮已满幅,底色即铺满整个区域,
|
||||
基础 .but 的 0.15s 过渡自动生效,并压掉 compat 层的 hover 半透明。 */
|
||||
.aksrt-after-sale-apply-modal .but-average.modal-buts > .but:hover,
|
||||
.aksrt-after-sale-detail-modal .but-average.modal-buts > .but:hover,
|
||||
.aksrt-after-sale-expired-modal .but-average.modal-buts > .but:hover,
|
||||
.aksrt-after-sale-withdraw-modal .but-average.modal-buts > .but:hover {
|
||||
opacity: 1;
|
||||
background: color-mix(in srgb, var(--modal-ink, #333333) 5%, transparent);
|
||||
color: var(--modal-primary, #f04494);
|
||||
}
|
||||
|
||||
.aksrt-after-sale-apply-modal .but-average.modal-buts > .but.c-blue:hover,
|
||||
.aksrt-after-sale-detail-modal .but-average.modal-buts > .but.c-blue:hover,
|
||||
.aksrt-after-sale-expired-modal .but-average.modal-buts > .but.c-blue:hover,
|
||||
.aksrt-after-sale-withdraw-modal .but-average.modal-buts > .but.c-blue:hover {
|
||||
color: color-mix(in srgb, var(--modal-primary, #f04494) 82%, #000000);
|
||||
}
|
||||
|
||||
/* 单按钮 footer 占满整行(全局网格是 1fr 1fr,单按钮会只占一半)。 */
|
||||
.aksrt-after-sale-apply-modal .but-average.modal-buts > .but:only-child,
|
||||
.aksrt-after-sale-detail-modal .but-average.modal-buts > .but:only-child,
|
||||
.aksrt-after-sale-expired-modal .but-average.modal-buts > .but:only-child,
|
||||
.aksrt-after-sale-withdraw-modal .but-average.modal-buts > .but:only-child { grid-column: 1 / -1; }
|
||||
@@ -108,14 +108,14 @@
|
||||
--detail-success: var(--modal-success);
|
||||
}
|
||||
|
||||
/* 订单详情弹窗:与售后弹窗统一为 modal-mini 几何(400px);标题区改用蓝色炫彩头,运行时不再渲染默认标题栏。 */
|
||||
/* 订单详情弹窗: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);
|
||||
}
|
||||
/* 滚动发生在 dialog 本体(modal-runtime 的 overflow:auto),内容超高时右缘会露出
|
||||
滚动栏;与售后/申诉弹窗口径一致:完全隐藏滚动条(含细滚动条),滚动行为不变。 */
|
||||
滚动栏;与申诉弹窗口径一致:完全隐藏滚动条(含细滚动条),滚动行为不变。 */
|
||||
.aksrt-order-detail-modal .aksrt-modal-runtime__dialog { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
.aksrt-order-detail-modal .aksrt-modal-runtime__dialog::-webkit-scrollbar { display: none; }
|
||||
.aksrt-order-detail-modal .aksrt-modal-runtime__content { display: flex; flex-direction: column; gap: 18px; padding: 16px 18px 0; }
|
||||
@@ -169,13 +169,13 @@
|
||||
按钮盒即整个底部区域;安全区留在 footer 底部,避免按钮文字沉入 Home 指示条。 */
|
||||
.aksrt-order-detail-modal .aksrt-order-detail__actions { position: sticky; bottom: 0; z-index: 2; margin: 0 -18px; padding: 0 0 env(safe-area-inset-bottom, 0); 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; gap: 0; }
|
||||
/* 底部按钮观感与售后弹窗家族对齐(modal-runtime 同款平铺规则):去胶囊底色/边框/圆角,
|
||||
文字色区分主次;仅「关闭」单按钮时占满整行,即售后详细「我知道了」的呈现。
|
||||
/* 底部按钮观感与弹窗家族对齐(modal-runtime 同款平铺规则):去胶囊底色/边框/圆角,
|
||||
文字色区分主次;仅「关闭」单按钮时占满整行。
|
||||
分界线与悬停底色需排在 .c-blue 规则之后:主按钮同受 border:0 影响与主色覆盖,同名优先级看先后。 */
|
||||
.aksrt-order-detail-modal .aksrt-order-detail__actions.but-average.modal-buts > .but { min-height: 64px; border: 0; border-radius: 0; box-shadow: none; background: transparent; color: var(--modal-muted, #777); font-weight: 600; cursor: pointer; --this-bg: transparent; --this-border: transparent; --this-shadow: none; }
|
||||
.aksrt-order-detail-modal .aksrt-order-detail__actions.but-average.modal-buts > .but.c-blue { background: transparent; border: 0; color: var(--modal-primary, #f04494); }
|
||||
.aksrt-order-detail-modal .aksrt-order-detail__actions.but-average.modal-buts > .but + .but { border-left: 1px solid var(--detail-line); }
|
||||
/* 悬停反馈与售后弹窗家族同款:整块区域浅灰底 + 文字转主色(次级升主色、主按钮略加深)。 */
|
||||
/* 悬停反馈与弹窗家族同款:整块区域浅灰底 + 文字转主色(次级升主色、主按钮略加深)。 */
|
||||
.aksrt-order-detail-modal .aksrt-order-detail__actions.but-average.modal-buts > .but:hover { opacity: 1; background: color-mix(in srgb, var(--modal-ink, #333333) 5%, transparent); color: var(--modal-primary, #f04494); }
|
||||
.aksrt-order-detail-modal .aksrt-order-detail__actions.but-average.modal-buts > .but.c-blue:hover { color: color-mix(in srgb, var(--modal-primary, #f04494) 82%, #000000); }
|
||||
.aksrt-order-detail-modal .aksrt-order-detail__actions.but-average.modal-buts > .but:only-child { grid-column: 1 / -1; }
|
||||
@@ -184,12 +184,6 @@
|
||||
.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; }
|
||||
@@ -200,16 +194,3 @@
|
||||
@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-field { color: var(--detail-muted); }
|
||||
.aksrt-order-detail__after-sale-foot { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||
@@ -0,0 +1,177 @@
|
||||
/* User center ticket (工单) center */
|
||||
.aksrt-tickets-page {
|
||||
--tickets-ink: var(--color-ink, #1d2738);
|
||||
--tickets-muted: var(--color-ink-3, #758096);
|
||||
--tickets-line: color-mix(in srgb, var(--color-line, #e1e6ef) 88%, transparent);
|
||||
padding: 0;
|
||||
color: var(--tickets-ink);
|
||||
}
|
||||
|
||||
.uc-tickets-panel__head {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 20px;
|
||||
align-items: end;
|
||||
padding: 4px 0 22px;
|
||||
border-bottom: 1px solid var(--tickets-line);
|
||||
}
|
||||
|
||||
.aksrt-tickets-page__eyebrow {
|
||||
margin: 0 0 7px;
|
||||
color: var(--color-primary, #3b82f6);
|
||||
font-size: 11px;
|
||||
font-weight: 750;
|
||||
letter-spacing: .14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.uc-tickets-panel__title { display: flex; align-items: center; gap: 9px; font-size: 21px; }
|
||||
.uc-tickets-panel__title-icon { color: var(--color-primary, #3b82f6); font-size: 17px; }
|
||||
.uc-tickets-panel__title-wrap > p:last-child { margin-bottom: 0; }
|
||||
|
||||
.aksrt-tickets-page__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(88px, 1fr));
|
||||
margin: 18px 0;
|
||||
border: 1px solid var(--tickets-line);
|
||||
border-radius: 14px;
|
||||
background: var(--surface-1, #fff);
|
||||
overflow: hidden;
|
||||
}
|
||||
.aksrt-tickets-page__stat { display: grid; gap: 5px; padding: 12px 15px; border-right: 1px solid var(--tickets-line); }
|
||||
.aksrt-tickets-page__stat:last-child { border-right: 0; }
|
||||
.aksrt-tickets-page__stat span { color: var(--tickets-muted); font-size: 12px; }
|
||||
.aksrt-tickets-page__stat strong { font-size: 19px; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.aksrt-tickets-page__search {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.aksrt-tickets-page__search input,
|
||||
.aksrt-tickets-page__search select {
|
||||
padding: 7px 11px;
|
||||
border: 1px solid var(--tickets-line);
|
||||
border-radius: 9px;
|
||||
background: var(--surface-1, #fff);
|
||||
color: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
.aksrt-tickets-page__search input { flex: 1 1 190px; }
|
||||
|
||||
.aksrt-tickets-page__list { display: grid; gap: 12px; }
|
||||
|
||||
.aksrt-tickets-page__card {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 15px 17px;
|
||||
border: 1px solid var(--tickets-line);
|
||||
border-radius: 13px;
|
||||
background: var(--surface-1, #fff);
|
||||
}
|
||||
.aksrt-tickets-page__card-head { display: flex; flex-wrap: wrap; gap: 9px; align-items: center; }
|
||||
.aksrt-tickets-page__card-num { color: var(--tickets-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
.aksrt-tickets-page__card-title { margin: 0; font-size: 15px; line-height: 1.5; }
|
||||
.aksrt-tickets-page__card-meta { display: flex; flex-wrap: wrap; gap: 12px; color: var(--tickets-muted); font-size: 12px; }
|
||||
.aksrt-tickets-page__card-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
|
||||
.aksrt-ticket-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 11px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--aksrt-ticket-color, #6b7280) 13%, transparent);
|
||||
color: var(--aksrt-ticket-color, #6b7280);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.aksrt-tickets-page__empty { display: grid; gap: 7px; justify-items: center; padding: 46px 16px; text-align: center; }
|
||||
.aksrt-tickets-page__empty > i { color: var(--tickets-muted); font-size: 30px; }
|
||||
.aksrt-tickets-page__empty h3 { margin: 0; font-size: 16px; }
|
||||
.aksrt-tickets-page__empty p { margin: 0; color: var(--tickets-muted); font-size: 13px; }
|
||||
|
||||
.aksrt-tickets-page__pagination { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; margin-top: 18px; }
|
||||
.aksrt-tickets-page__page-chip {
|
||||
min-width: 32px;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid var(--tickets-line);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-1, #fff);
|
||||
color: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.aksrt-tickets-page__page-chip.is-active {
|
||||
border-color: transparent;
|
||||
background: var(--color-primary, #3b82f6);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 详情 / 新建弹窗 */
|
||||
.aksrt-ticket-detail { display: grid; gap: 12px; text-align: left; }
|
||||
.aksrt-ticket-detail__head { display: flex; flex-wrap: wrap; gap: 9px; align-items: center; }
|
||||
.aksrt-ticket-detail__type { color: var(--tickets-muted); font-size: 12px; }
|
||||
.aksrt-ticket-detail__title { margin: 0; font-size: 15px; line-height: 1.5; }
|
||||
.aksrt-ticket-detail__order { margin: 0; color: var(--tickets-muted); font-size: 12px; }
|
||||
.aksrt-ticket-detail__content,
|
||||
.aksrt-ticket-detail__reply-body { color: var(--tickets-ink); font-size: 13px; line-height: 1.7; white-space: pre-wrap; word-break: break-word; }
|
||||
|
||||
.aksrt-ticket-detail__replies { display: grid; gap: 9px; }
|
||||
.aksrt-ticket-detail__reply { display: grid; gap: 5px; padding: 10px 12px; border-left: 3px solid var(--tickets-line); border-radius: 4px; background: var(--surface-2, #f6f7f9); }
|
||||
.aksrt-ticket-detail__reply.is-admin { border-left-color: var(--color-primary, #3b82f6); }
|
||||
.aksrt-ticket-detail__reply-head { display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline; font-size: 12px; }
|
||||
.aksrt-ticket-detail__reply-head time { color: var(--tickets-muted); font-size: 11px; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.aksrt-ticket-quote {
|
||||
margin: 0;
|
||||
padding: 6px 10px;
|
||||
border-left: 3px solid var(--tickets-line);
|
||||
background: var(--surface-1, #fff);
|
||||
color: var(--tickets-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.aksrt-ticket-detail__closed { margin: 0; color: var(--tickets-muted); font-size: 12px; }
|
||||
|
||||
.aksrt-ticket-attachments { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||
.aksrt-ticket-attachment { display: block; width: 68px; height: 68px; border: 1px solid var(--tickets-line); border-radius: 8px; overflow: hidden; }
|
||||
.aksrt-ticket-attachment img { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
.aksrt-ticket-reply-form,
|
||||
.aksrt-ticket-create-form { display: grid; gap: 11px; text-align: left; }
|
||||
.aksrt-ticket-reply-form textarea,
|
||||
.aksrt-ticket-create-form textarea,
|
||||
.aksrt-ticket-create-form input,
|
||||
.aksrt-ticket-create-form select {
|
||||
width: 100%;
|
||||
padding: 8px 11px;
|
||||
border: 1px solid var(--tickets-line);
|
||||
border-radius: 9px;
|
||||
background: var(--surface-1, #fff);
|
||||
color: inherit;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.aksrt-ticket-reply-form__actions { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||
|
||||
.aksrt-ticket-field { display: grid; gap: 5px; }
|
||||
.aksrt-ticket-field > span { color: var(--tickets-muted); font-size: 12px; }
|
||||
.aksrt-ticket-images { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||
.aksrt-ticket-image { position: relative; width: 66px; height: 66px; border: 1px solid var(--tickets-line); border-radius: 8px; overflow: hidden; }
|
||||
.aksrt-ticket-image img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.aksrt-ticket-image__remove {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
right: 1px;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: rgb(0 0 0 / 55%);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -1,700 +0,0 @@
|
||||
/**
|
||||
* AKSRT After-Sale — 前台售后弹窗(申请 / 超时效提示 / 售后详细)
|
||||
*
|
||||
* 与「禁封/举报」链路同构(report.js / user-ban.js):
|
||||
* - [data-aksrt-after-sale-apply] → 申请售后弹窗(超时效则改为超时效提示弹窗)
|
||||
* - [data-aksrt-after-sale-detail] → 售后详细弹窗(可再次申请 / 不可再申请提示)
|
||||
* - [data-aksrt-after-sale-withdraw] → 撤销售后二次确认弹窗(仅待审核工单),确认后 DELETE 工单
|
||||
* 两个入口的 data-* 属性携带订单与售后状态载荷(同 report.js 的 data-aksrt-report),
|
||||
* 点击即同步弹窗,不做前置请求(避免首次点击无反馈)。
|
||||
* - 提交走 REST /aksrt/v1/orders/{order_number}/after-sale
|
||||
* - 撤销走 DELETE 同端点(仅待审核工单,后端置为已取消)
|
||||
* - 提交与撤销前先过人机验证(after_sale_apply / after_sale_cancel,场景未启用自动放行)
|
||||
* - 举证图片走 /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, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function colorfulHeader(title, icon) {
|
||||
return '<div class="modal-colorful-header colorful-bg jb-blue">' +
|
||||
'<button type="button" class="close" data-aksrt-modal-close aria-label="关闭"><svg class="ic-close" aria-hidden="true"><use xlink:href="#icon-close"></use></svg></button>' +
|
||||
'<div class="colorful-make"></div>' +
|
||||
'<div class="text-center"><div class="em2x"><i class="fa fas ' + escapeHtml(icon) + '" aria-hidden="true"></i></div>' +
|
||||
'<div class="mt10 em12 padding-w10">' + escapeHtml(title) + '</div></div></div>';
|
||||
}
|
||||
|
||||
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 orderSummaryHtml(order) {
|
||||
if (!order) return '';
|
||||
return '<div class="muted-box padding-10 mb20 em09 aksrt-after-sale-summary">' +
|
||||
'<div class="aksrt-after-sale-summary__row"><span class="muted-2-color">商品</span>' +
|
||||
'<span class="aksrt-after-sale-summary__value">' + escapeHtml(order.product_name || '订单') + '</span></div>' +
|
||||
'<div class="aksrt-after-sale-summary__row"><span class="muted-2-color">订单号</span>' +
|
||||
'<span class="aksrt-after-sale-summary__value">' + escapeHtml(order.order_number || '') + '</span></div>' +
|
||||
'<div class="aksrt-after-sale-summary__row"><span class="muted-2-color">实付</span>' +
|
||||
'<span class="aksrt-after-sale-summary__value">' + orderAmountText(order) + '</span></div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function buildApplyHtml(order) {
|
||||
var cfg = settings();
|
||||
var reasons = reasonOptions();
|
||||
var materials = materialOptions();
|
||||
|
||||
var html = '<div class="aksrt-after-sale-apply-modal">';
|
||||
html += colorfulHeader('申请售后', 'fa-undo-alt');
|
||||
html += '<form class="aksrt-after-sale-form" data-aksrt-after-sale-order="' + escapeHtml((order && order.order_number) || '') + '" novalidate>';
|
||||
// 滚动容器与禁封申诉弹窗一致:no-scrollbar 完全隐藏滚动条(含细滚动条)+ 50vh 内层滚动。
|
||||
html += '<div class="scroll-y no-scrollbar max-vh5">';
|
||||
html += orderSummaryHtml(order);
|
||||
|
||||
if (reasons.length) {
|
||||
html += '<div class="mb20">';
|
||||
html += '<div class="em09 muted-2-color mb6">售后原因</div>';
|
||||
html += '<div class="form-radio form-but-radio">';
|
||||
reasons.forEach(function (reason) {
|
||||
html += '<span class="mr6 mb6 badg p2-10 pointer" data-aksrt-after-sale-reason="' + escapeHtml(reason.name) + '" data-reason-slug="' + escapeHtml(reason.slug) + '">' + escapeHtml(reason.name) + '</span>';
|
||||
});
|
||||
html += '</div></div>';
|
||||
}
|
||||
|
||||
materials.forEach(function (label) {
|
||||
html += '<div class="mb10"><div class="muted-2-color mb6">' + escapeHtml(label) + '</div>' +
|
||||
'<input class="form-control" type="text" maxlength="120" data-aksrt-after-sale-material="' + escapeHtml(label) + '" placeholder="请输入' + escapeHtml(label) + '"></div>';
|
||||
});
|
||||
|
||||
var placeholder = String(cfg.reasonPlaceholder || '必填,请您详细填写申请原因');
|
||||
html += '<div class="mb10"><div class="muted-2-color mb6">申请说明</div>' +
|
||||
'<textarea class="form-control" name="reason" rows="3" maxlength="2000" required placeholder="' + escapeHtml(placeholder) + '"></textarea></div>';
|
||||
|
||||
if (cfg.imgEnabled) {
|
||||
html += '<div class="form-upload mb10">' +
|
||||
'<div class="em09 muted-2-color mb6">上传图片</div>' +
|
||||
'<div class="preview"><div class="add" data-aksrt-after-sale-add role="button" tabindex="0" aria-label="上传图片"></div></div>' +
|
||||
'<input class="hide" type="file" accept="image/gif,image/jpeg,image/jpg,image/png,image/webp" data-aksrt-after-sale-file>' +
|
||||
'<div class="muted-2-color em09 mt6">最多上传 ' + MAX_IMAGES + ' 张图片</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// 申请售后说明(后台 after_sale_desc,服务端已 wp_kses_post)。
|
||||
var desc = String(cfg.desc || '');
|
||||
if (desc) {
|
||||
html += '<div class="em09 padding-h10 muted-box mb10">' + desc + '</div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
html += '<div class="but-average modal-buts">' +
|
||||
'<button type="button" class="but" data-aksrt-modal-close>取消</button>' +
|
||||
'<button type="button" class="but c-blue" data-aksrt-after-sale-submit><i class="fa fas fa-check" aria-hidden="true"></i>确认提交</button>' +
|
||||
'</div>';
|
||||
html += '</form></div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function detailRow(label, value) {
|
||||
if (value === '' || value == null) return '';
|
||||
return '<div class="aksrt-after-sale-detail__row"><span class="muted-2-color">' + escapeHtml(label) + '</span>' +
|
||||
'<span class="aksrt-after-sale-detail__value">' + escapeHtml(value) + '</span></div>';
|
||||
}
|
||||
|
||||
// 处理记录节点标题:用户侧提交/撤脱离不开工单状态,单独措辞;
|
||||
// 后台提交直接用目标状态,与后台列表口径一致。
|
||||
function handlingEntryTitle(entry) {
|
||||
var status = String((entry && entry.status) || '');
|
||||
if (String((entry && entry.source) || '') === 'user') {
|
||||
return status === 'cancelled' ? '用户撤销售后' : '用户提交申请';
|
||||
}
|
||||
return STATUS_LABELS[status] || status;
|
||||
}
|
||||
|
||||
// 处理记录时间线:左列绳结状态条(节点 + 状态 + 时间),右列处理备注。
|
||||
// 数据源为 latest.handling_log(工单行内 JSON,按时间正序)。
|
||||
function handlingLogHtml(log) {
|
||||
var entries = Array.isArray(log) ? log : [];
|
||||
if (!entries.length) {
|
||||
return '<div class="aksrt-after-sale-log"><div class="aksrt-after-sale-log__empty">暂无处理记录</div></div>';
|
||||
}
|
||||
var html = '<div class="aksrt-after-sale-log"><div class="muted-2-color em09 mb6">处理记录</div>';
|
||||
html += '<ol class="aksrt-after-sale-timeline">';
|
||||
entries.forEach(function (entry) {
|
||||
var status = String((entry && entry.status) || '');
|
||||
var note = String((entry && entry.note) || '');
|
||||
html += '<li class="aksrt-after-sale-timeline__item is-' + escapeHtml(status) + '">';
|
||||
html += '<span class="aksrt-after-sale-timeline__knot"></span>';
|
||||
html += '<div class="aksrt-after-sale-timeline__body">';
|
||||
// [节点][处理状态][时间] 同一行,处理备注另起一行
|
||||
html += '<div class="aksrt-after-sale-timeline__head">' +
|
||||
'<span class="aksrt-after-sale-timeline__status">' + escapeHtml(handlingEntryTitle(entry)) + '</span>' +
|
||||
'<time class="aksrt-after-sale-timeline__time">' + escapeHtml(String((entry && entry.created_at) || '')) + '</time>' +
|
||||
'</div>';
|
||||
if (note !== '') {
|
||||
html += '<p class="aksrt-after-sale-timeline__note">' + escapeHtml(note).replace(/\r\n|\r|\n/g, '<br>') + '</p>';
|
||||
}
|
||||
html += '</div>';
|
||||
html += '</li>';
|
||||
});
|
||||
html += '</ol></div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function buildDetailHtml(order) {
|
||||
var afterSale = (order && order.after_sale) || {};
|
||||
var latest = afterSale.latest || {};
|
||||
var status = String(latest.status || '');
|
||||
|
||||
var html = '<div class="aksrt-after-sale-detail-modal">';
|
||||
html += colorfulHeader('售后详细', 'fa-clipboard-list');
|
||||
html += '<div class="scroll-y no-scrollbar max-vh5 aksrt-after-sale-detail__body">';
|
||||
html += orderSummaryHtml(order);
|
||||
|
||||
html += '<div class="aksrt-after-sale-detail__statusRow">' +
|
||||
'<span class="muted-2-color em09">售后状态</span>' +
|
||||
'<span class="aksrt-after-sale-detail__statusCell">' +
|
||||
'<span class="badg p2-10 aksrt-after-sale-status is-' + escapeHtml(status) + '">' + escapeHtml(STATUS_LABELS[status] || status) + '</span>' +
|
||||
// 仅「待审核」可撤销(后端 cancel 仅放行 pending):入口随状态徽章渲染。
|
||||
(status === 'pending'
|
||||
? '<button type="button" class="aksrt-after-sale-withdraw" data-aksrt-after-sale-withdraw="' + escapeHtml((order && order.order_number) || '') + '">撤销售后<i class="fa fas fa-angle-right" aria-hidden="true"></i></button>'
|
||||
: '') +
|
||||
'</span></div>';
|
||||
|
||||
html += '<div class="aksrt-after-sale-detail__rows">';
|
||||
html += detailRow('申请时间', String(latest.created_at || ''));
|
||||
html += '</div>';
|
||||
// 处理记录:每次后台提交一条,替代原先单行的「处理备注 / 完成时间」。
|
||||
html += handlingLogHtml(latest.handling_log);
|
||||
|
||||
// 申请表单分行展示:预置原因 / 申请资料 / 申请说明各自独立成行,
|
||||
// 与上方「申请时间」等明细行同格式;老数据无 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 += '<div class="mb10"><div class="muted-2-color em09 mb6">申请原因</div>' +
|
||||
'<div class="em09 aksrt-after-sale-reason-text">' + escapeHtml(latest.reason).replace(/\r\n|\r|\n/g, '<br>') + '</div></div>';
|
||||
}
|
||||
|
||||
var images = Array.isArray(latest.images) ? latest.images : [];
|
||||
if (images.length) {
|
||||
html += '<div class="mb10"><div class="muted-2-color em09 mb6">举证图片</div><div class="aksrt-after-sale-images">';
|
||||
images.forEach(function (url) {
|
||||
html += '<a href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer"><img src="' + escapeHtml(url) + '" alt=""></a>';
|
||||
});
|
||||
html += '</div></div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
|
||||
if (afterSale.can_reapply) {
|
||||
// 可再次申请:仅保留单个主按钮(与禁封申诉弹窗单按钮同式)。
|
||||
html += '<div class="but-average modal-buts">' +
|
||||
'<button type="button" class="but c-blue" data-aksrt-after-sale-reapply data-aksrt-after-sale-payload="' + escapeHtml(JSON.stringify(order)) + '"><i class="fa fas fa-undo-alt" aria-hidden="true"></i>再次申请售后</button>' +
|
||||
'</div>';
|
||||
} else {
|
||||
// 不可再申请售后:不再展示阻断文案,仅保留关闭入口。
|
||||
html += '<div class="but-average modal-buts"><button type="button" class="but c-blue" data-aksrt-modal-close>我知道了</button></div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function buildExpiredHtml() {
|
||||
var text = String(settings().expiredText || '抱歉,您的订单已超过售后申请时效');
|
||||
var html = '<div class="aksrt-after-sale-expired-modal">';
|
||||
html += colorfulHeader('售后提示', 'fa-clock');
|
||||
// 超过时效提示(后台 after_sale_expired_text,服务端已 wp_kses_post,支持 HTML)。
|
||||
html += '<div class="aksrt-after-sale-expired__body em09">' + text + '</div>';
|
||||
html += '<div class="but-average modal-buts"><button type="button" class="but c-blue" data-aksrt-modal-close>我知道了</button></div>';
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
// 撤销售后二次确认弹窗:待审核工单入口点入,确认后 DELETE 售后工单。
|
||||
function buildWithdrawHtml(orderNumber) {
|
||||
var html = '<div class="aksrt-after-sale-withdraw-modal">';
|
||||
html += colorfulHeader('撤销售后', 'fa-undo-alt');
|
||||
html += '<div class="aksrt-after-sale-withdraw__body em09">撤销后本次售后申请将关闭,订单恢复为可再次申请售后的状态。确定撤销吗?</div>';
|
||||
html += '<div class="but-average modal-buts">' +
|
||||
'<button type="button" class="but" data-aksrt-modal-close>取消</button>' +
|
||||
'<button type="button" class="but c-blue" data-aksrt-after-sale-withdraw-confirm="' + escapeHtml(orderNumber) + '">确认撤销</button>' +
|
||||
'</div>';
|
||||
html += '</div>';
|
||||
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',
|
||||
});
|
||||
}
|
||||
|
||||
function openWithdraw(orderNumber) {
|
||||
if (!orderNumber) return;
|
||||
return openModal({
|
||||
html: buildWithdrawHtml(orderNumber),
|
||||
className: 'aksrt-after-sale-withdraw-modal',
|
||||
kind: 'after-sale-withdraw',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析按钮 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 clearToast(id) {
|
||||
var toast = document.getElementById(id);
|
||||
if (toast) toast.remove();
|
||||
}
|
||||
|
||||
// 提交前的人机验证:场景未启用时无挂载点,run 直接 resolve {};
|
||||
// 用户中途取消验证时 run resolve null,此时还原按钮并中止提交。
|
||||
function runCaptcha(scene) {
|
||||
if (!window.AKSRTCaptcha || typeof window.AKSRTCaptcha.run !== 'function') {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
return window.AKSRTCaptcha.run(scene).then(function (payload) {
|
||||
return payload === null ? Promise.reject({ cancelled: true }) : (payload || {});
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
var restoreButton = function () {
|
||||
if (submitBtn) submitBtn.disabled = false;
|
||||
};
|
||||
var sendApply = function (captchaPayload) {
|
||||
window.fetch(API_ROOT + '/orders/' + encodeURIComponent(orderNumber) + '/after-sale', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': NONCE,
|
||||
},
|
||||
body: JSON.stringify(Object.assign({
|
||||
reason: composeReason(shell),
|
||||
reason_label: selectedReasonLabel(shell),
|
||||
materials: collectMaterials(shell),
|
||||
description: reasonText,
|
||||
images: collectImages(shell),
|
||||
}, captchaPayload)),
|
||||
})
|
||||
.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 {
|
||||
restoreButton();
|
||||
notyf((result && result.message) || '售后申请提交失败', 'danger');
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
restoreButton();
|
||||
notyf('网络错误,请重试', 'danger');
|
||||
})
|
||||
.then(function () { clearToast('aksrt-after-sale-submit'); });
|
||||
};
|
||||
|
||||
runCaptcha('after_sale_apply').then(sendApply).catch(function (error) {
|
||||
restoreButton();
|
||||
clearToast('aksrt-after-sale-submit');
|
||||
if (error && error.cancelled) return;
|
||||
notyf('人机验证失败,请重试', 'danger');
|
||||
});
|
||||
}
|
||||
|
||||
// 撤销售后:待审核工单置为已取消(DELETE 售后工单),成功后刷新订单列表与统计。
|
||||
function submitWithdraw(shell, button) {
|
||||
var orderNumber = button.getAttribute('data-aksrt-after-sale-withdraw-confirm') || '';
|
||||
if (!orderNumber || button.disabled) return;
|
||||
button.disabled = true;
|
||||
notyf('正在撤销售后申请…', 'load', 3000, 'aksrt-after-sale-withdraw');
|
||||
|
||||
var restoreButton = function () {
|
||||
button.disabled = false;
|
||||
};
|
||||
var sendWithdraw = function (captchaPayload) {
|
||||
window.fetch(API_ROOT + '/orders/' + encodeURIComponent(orderNumber) + '/after-sale', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': NONCE,
|
||||
},
|
||||
body: JSON.stringify(captchaPayload || {}),
|
||||
})
|
||||
.then(function (res) { return res.json(); })
|
||||
.then(function (result) {
|
||||
if (result && result.success) {
|
||||
window.AksrtModalRuntime.close('withdraw');
|
||||
notyf('售后申请已撤销', 'success');
|
||||
if (window.AKSRTOrderCenter && typeof window.AKSRTOrderCenter.refresh === 'function') {
|
||||
window.AKSRTOrderCenter.refresh(orderNumber);
|
||||
}
|
||||
} else {
|
||||
restoreButton();
|
||||
notyf((result && result.message) || '撤销失败,请重试', 'danger');
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
restoreButton();
|
||||
notyf('网络错误,请重试', 'danger');
|
||||
})
|
||||
.then(function () { clearToast('aksrt-after-sale-withdraw'); });
|
||||
};
|
||||
|
||||
runCaptcha('after_sale_cancel').then(sendWithdraw).catch(function (error) {
|
||||
restoreButton();
|
||||
clearToast('aksrt-after-sale-withdraw');
|
||||
if (error && error.cancelled) return;
|
||||
notyf('人机验证失败,请重试', 'danger');
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 再次申请售后:载荷已随按钮渲染。can_reapply 必然伴随 has_application=true,
|
||||
// 直接走 openApplyFlow 会被 has_application 分流回「售后详细」打转;
|
||||
// 清除标记落回申请表单分支(expired 超时效拦截仍然生效)。
|
||||
var reapplyBtn = event.target.closest('[data-aksrt-after-sale-reapply]');
|
||||
if (reapplyBtn) {
|
||||
event.preventDefault();
|
||||
var payload = parsePayload(reapplyBtn.getAttribute('data-aksrt-after-sale-payload'));
|
||||
if (payload && payload.after_sale) payload.after_sale.has_application = false;
|
||||
window.AksrtModalRuntime.close('replace');
|
||||
openApplyFlow(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
// 撤销售后:入口在售后详细弹窗状态行(仅待审核),点击先弹二次确认。
|
||||
var withdrawBtn = event.target.closest('[data-aksrt-after-sale-withdraw]');
|
||||
if (withdrawBtn) {
|
||||
event.preventDefault();
|
||||
openWithdraw(withdrawBtn.getAttribute('data-aksrt-after-sale-withdraw') || '');
|
||||
return;
|
||||
}
|
||||
|
||||
var withdrawConfirm = event.target.closest('[data-aksrt-after-sale-withdraw-confirm]');
|
||||
if (withdrawConfirm) {
|
||||
event.preventDefault();
|
||||
submitWithdraw(shell, withdrawConfirm);
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
})();
|
||||
@@ -124,7 +124,7 @@
|
||||
var meta = statusMeta(status);
|
||||
var timeLeft = Number(order.time_left || 0);
|
||||
var html = "";
|
||||
// 炫彩标题区:与售后弹窗统一(modal-mini + jb-blue),运行时不再渲染默认标题栏。
|
||||
// 炫彩标题区(modal-mini + jb-blue),运行时不再渲染默认标题栏。
|
||||
html += colorfulHeader("订单详情", "fa-receipt");
|
||||
|
||||
// 状态横幅:整块配色只由 --hero-accent 驱动,换状态只改一处变量。
|
||||
@@ -164,59 +164,6 @@
|
||||
});
|
||||
html += "</dl></section>";
|
||||
|
||||
var afterSales = Array.isArray(order.after_sales) ? order.after_sales : [];
|
||||
if (afterSales.length) {
|
||||
html += '<section class="aksrt-order-detail__group"><h4 class="aksrt-order-detail__group-title">售后记录</h4><ul class="aksrt-order-detail__after-sales">';
|
||||
afterSales.forEach(function (item, index) {
|
||||
var labels = { pending: "待审核", processing: "处理中", approved: "已通过", rejected: "已拒绝", cancelled: "已取消", completed: "已完成" };
|
||||
var asStatus = String(item.status || "");
|
||||
var asLabel = labels[asStatus] || asStatus;
|
||||
html += '<li class="aksrt-order-detail__after-sale is-' + escapeHtml(asStatus) + '">';
|
||||
html += '<div class="aksrt-order-detail__after-sale-head"><span class="aksrt-order-detail__after-sale-badge is-' + escapeHtml(asStatus) + '">' + escapeHtml(asLabel) + "</span>";
|
||||
if (item.created_at) html += "<time>" + escapeHtml(item.created_at) + "</time>";
|
||||
html += "</div>";
|
||||
// 原因 / 申请理由:新数据走 apply_form 结构化字段;老数据无 apply_form 时兜底展示融合串。
|
||||
var applyForm = item.apply_form && typeof item.apply_form === "object" ? item.apply_form : null;
|
||||
if (applyForm) {
|
||||
var reasonLabel = String(applyForm.reason_label || "");
|
||||
var applyDesc = String(applyForm.description || "");
|
||||
if (reasonLabel !== "") html += '<p class="aksrt-order-detail__after-sale-reason"><span class="aksrt-order-detail__after-sale-field">原因:</span>' + escapeHtml(reasonLabel) + "</p>";
|
||||
if (applyDesc !== "") html += '<p class="aksrt-order-detail__after-sale-reason"><span class="aksrt-order-detail__after-sale-field">申请理由:</span>' + escapeHtml(applyDesc).replace(/\r\n|\r|\n/g, "<br>") + "</p>";
|
||||
} else if (item.reason) {
|
||||
html += '<p class="aksrt-order-detail__after-sale-reason">' + escapeHtml(item.reason).replace(/\r\n|\r|\n/g, "<br>") + "</p>";
|
||||
}
|
||||
// 查看详细:逐记录构造售后详细弹窗载荷(after_sale.latest 指向本条记录);
|
||||
// 再次申请仅对最新一条且订单级可再申请时开放(after_sales 按 id 倒序,首条即最新)。
|
||||
var recordPayload = 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: {
|
||||
enabled: true,
|
||||
has_application: true,
|
||||
can_reapply: index === 0 && !!((order.after_sale || {}).can_reapply),
|
||||
latest: {
|
||||
status: asStatus,
|
||||
reason: String(item.reason || ""),
|
||||
apply_form: applyForm,
|
||||
images: Array.isArray(item.images) ? item.images : [],
|
||||
refund_amount: String(item.refund_amount || "0"),
|
||||
created_at: String(item.created_at || ""),
|
||||
handling_log: Array.isArray(item.handling_log) ? item.handling_log : [],
|
||||
},
|
||||
},
|
||||
}));
|
||||
html += '<div class="aksrt-order-detail__after-sale-foot">';
|
||||
html += '<button class="btn-ghost" type="button" data-aksrt-after-sale-detail="' + recordPayload + '">查看详细</button>';
|
||||
html += "</div>";
|
||||
html += "</li>";
|
||||
});
|
||||
html += "</ul></section>";
|
||||
}
|
||||
|
||||
if (reconciliation.state === "refresh_available") {
|
||||
html += '<div class="aksrt-order-detail__refresh-state" role="status">';
|
||||
html += '<i class="fa fas fa-info-circle" aria-hidden="true"></i>';
|
||||
@@ -225,17 +172,12 @@
|
||||
html += "</div>";
|
||||
}
|
||||
|
||||
// 底部按钮与售后详细弹窗同式:but-average modal-buts;「关闭」与「我知道了」
|
||||
// 底部按钮:but-average modal-buts;「关闭」与「我知道了」
|
||||
// 同款(but c-blue),平铺观感由 orders.css 的 footer 按钮规则统一呈现。
|
||||
html += '<footer class="aksrt-order-detail__actions but-average modal-buts">';
|
||||
html += '<button type="button" class="but c-blue" data-aksrt-modal-close>关闭</button>';
|
||||
if (allowedActions.can_cancel) html += '<button type="button" class="but" data-aksrt-order-cancel="' + escapeHtml(order.order_number) + '">关闭订单</button>';
|
||||
if (allowedActions.can_pay) html += '<button type="button" class="but c-blue" data-aksrt-order-pay="' + escapeHtml(order.order_number) + '" data-aksrt-order-method="' + escapeHtml(paymentMethod.value || "") + '">继续支付</button>';
|
||||
// 售后入口:未申请时底部保留「申请售后」;已有申请时入口收拢到售后记录卡片的「查看详细」。
|
||||
var detailAfterSale = order.after_sale || {};
|
||||
if (detailAfterSale.enabled && !detailAfterSale.has_application) {
|
||||
html += '<button type="button" class="but c-blue" data-aksrt-after-sale-apply="' + afterSalePayload(order) + '">申请售后</button>';
|
||||
}
|
||||
html += "</footer>";
|
||||
|
||||
session.content.innerHTML = html;
|
||||
@@ -310,42 +252,12 @@
|
||||
|
||||
function actionHtml(order) {
|
||||
var allowedActions = order.allowed_actions || {};
|
||||
var afterSale = order.after_sale || {};
|
||||
var html = '<button class="btn-outline" type="button" data-aksrt-order-detail="' + escapeHtml(order.order_number) + '">订单详情</button>';
|
||||
if (allowedActions.can_cancel) html += '<button class="btn-ghost" type="button" data-aksrt-order-cancel="' + escapeHtml(order.order_number) + '">关闭订单</button>';
|
||||
if (allowedActions.can_pay) html += '<button class="btn-primary" type="button" data-aksrt-order-pay="' + escapeHtml(order.order_number) + '" data-aksrt-order-method="' + escapeHtml((order.payment_method || {}).value || "") + '">继续支付</button>';
|
||||
// 售后入口:申请售后 / 售后详细走 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 += '<button class="btn-outline" type="button" data-aksrt-after-sale-detail="' + payload + '">售后详细</button>';
|
||||
return html;
|
||||
}
|
||||
html += '<button class="btn-outline" type="button" data-aksrt-after-sale-apply="' + payload + '">申请售后</button>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function cardHtml(order) {
|
||||
var image = order.image
|
||||
@@ -634,7 +546,6 @@
|
||||
state.detailReplacementSession = null;
|
||||
};
|
||||
state.onDocumentClick = function (event) {
|
||||
// 取消/撤销售后已移入售后详细弹窗(order-after-sale.js 的「撤销售后」入口)。
|
||||
if (!state.detailSession || window.AksrtModalRuntime.getActive() !== state.detailSession.shell) return;
|
||||
var copy = closest(event.target, "[data-aksrt-order-copy]");
|
||||
if (copy) {
|
||||
@@ -697,7 +608,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
// refresh 供售后弹窗(order-after-sale.js)提交/取消后刷新列表与统计。
|
||||
// refresh 供弹窗提交/取消后刷新列表与统计。
|
||||
function refresh(orderNumber) {
|
||||
invalidateOrder(orderNumber);
|
||||
liveStates.forEach(function (state) {
|
||||
|
||||
@@ -0,0 +1,638 @@
|
||||
/**
|
||||
* AKSRT Ticket Center — 用户中心「工单中心」面板。
|
||||
*
|
||||
* 与「举报」链路同构(report.js):
|
||||
* - 列表 / 统计 / 详情全部走 REST,模板只输出容器(profile-tickets.php)
|
||||
* - 弹窗统一经 AksrtModalRuntime.open 直出 HTML:桌面 modal-mini(400px)
|
||||
* + 蓝色炫彩标题区,移动端由 mobileBottom 底部抽屉接管
|
||||
* - 提交工单与回复前先过人机验证(ticket_create / ticket_reply,场景未启用自动放行)
|
||||
* - 举证图片走 /aksrt/v1/ticket/upload-attachment(复用评论图片上传的大小与类型校验)
|
||||
*
|
||||
* @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 state = {
|
||||
page: 1,
|
||||
perPage: 10,
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
status: '',
|
||||
type: '',
|
||||
keywords: '',
|
||||
meta: null,
|
||||
attachmentMax: 5,
|
||||
};
|
||||
|
||||
function settings() {
|
||||
var d = window.aksrtData && window.aksrtData.ticket;
|
||||
return d && typeof d === 'object' ? d : {};
|
||||
}
|
||||
|
||||
function notyf(text, type, timeout, id) {
|
||||
if (typeof window.notyf === 'function') {
|
||||
return window.notyf(text, type, timeout, id);
|
||||
}
|
||||
if (typeof window.toast === 'function') {
|
||||
return window.toast(text, type);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value === null || value === undefined ? '' : value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function request(path, options) {
|
||||
var opts = options || {};
|
||||
return fetch(API_ROOT + path, {
|
||||
method: opts.method || 'GET',
|
||||
headers: Object.assign({ 'X-WP-Nonce': NONCE }, opts.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
||||
credentials: 'same-origin',
|
||||
}).then(function (res) { return res.json(); });
|
||||
}
|
||||
|
||||
// 提交前的人机验证:场景未启用时无挂载点,run 直接 resolve {};
|
||||
// 用户中途取消验证时 run resolve null,此时 reject {cancelled:true} 中止提交。
|
||||
function runCaptcha(scene) {
|
||||
if (!window.AKSRTCaptcha || typeof window.AKSRTCaptcha.run !== 'function') {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
return window.AKSRTCaptcha.run(scene).then(function (payload) {
|
||||
return payload === null ? Promise.reject({ cancelled: true }) : (payload || {});
|
||||
});
|
||||
}
|
||||
|
||||
function openModal(options) {
|
||||
if (!window.AksrtModalRuntime || typeof window.AksrtModalRuntime.open !== 'function') {
|
||||
notyf('工单组件加载失败,请刷新页面后重试', 'danger');
|
||||
return null;
|
||||
}
|
||||
return window.AksrtModalRuntime.open(options);
|
||||
}
|
||||
|
||||
function closeModal(reason) {
|
||||
if (window.AksrtModalRuntime && typeof window.AksrtModalRuntime.close === 'function') {
|
||||
window.AksrtModalRuntime.close(reason);
|
||||
}
|
||||
}
|
||||
|
||||
function statusBadge(status, color, label) {
|
||||
return '<span class="aksrt-ticket-badge" style="--aksrt-ticket-color:' + escapeHtml(color || '#6b7280') + '">'
|
||||
+ escapeHtml(label || status) + '</span>';
|
||||
}
|
||||
|
||||
function attachmentPreview(images) {
|
||||
if (!images || !images.length) return '';
|
||||
var html = '<div class="aksrt-ticket-attachments">';
|
||||
images.forEach(function (url) {
|
||||
html += '<a class="aksrt-ticket-attachment" href="' + escapeHtml(url) + '" target="_blank" rel="noopener">'
|
||||
+ '<img src="' + escapeHtml(url) + '" alt="">'
|
||||
+ '</a>';
|
||||
});
|
||||
return html + '</div>';
|
||||
}
|
||||
|
||||
function quoteBlock(quote) {
|
||||
if (!quote) return '';
|
||||
var text = quote.is_deleted ? '原回复已删除' : (quote.content || '');
|
||||
return '<blockquote class="aksrt-ticket-quote">'
|
||||
+ '<strong>' + escapeHtml(quote.author_name || '') + '</strong> '
|
||||
+ escapeHtml(text) + '</blockquote>';
|
||||
}
|
||||
|
||||
function modalShell(title, body, footer) {
|
||||
return '<div class="aksrt-modal-colorful-header modal-colorful-header colorful-bg jb-blue">'
|
||||
+ '<h3>' + escapeHtml(title) + '</h3>'
|
||||
+ '<button type="button" class="close" data-aksrt-modal-close aria-label="关闭">'
|
||||
+ '<svg class="ic-close" aria-hidden="true"><use xlink:href="#icon-close"></use></svg></button>'
|
||||
+ '</div>'
|
||||
+ '<div class="aksrt-modal-runtime__body">' + body + '</div>'
|
||||
+ '<div class="but-average modal-buts">' + (footer || '') + '</div>';
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 列表 / 统计
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function loadMeta() {
|
||||
if (state.meta) return Promise.resolve(state.meta);
|
||||
return request('/tickets/meta').then(function (result) {
|
||||
state.meta = (result && result.data) || {};
|
||||
if (state.meta.attachment) {
|
||||
state.attachmentMax = state.meta.attachment.max || 5;
|
||||
}
|
||||
return state.meta;
|
||||
});
|
||||
}
|
||||
|
||||
function loadList() {
|
||||
var panel = document.querySelector('[data-aksrt-ticket-center]');
|
||||
if (!panel) return;
|
||||
var list = panel.querySelector('[data-aksrt-ticket-list]');
|
||||
if (!list) return;
|
||||
|
||||
var params = 'page=' + state.page + '&per_page=' + state.perPage;
|
||||
if (state.status) params += '&status=' + encodeURIComponent(state.status);
|
||||
if (state.type) params += '&ticket_type_id=' + encodeURIComponent(state.type);
|
||||
if (state.keywords) params += '&keywords=' + encodeURIComponent(state.keywords);
|
||||
|
||||
request('/tickets?' + params).then(function (result) {
|
||||
var data = (result && result.data) || {};
|
||||
state.total = data.total || 0;
|
||||
state.totalPages = data.total_pages || 1;
|
||||
renderList(Array.isArray(data.items) ? data.items : []);
|
||||
renderPagination();
|
||||
}).catch(function () {
|
||||
notyf('工单列表加载失败', 'danger');
|
||||
});
|
||||
}
|
||||
|
||||
function renderList(items) {
|
||||
var list = document.querySelector('[data-aksrt-ticket-list]');
|
||||
if (!list) return;
|
||||
if (!items.length) {
|
||||
list.innerHTML = '<div class="aksrt-tickets-page__empty">'
|
||||
+ '<i class="fa fas fa-headset" aria-hidden="true"></i>'
|
||||
+ '<h3>还没有工单</h3>'
|
||||
+ '<p>遇到问题时提交工单,我们会尽快处理。</p></div>';
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
items.forEach(function (ticket) {
|
||||
html += '<article class="aksrt-tickets-page__card is-' + escapeHtml(ticket.status) + '" data-aksrt-ticket-id="' + escapeHtml(ticket.id) + '">'
|
||||
+ '<div class="aksrt-tickets-page__card-head">'
|
||||
+ '<span class="aksrt-tickets-page__card-num">#' + escapeHtml(ticket.ticket_num) + '</span>'
|
||||
+ statusBadge(ticket.status, ticket.status_color, ticket.status_label)
|
||||
+ '</div>'
|
||||
+ '<h3 class="aksrt-tickets-page__card-title">' + escapeHtml(ticket.title) + '</h3>'
|
||||
+ '<div class="aksrt-tickets-page__card-meta">'
|
||||
+ '<span>' + escapeHtml(ticket.ticket_type_name) + '</span>'
|
||||
+ '<time>' + escapeHtml(ticket.created_at) + '</time>'
|
||||
+ (ticket.order_number ? '<span>订单 ' + escapeHtml(ticket.order_number) + '</span>' : '')
|
||||
+ '</div>'
|
||||
+ '<footer class="aksrt-tickets-page__card-actions">'
|
||||
+ '<button class="btn-outline" type="button" data-aksrt-ticket-view="' + escapeHtml(ticket.id) + '">查看详情</button>'
|
||||
+ '</footer>'
|
||||
+ '</article>';
|
||||
});
|
||||
list.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderPagination() {
|
||||
var nav = document.querySelector('[data-aksrt-ticket-pagination]');
|
||||
if (!nav) return;
|
||||
if (state.totalPages <= 1) {
|
||||
nav.hidden = true;
|
||||
nav.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
nav.hidden = false;
|
||||
var html = '';
|
||||
for (var i = 1; i <= state.totalPages; i++) {
|
||||
html += '<button class="aksrt-tickets-page__page-chip' + (i === state.page ? ' is-active' : '') + '" type="button"'
|
||||
+ ' data-aksrt-ticket-page="' + i + '"' + (i === state.page ? ' aria-current="page"' : '') + '>' + i + '</button>';
|
||||
}
|
||||
nav.innerHTML = html;
|
||||
}
|
||||
|
||||
function loadStatistic() {
|
||||
request('/tickets/statistic').then(function (result) {
|
||||
var data = (result && result.data) || {};
|
||||
Object.keys(data).forEach(function (key) {
|
||||
var node = document.querySelector('[data-aksrt-ticket-stat="' + key + '"] strong');
|
||||
if (node) node.textContent = String(data[key]);
|
||||
});
|
||||
}).catch(function () { /* 统计失败不阻塞列表 */ });
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 详情弹窗
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function openDetail(ticketId) {
|
||||
request('/tickets/' + ticketId).then(function (result) {
|
||||
var ticket = (result && result.data) || null;
|
||||
if (!ticket) {
|
||||
notyf('工单不存在', 'danger');
|
||||
return;
|
||||
}
|
||||
var shell = openModal({
|
||||
html: modalShell('#' + ticket.ticket_num, buildDetailHtml(ticket)),
|
||||
className: 'aksrt-ticket-detail-modal',
|
||||
kind: 'ticket-detail',
|
||||
mobileBottom: true,
|
||||
});
|
||||
if (shell) bindDetail(shell, ticket);
|
||||
}).catch(function () {
|
||||
notyf('工单详情加载失败', 'danger');
|
||||
});
|
||||
}
|
||||
|
||||
function buildDetailHtml(ticket) {
|
||||
var html = '<div class="aksrt-ticket-detail" data-aksrt-ticket-detail-id="' + escapeHtml(ticket.id) + '">';
|
||||
html += '<div class="aksrt-ticket-detail__head">'
|
||||
+ statusBadge(ticket.status, ticket.status_color, ticket.status_label)
|
||||
+ '<span class="aksrt-ticket-detail__type">' + escapeHtml(ticket.ticket_type_name) + '</span>'
|
||||
+ '</div>';
|
||||
html += '<h4 class="aksrt-ticket-detail__title">' + escapeHtml(ticket.title) + '</h4>';
|
||||
if (ticket.order_number) {
|
||||
html += '<p class="aksrt-ticket-detail__order">关联订单:' + escapeHtml(ticket.order_number) + '</p>';
|
||||
}
|
||||
html += '<div class="aksrt-ticket-detail__content">' + escapeHtml(ticket.content).replace(/\r\n|\r|\n/g, '<br>') + '</div>';
|
||||
html += attachmentPreview(ticket.attachment);
|
||||
|
||||
html += '<div class="aksrt-ticket-detail__replies">';
|
||||
(ticket.replies || []).forEach(function (reply) {
|
||||
var who = reply.author_type === 'admin' ? '管理员' : '我';
|
||||
html += '<div class="aksrt-ticket-detail__reply is-' + escapeHtml(reply.author_type) + '">'
|
||||
+ '<div class="aksrt-ticket-detail__reply-head"><strong>' + escapeHtml(reply.author_name || who) + '</strong>'
|
||||
+ '<time>' + escapeHtml(reply.created_at) + '</time></div>'
|
||||
+ quoteBlock(reply.quote)
|
||||
+ '<div class="aksrt-ticket-detail__reply-body">' + escapeHtml(reply.content).replace(/\r\n|\r|\n/g, '<br>') + '</div>'
|
||||
+ attachmentPreview(reply.attachment)
|
||||
+ '<button class="aksrt-ticket-detail__quote-btn" type="button" data-aksrt-ticket-quote="' + escapeHtml(reply.id) + '">引用</button>'
|
||||
+ '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
|
||||
if (ticket.is_closed) {
|
||||
html += '<p class="aksrt-ticket-detail__closed">工单已关闭,如需继续沟通请提交新工单。</p>';
|
||||
return html + '</div>';
|
||||
}
|
||||
|
||||
html += '<form class="aksrt-ticket-reply-form" data-aksrt-ticket-reply-form novalidate>';
|
||||
html += '<input type="hidden" name="quote_reply_id" value="" data-aksrt-ticket-quote-id>';
|
||||
html += '<textarea name="content" rows="3" maxlength="3000" placeholder="填写回复内容"></textarea>';
|
||||
html += '<div class="aksrt-ticket-reply-form__actions">';
|
||||
if (settings().attachmentEnabled) {
|
||||
html += '<button type="button" class="btn-ghost" data-aksrt-ticket-upload>上传图片</button>';
|
||||
}
|
||||
html += '<button type="submit" class="btn-primary">提交回复</button>';
|
||||
html += '<button type="button" class="btn-outline" data-aksrt-ticket-urge>催单</button>';
|
||||
html += '<button type="button" class="btn-ghost" data-aksrt-ticket-close>关闭工单</button>';
|
||||
html += '</div></form>';
|
||||
return html + '</div>';
|
||||
}
|
||||
|
||||
function bindDetail(shell, ticket) {
|
||||
var form = shell.querySelector('[data-aksrt-ticket-reply-form]');
|
||||
var quoteField = shell.querySelector('[data-aksrt-ticket-quote-id]');
|
||||
var fileInput = null;
|
||||
|
||||
if (fileInput === null) {
|
||||
fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = 'image/gif,image/jpeg,image/jpg,image/png,image/webp';
|
||||
fileInput.hidden = true;
|
||||
shell.appendChild(fileInput);
|
||||
}
|
||||
|
||||
shell.addEventListener('click', function (event) {
|
||||
var quoteBtn = event.target.closest('[data-aksrt-ticket-quote]');
|
||||
if (quoteBtn) {
|
||||
if (quoteField) quoteField.value = quoteBtn.getAttribute('data-aksrt-ticket-quote') || '';
|
||||
var textarea = shell.querySelector('[data-aksrt-ticket-reply-form] textarea');
|
||||
if (textarea) textarea.focus();
|
||||
return;
|
||||
}
|
||||
if (event.target.closest('[data-aksrt-ticket-urge]')) {
|
||||
sendUrge(ticket);
|
||||
return;
|
||||
}
|
||||
if (event.target.closest('[data-aksrt-ticket-close]')) {
|
||||
sendClose(ticket);
|
||||
return;
|
||||
}
|
||||
if (event.target.closest('[data-aksrt-ticket-upload]')) {
|
||||
fileInput.click();
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.addEventListener('change', function () {
|
||||
var file = fileInput.files && fileInput.files[0];
|
||||
if (!file) return;
|
||||
uploadAttachment(file, shell, function () {
|
||||
fileInput.value = '';
|
||||
});
|
||||
});
|
||||
|
||||
if (form) {
|
||||
form.addEventListener('submit', function (event) {
|
||||
event.preventDefault();
|
||||
var textarea = form.querySelector('textarea');
|
||||
var content = (textarea && textarea.value || '').trim();
|
||||
if (!content) {
|
||||
notyf('请填写回复内容', 'danger');
|
||||
return;
|
||||
}
|
||||
runCaptcha('ticket_reply').then(function () {
|
||||
sendReply(ticket, content, quoteField ? quoteField.value : '', collectAttachments(shell));
|
||||
}).catch(function (error) {
|
||||
if (error && error.cancelled) return;
|
||||
notyf('人机验证未通过', 'danger');
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function collectAttachments(shell) {
|
||||
var out = [];
|
||||
shell.querySelectorAll('[data-aksrt-ticket-image]').forEach(function (node) {
|
||||
var url = node.getAttribute('data-aksrt-ticket-image');
|
||||
if (url && out.length < state.attachmentMax) out.push(url);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function sendReply(ticket, content, quoteReplyId, attachment) {
|
||||
request('/tickets/' + ticket.id + '/reply', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
content: content,
|
||||
quote_reply_id: quoteReplyId ? parseInt(quoteReplyId, 10) : 0,
|
||||
attachment: attachment,
|
||||
},
|
||||
}).then(function (result) {
|
||||
if (result && result.success) {
|
||||
closeModal('submit');
|
||||
notyf('回复已提交', 'success');
|
||||
loadList();
|
||||
loadStatistic();
|
||||
} else {
|
||||
notyf((result && result.message) || '回复提交失败', 'danger');
|
||||
}
|
||||
}).catch(function () {
|
||||
notyf('网络错误,请重试', 'danger');
|
||||
});
|
||||
}
|
||||
|
||||
function sendUrge(ticket) {
|
||||
request('/tickets/' + ticket.id + '/urge', { method: 'PUT' }).then(function (result) {
|
||||
if (result && result.success) {
|
||||
notyf('已催单,请耐心等待', 'success');
|
||||
} else {
|
||||
notyf((result && result.message) || '催单失败', 'danger');
|
||||
}
|
||||
}).catch(function () {
|
||||
notyf('网络错误,请重试', 'danger');
|
||||
});
|
||||
}
|
||||
|
||||
function sendClose(ticket) {
|
||||
if (!window.confirm('确认关闭该工单?关闭后无法继续回复。')) return;
|
||||
request('/tickets/' + ticket.id + '/close', { method: 'PUT' }).then(function (result) {
|
||||
if (result && result.success) {
|
||||
closeModal('submit');
|
||||
notyf('工单已关闭', 'success');
|
||||
loadList();
|
||||
loadStatistic();
|
||||
} else {
|
||||
notyf((result && result.message) || '关闭失败', 'danger');
|
||||
}
|
||||
}).catch(function () {
|
||||
notyf('网络错误,请重试', 'danger');
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 新建工单弹窗
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function openCreate() {
|
||||
loadMeta().then(function (meta) {
|
||||
var shell = openModal({
|
||||
html: modalShell('提交工单', buildCreateHtml(meta)),
|
||||
className: 'aksrt-ticket-create-modal',
|
||||
kind: 'ticket-create',
|
||||
mobileBottom: true,
|
||||
});
|
||||
if (shell) bindCreate(shell);
|
||||
});
|
||||
}
|
||||
|
||||
function buildCreateHtml(meta) {
|
||||
var types = meta.types || [];
|
||||
var orders = meta.orders || [];
|
||||
var attachmentEnabled = meta.attachment && meta.attachment.enabled;
|
||||
|
||||
var html = '<form class="aksrt-ticket-create-form" data-aksrt-ticket-create-form novalidate>';
|
||||
html += '<label class="aksrt-ticket-field"><span>工单标题</span>'
|
||||
+ '<input type="text" name="title" maxlength="200" placeholder="简要描述问题"></label>';
|
||||
|
||||
html += '<label class="aksrt-ticket-field"><span>工单部门</span><select name="ticket_type_id">';
|
||||
html += '<option value="">请选择部门</option>';
|
||||
types.forEach(function (type) {
|
||||
html += '<option value="' + escapeHtml(type.id) + '">' + escapeHtml(type.name) + '</option>';
|
||||
});
|
||||
html += '</select></label>';
|
||||
|
||||
if (orders.length) {
|
||||
html += '<label class="aksrt-ticket-field"><span>关联订单(可选)</span><select name="order_number">';
|
||||
html += '<option value="">不关联订单</option>';
|
||||
orders.forEach(function (order) {
|
||||
html += '<option value="' + escapeHtml(order.order_number) + '">'
|
||||
+ escapeHtml(order.order_number + (order.product_name ? ' ' + order.product_name : '')) + '</option>';
|
||||
});
|
||||
html += '</select></label>';
|
||||
}
|
||||
|
||||
html += '<label class="aksrt-ticket-field"><span>问题描述</span>'
|
||||
+ '<textarea name="content" rows="5" maxlength="5000" placeholder="请详细描述遇到的问题"></textarea></label>';
|
||||
|
||||
if (attachmentEnabled) {
|
||||
html += '<div class="aksrt-ticket-field"><span>图片(最多 ' + state.attachmentMax + ' 张)</span>'
|
||||
+ '<div class="aksrt-ticket-images" data-aksrt-ticket-images></div>'
|
||||
+ '<button type="button" class="btn-ghost" data-aksrt-ticket-upload>上传图片</button></div>';
|
||||
}
|
||||
|
||||
html += '<button type="submit" class="btn-primary">提交工单</button>';
|
||||
return html + '</form>';
|
||||
}
|
||||
|
||||
function bindCreate(shell) {
|
||||
var form = shell.querySelector('[data-aksrt-ticket-create-form]');
|
||||
if (!form) return;
|
||||
|
||||
var fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = 'image/gif,image/jpeg,image/jpg,image/png,image/webp';
|
||||
fileInput.hidden = true;
|
||||
shell.appendChild(fileInput);
|
||||
|
||||
shell.addEventListener('click', function (event) {
|
||||
if (event.target.closest('[data-aksrt-ticket-upload]')) fileInput.click();
|
||||
});
|
||||
|
||||
fileInput.addEventListener('change', function () {
|
||||
var file = fileInput.files && fileInput.files[0];
|
||||
if (!file) return;
|
||||
uploadAttachment(file, shell, function () { fileInput.value = ''; });
|
||||
});
|
||||
|
||||
form.addEventListener('submit', function (event) {
|
||||
event.preventDefault();
|
||||
var title = (form.querySelector('[name="title"]').value || '').trim();
|
||||
var typeId = form.querySelector('[name="ticket_type_id"]').value;
|
||||
var content = (form.querySelector('[name="content"]').value || '').trim();
|
||||
var orderField = form.querySelector('[name="order_number"]');
|
||||
|
||||
if (!title) { notyf('请填写工单标题', 'danger'); return; }
|
||||
if (!typeId) { notyf('请选择工单部门', 'danger'); return; }
|
||||
if (!content) { notyf('请填写问题描述', 'danger'); return; }
|
||||
|
||||
runCaptcha('ticket_create').then(function () {
|
||||
request('/tickets', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
title: title,
|
||||
ticket_type_id: parseInt(typeId, 10),
|
||||
content: content,
|
||||
order_number: orderField ? orderField.value : '',
|
||||
attachment: collectAttachments(shell),
|
||||
},
|
||||
}).then(function (result) {
|
||||
if (result && result.success) {
|
||||
closeModal('submit');
|
||||
notyf('工单已提交', 'success');
|
||||
state.page = 1;
|
||||
loadList();
|
||||
loadStatistic();
|
||||
} else {
|
||||
notyf((result && result.message) || '工单提交失败', 'danger');
|
||||
}
|
||||
}).catch(function () {
|
||||
notyf('网络错误,请重试', 'danger');
|
||||
});
|
||||
}).catch(function (error) {
|
||||
if (error && error.cancelled) return;
|
||||
notyf('人机验证未通过', 'danger');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function uploadAttachment(file, shell, done) {
|
||||
if (collectAttachments(shell).length >= state.attachmentMax) {
|
||||
notyf('最多上传 ' + state.attachmentMax + ' 张图片', 'danger');
|
||||
if (done) done();
|
||||
return;
|
||||
}
|
||||
var body = new FormData();
|
||||
body.append('image', file);
|
||||
notyf('正在上传图片…', 'load', 3000, 'aksrt-ticket-upload');
|
||||
fetch(API_ROOT + '/ticket/upload-attachment', {
|
||||
method: 'POST',
|
||||
headers: { 'X-WP-Nonce': NONCE },
|
||||
body: body,
|
||||
credentials: 'same-origin',
|
||||
}).then(function (res) { return res.json(); })
|
||||
.then(function (result) {
|
||||
var url = result && (result.url || (result.data && result.data.url));
|
||||
if (url) {
|
||||
appendImage(shell, url);
|
||||
} else {
|
||||
notyf((result && result.message) || '图片上传失败', 'danger');
|
||||
}
|
||||
})
|
||||
.catch(function () { notyf('图片上传失败', 'danger'); })
|
||||
.then(function () {
|
||||
var toast = document.getElementById('aksrt-ticket-upload');
|
||||
if (toast) toast.remove();
|
||||
if (done) done();
|
||||
});
|
||||
}
|
||||
|
||||
function appendImage(shell, url) {
|
||||
var holder = shell.querySelector('[data-aksrt-ticket-images]');
|
||||
if (!holder) return;
|
||||
var item = document.createElement('div');
|
||||
item.className = 'aksrt-ticket-image';
|
||||
item.setAttribute('data-aksrt-ticket-image', url);
|
||||
item.innerHTML = '<img src="' + escapeHtml(url) + '" alt="">'
|
||||
+ '<button type="button" class="aksrt-ticket-image__remove" aria-label="移除图片">×</button>';
|
||||
item.querySelector('.aksrt-ticket-image__remove').addEventListener('click', function () { item.remove(); });
|
||||
holder.appendChild(item);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 面板初始化
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function initPanel() {
|
||||
var panel = document.querySelector('[data-aksrt-ticket-center]');
|
||||
if (!panel || panel.dataset.aksrtTicketReady === '1') return;
|
||||
panel.dataset.aksrtTicketReady = '1';
|
||||
|
||||
var configNode = panel.querySelector('[data-aksrt-ticket-config]');
|
||||
if (configNode) {
|
||||
try {
|
||||
var config = JSON.parse(configNode.getAttribute('data-aksrt-ticket-config'));
|
||||
if (config && config.attachmentMax) state.attachmentMax = config.attachmentMax;
|
||||
} catch { /* invalid config attribute */ }
|
||||
}
|
||||
|
||||
panel.addEventListener('click', function (event) {
|
||||
var view = event.target.closest('[data-aksrt-ticket-view]');
|
||||
if (view) { openDetail(view.getAttribute('data-aksrt-ticket-view')); return; }
|
||||
var page = event.target.closest('[data-aksrt-ticket-page]');
|
||||
if (page) {
|
||||
state.page = parseInt(page.getAttribute('data-aksrt-ticket-page'), 10) || 1;
|
||||
loadList();
|
||||
return;
|
||||
}
|
||||
if (event.target.closest('[data-aksrt-ticket-create]')) { openCreate(); }
|
||||
});
|
||||
|
||||
var search = panel.querySelector('[data-aksrt-tickets-search]');
|
||||
if (search) {
|
||||
search.addEventListener('submit', function (event) {
|
||||
event.preventDefault();
|
||||
var input = search.querySelector('input');
|
||||
state.keywords = (input && input.value || '').trim();
|
||||
state.page = 1;
|
||||
loadList();
|
||||
});
|
||||
}
|
||||
|
||||
panel.querySelectorAll('[data-aksrt-ticket-filter]').forEach(function (select) {
|
||||
select.addEventListener('change', function () {
|
||||
var key = select.getAttribute('data-aksrt-ticket-filter');
|
||||
if (key === 'status') state.status = select.value;
|
||||
if (key === 'ticket_type_id') state.type = select.value;
|
||||
state.page = 1;
|
||||
loadList();
|
||||
});
|
||||
});
|
||||
|
||||
loadStatistic();
|
||||
loadList();
|
||||
}
|
||||
|
||||
function boot() {
|
||||
initPanel();
|
||||
// 面板是 AJAX 延迟加载的:user-center.js 每次切 Tab 都会触发 auto_fun。
|
||||
if (window.jQuery) {
|
||||
window.jQuery(document).off('auto_fun.aksrt-ticket').on('auto_fun.aksrt-ticket', initPanel);
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
})();
|
||||
+2
-2
@@ -239,8 +239,8 @@ if (class_exists('\AKSRT\Payment\ChargeCard')) {
|
||||
if (class_exists('\AKSRT\Payment\OrderCenterRest')) {
|
||||
\AKSRT\Payment\OrderCenterRest::get_instance()->init();
|
||||
}
|
||||
if (class_exists('\AKSRT\Payment\AfterSaleService')) {
|
||||
\AKSRT\Payment\AfterSaleService::get_instance()->init();
|
||||
if (class_exists('\AKSRT\Ticket\TicketService')) {
|
||||
\AKSRT\Ticket\TicketService::get_instance()->init();
|
||||
}
|
||||
if (class_exists('\AKSRT\Credits\BalanceService')) {
|
||||
\AKSRT\Credits\BalanceService::get_instance()->init();
|
||||
|
||||
@@ -184,6 +184,7 @@ if (!function_exists('aksrt_capture_user_center_view')) {
|
||||
'credits' => 'credits',
|
||||
'vip' => 'vip',
|
||||
'orders' => 'orders',
|
||||
'tickets' => 'tickets',
|
||||
'auth' => 'auth',
|
||||
'revenue' => 'revenue',
|
||||
'msg' => 'msg',
|
||||
@@ -210,7 +211,7 @@ if (!function_exists('aksrt_capture_user_center_panel')) {
|
||||
* Capture a built-in or registered user-center panel body.
|
||||
*/
|
||||
function aksrt_capture_user_center_panel(string $tab_key, array $tab, array $context): string {
|
||||
$built_in_tabs = ['profile', 'level', 'creator', 'posts', 'follow', 'comments', 'favorites', 'credits', 'vip', 'orders', 'auth', 'revenue', 'msg', 'settings'];
|
||||
$built_in_tabs = ['profile', 'level', 'creator', 'posts', 'follow', 'comments', 'favorites', 'credits', 'vip', 'orders', 'tickets', 'auth', 'revenue', 'msg', 'settings'];
|
||||
if (in_array($tab_key, $built_in_tabs, true)) {
|
||||
return aksrt_capture_user_center_view($tab_key, $context);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ $status_options = [
|
||||
'all' => __('全部', 'aksrt'),
|
||||
'pending' => __('待支付', 'aksrt'),
|
||||
'paid' => __('已支付', 'aksrt'),
|
||||
'after_sale' => __('售后中', 'aksrt'),
|
||||
'refunded' => __('已退款', 'aksrt'),
|
||||
'closed' => __('已关闭', 'aksrt'),
|
||||
];
|
||||
@@ -121,26 +120,6 @@ $status_options = [
|
||||
<button class="btn-outline" type="button" data-aksrt-order-detail="<?php echo esc_attr($number); ?>"><?php esc_html_e('订单详情', 'aksrt'); ?></button>
|
||||
<?php if (!empty($actions['can_cancel'])) : ?><button class="btn-ghost" type="button" data-aksrt-order-cancel="<?php echo esc_attr($number); ?>"><?php esc_html_e('关闭订单', 'aksrt'); ?></button><?php endif; ?>
|
||||
<?php if (!empty($actions['can_pay'])) : ?><button class="btn-primary" type="button" data-aksrt-order-pay="<?php echo esc_attr($number); ?>" data-aksrt-order-method="<?php echo esc_attr((string) ($order['payment_method']['value'] ?? '')); ?>"><?php esc_html_e('继续支付', 'aksrt'); ?></button><?php endif; ?>
|
||||
<?php
|
||||
$after_sale = is_array($order['after_sale'] ?? null) ? $order['after_sale'] : [];
|
||||
// 售后弹窗载荷随按钮渲染(同举报链路 data-aksrt-report):点击即同步弹窗。
|
||||
$after_sale_payload = wp_json_encode([
|
||||
'order_number' => $number,
|
||||
'product_name' => (string) ($order['product_name'] ?? ''),
|
||||
'amount' => (string) ($order['amount'] ?? '0'),
|
||||
'is_points' => !empty($order['is_points']),
|
||||
'currency_symbol' => (string) ($order['currency_symbol'] ?? ''),
|
||||
'status_label' => (string) ($order['status_label'] ?? ''),
|
||||
'after_sale' => $after_sale,
|
||||
]);
|
||||
?>
|
||||
<?php if (!empty($after_sale['enabled'])) : ?>
|
||||
<?php if (!empty($after_sale['has_application'])) : ?>
|
||||
<button class="btn-outline" type="button" data-aksrt-after-sale-detail="<?php echo esc_attr($after_sale_payload); ?>"><?php esc_html_e('售后详细', 'aksrt'); ?></button>
|
||||
<?php else : ?>
|
||||
<button class="btn-outline" type="button" data-aksrt-after-sale-apply="<?php echo esc_attr($after_sale_payload); ?>"><?php esc_html_e('申请售后', 'aksrt'); ?></button>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</footer>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
@@ -150,13 +129,5 @@ $status_options = [
|
||||
<div class="aksrt-orders-page__empty" data-aksrt-order-list><i class="fa fas fa-file-alt" aria-hidden="true"></i><h3><?php esc_html_e('还没有购买记录', 'aksrt'); ?></h3><p><?php esc_html_e('购买内容或开通权益后,订单会出现在这里。', 'aksrt'); ?></p></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
// 售后人机验证挂载点(申请 / 撤销):场景未启用时 aksrt_render_captcha 无输出,
|
||||
// 前端 order-after-sale.js 以挂载点存在与否决定是否先过验证。
|
||||
if (function_exists('aksrt_render_captcha')) {
|
||||
aksrt_render_captcha('after_sale_apply');
|
||||
aksrt_render_captcha('after_sale_cancel');
|
||||
}
|
||||
?>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
/**
|
||||
* User center ticket (工单) panel.
|
||||
*
|
||||
* 数据全部来自 /aksrt/v1/tickets* REST;本文件只输出容器与挂载点,
|
||||
* 交互在 assets/js/ticket-center.js。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
$context = is_array($args['context'] ?? null) ? $args['context'] : [];
|
||||
$user_id = (int) ($context['user_id'] ?? get_current_user_id());
|
||||
|
||||
if ($user_id <= 0) {
|
||||
echo '<div class="uc-panel-empty muted-2-color">' . esc_html__('请先登录', 'aksrt') . '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
$enabled = class_exists('\AKSRT\Ticket\TicketSettings') && \AKSRT\Ticket\TicketSettings::enabled();
|
||||
if (!$enabled) {
|
||||
echo '<div class="uc-panel-empty muted-2-color">' . esc_html__('工单功能暂未开启', 'aksrt') . '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
$statuses = class_exists('\AKSRT\Ticket\TicketStatusService')
|
||||
? \AKSRT\Ticket\TicketStatusService::all()
|
||||
: [];
|
||||
$types = class_exists('\AKSRT\Ticket\TicketTypeService')
|
||||
? \AKSRT\Ticket\TicketTypeService::public_list()
|
||||
: [];
|
||||
$attachment_max = class_exists('\AKSRT\Ticket\TicketSettings')
|
||||
? \AKSRT\Ticket\TicketSettings::attachment_max()
|
||||
: 5;
|
||||
?>
|
||||
<div class="aksrt-tickets-page uc-tickets-panel" data-aksrt-ticket-center>
|
||||
<header class="uc-tickets-panel__head">
|
||||
<div class="uc-tickets-panel__title-wrap">
|
||||
<p class="aksrt-tickets-page__eyebrow"><?php esc_html_e('我的服务', 'aksrt'); ?></p>
|
||||
<div class="uc-tickets-panel__title font-bold em12">
|
||||
<i class="fa fas fa-headset uc-tickets-panel__title-icon" aria-hidden="true"></i>
|
||||
<?php esc_html_e('工单中心', 'aksrt'); ?>
|
||||
</div>
|
||||
<p class="muted-2-color em09 mt3"><?php esc_html_e('提交问题工单,跟踪处理进度并与管理员沟通。', 'aksrt'); ?></p>
|
||||
</div>
|
||||
<button class="btn-primary" type="button" data-aksrt-ticket-create><?php esc_html_e('提交工单', 'aksrt'); ?></button>
|
||||
</header>
|
||||
|
||||
<div class="aksrt-tickets-page__stats" data-aksrt-ticket-stats>
|
||||
<?php foreach ($statuses as $status) : ?>
|
||||
<div class="aksrt-tickets-page__stat" data-aksrt-ticket-stat="<?php echo esc_attr((string) $status['status_key']); ?>">
|
||||
<span><?php echo esc_html((string) $status['name']); ?></span><strong>0</strong>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<form class="aksrt-tickets-page__search" data-aksrt-tickets-search>
|
||||
<label class="screen-reader-text" for="aksrt-ticket-keywords"><?php esc_html_e('按工单号或标题查找', 'aksrt'); ?></label>
|
||||
<i class="fa fas fa-search" aria-hidden="true"></i>
|
||||
<input id="aksrt-ticket-keywords" maxlength="60" placeholder="<?php esc_attr_e('输入工单号或标题查找', 'aksrt'); ?>">
|
||||
<button class="btn-primary" type="submit"><?php esc_html_e('查找', 'aksrt'); ?></button>
|
||||
<select data-aksrt-ticket-filter="status" aria-label="<?php esc_attr_e('工单状态', 'aksrt'); ?>">
|
||||
<option value=""><?php esc_html_e('全部状态', 'aksrt'); ?></option>
|
||||
<?php foreach ($statuses as $status) : ?>
|
||||
<option value="<?php echo esc_attr((string) $status['status_key']); ?>"><?php echo esc_html((string) $status['name']); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php if ($types !== []) : ?>
|
||||
<select data-aksrt-ticket-filter="ticket_type_id" aria-label="<?php esc_attr_e('工单部门', 'aksrt'); ?>">
|
||||
<option value=""><?php esc_html_e('全部部门', 'aksrt'); ?></option>
|
||||
<?php foreach ($types as $type) : ?>
|
||||
<option value="<?php echo esc_attr((string) $type['id']); ?>"><?php echo esc_html((string) $type['name']); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
|
||||
<div class="aksrt-tickets-page__list" data-aksrt-ticket-list>
|
||||
<div class="aksrt-tickets-page__empty">
|
||||
<i class="fa fas fa-headset" aria-hidden="true"></i>
|
||||
<h3><?php esc_html_e('还没有工单', 'aksrt'); ?></h3>
|
||||
<p><?php esc_html_e('遇到问题时提交工单,我们会尽快处理。', 'aksrt'); ?></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="aksrt-tickets-page__pagination" data-aksrt-ticket-pagination hidden></nav>
|
||||
|
||||
<?php
|
||||
// 人机验证挂载点(提交工单 / 回复):场景未启用时 aksrt_render_captcha 无输出,
|
||||
// ticket-center.js 以挂载点存在与否决定是否先过验证。
|
||||
if (function_exists('aksrt_render_captcha')) {
|
||||
aksrt_render_captcha('ticket_create');
|
||||
aksrt_render_captcha('ticket_reply');
|
||||
}
|
||||
?>
|
||||
<span class="hidden" data-aksrt-ticket-config="<?php echo esc_attr(wp_json_encode([
|
||||
'attachmentMax' => $attachment_max,
|
||||
])); ?>"></span>
|
||||
</div>
|
||||
@@ -16,6 +16,8 @@ $level_url = isset($dashboard_tabs['level'])
|
||||
$auth_url = isset($dashboard_tabs['auth']) ? aksrt_get_user_center_tab_url('auth') : '';
|
||||
$orders_available = isset($dashboard_tabs['orders']);
|
||||
$orders_url = $orders_available ? (string) ($orders_url ?? '') : '';
|
||||
$tickets_available = isset($dashboard_tabs['tickets']);
|
||||
$tickets_url = $tickets_available ? aksrt_get_user_center_tab_url('tickets') : '';
|
||||
|
||||
$services = array_values(array_filter([
|
||||
[
|
||||
@@ -37,6 +39,12 @@ $services = array_values(array_filter([
|
||||
'url' => $orders_url,
|
||||
'tab' => 'orders',
|
||||
],
|
||||
[
|
||||
'label' => __('工单中心', 'aksrt'),
|
||||
'icon' => 'manual-service',
|
||||
'url' => $tickets_url,
|
||||
'tab' => 'tickets',
|
||||
],
|
||||
], static fn(array $item): bool => (string) $item['url'] !== ''));
|
||||
|
||||
$settings = array_values(array_filter([
|
||||
|
||||
@@ -375,8 +375,8 @@ function aksrt_captcha_scenes(): array {
|
||||
'signin' => __('每日签到', 'aksrt'),
|
||||
'front_submit' => __('前台投稿', 'aksrt'),
|
||||
'tip_user' => __('打赏用户', 'aksrt'),
|
||||
'after_sale_apply' => __('申请售后', 'aksrt'),
|
||||
'after_sale_cancel' => __('撤销售后', 'aksrt'),
|
||||
'ticket_create' => __('提交工单', 'aksrt'),
|
||||
'ticket_reply' => __('回复工单', 'aksrt'),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* 后台列表页分页条 —— 1:1 复刻「卡密管理」页的 zibpay_admin_pagenavi 结构。
|
||||
*
|
||||
* 支付中心各页面(订单明细 / 售后服务 / 付费文章 / 优惠码 / 创作分成 / 提现审核 / 卡密管理)
|
||||
* 支付中心各页面(订单明细 / 付费文章 / 优惠码 / 创作分成 / 提现审核 / 卡密管理)
|
||||
* 原先各写一份几乎相同的翻页代码。这里统一到一处,标记沿用卡密管理那一套:
|
||||
* `« ‹ 第 X 页,共 Y 页 › »` 四枚按钮 + 「每页 X 共Y」计数,没有数字页码链接。
|
||||
*
|
||||
|
||||
@@ -52,17 +52,6 @@ class PayMenus {
|
||||
wp_die(esc_html__('订单模块未初始化。', 'aksrt'));
|
||||
},
|
||||
],
|
||||
'after_sales' => [
|
||||
'slug' => 'aksrt_after_sales',
|
||||
'title' => __('售后服务', 'aksrt'),
|
||||
'callback' => static function (): void {
|
||||
if (class_exists('AKSRT\Admin\Native\PayOrders')) {
|
||||
PayOrders::get_instance()->render_after_sales_admin_page();
|
||||
return;
|
||||
}
|
||||
wp_die(esc_html__('订单模块未初始化。', 'aksrt'));
|
||||
},
|
||||
],
|
||||
'products' => [
|
||||
'slug' => 'aksrt_pay_products',
|
||||
'title' => __('付费文章', 'aksrt'),
|
||||
|
||||
+10
-396
@@ -11,7 +11,6 @@ class PayOrders {
|
||||
|
||||
public const CAPABILITY = 'manage_options';
|
||||
public const PAGE_SLUG = 'aksrt_orders';
|
||||
public const PAGE_AFTER_SALES_SLUG = 'aksrt_after_sales';
|
||||
|
||||
private static $instance = null;
|
||||
|
||||
@@ -31,7 +30,6 @@ class PayOrders {
|
||||
add_action('admin_post_aksrt_pay_clear_closed_orders', [$this, 'handle_clear_closed_orders']);
|
||||
add_action('admin_post_aksrt_pay_delete_order', [$this, 'handle_delete_order']);
|
||||
add_action('admin_post_aksrt_pay_refund_order', [$this, 'handle_refund_order']);
|
||||
add_action('admin_post_aksrt_after_sale_update', [$this, 'handle_after_sale_status']);
|
||||
}
|
||||
|
||||
public function render_orders_page(): void {
|
||||
@@ -269,7 +267,7 @@ class PayOrders {
|
||||
echo '<td>' . esc_html((string) ($row['created_at'] ?? '')) . '<br><span class="description">' . esc_html((string) ($row['paid_at'] ?? '') ?: '—') . '</span></td>';
|
||||
echo '<td style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;">';
|
||||
echo '<a class="button button-small" href="' . esc_url($this->page_url('aksrt_orders', ['order' => $order_number])) . '">' . esc_html__('详情', 'aksrt') . '</a>';
|
||||
// 已支付是已退款的唯一前置状态;退款仍需落到一条进行中的售后工单上。
|
||||
// 已支付是已退款的唯一前置状态。
|
||||
if ($status_key === \AKSRT\Core\DomainModel::ORDER_STATUS_PAID) {
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:inline;" onsubmit="return confirm(\'' . esc_js(__('确认将该订单标记为已退款?不可恢复。', 'aksrt')) . '\');">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_pay_refund_order" />';
|
||||
@@ -298,34 +296,6 @@ class PayOrders {
|
||||
$this->render_footer();
|
||||
}
|
||||
|
||||
/**
|
||||
* 售后服务子菜单(aksrt_after_sales):原订单明细页「售后」Tab 独立成页。
|
||||
*/
|
||||
public function render_after_sales_admin_page(): void {
|
||||
$this->ensure_cap();
|
||||
$this->render_header(__('售后服务', 'aksrt'));
|
||||
|
||||
if (!class_exists('\\AKSRT\\Payment\\AfterSaleService')) {
|
||||
echo '<div class="notice notice-error"><p>' . esc_html__('售后服务不可用。', 'aksrt') . '</p></div>';
|
||||
$this->render_footer();
|
||||
return;
|
||||
}
|
||||
|
||||
$policy = \AKSRT\Settings\PaymentRuntime::get_after_sale_settings();
|
||||
$policy_bits = [];
|
||||
$policy_bits[] = __('售后功能', 'aksrt') . ':' . ($policy['enabled'] ? __('开启', 'aksrt') : __('关闭', 'aksrt'));
|
||||
$limit_days = (int) ($policy['time_limit'] ?? 0);
|
||||
$policy_bits[] = __('申请售后时效', 'aksrt') . ':' . ($limit_days > 0 ? sprintf(__('%d 天', 'aksrt'), $limit_days) : __('不限制', 'aksrt'));
|
||||
$reason_names = array_column((array) ($policy['reasons'] ?? []), 'name');
|
||||
if ($reason_names) $policy_bits[] = __('预置原因', 'aksrt') . ':' . implode('、', $reason_names);
|
||||
$material_names = array_values(array_filter(array_map('trim', (array) ($policy['materials'] ?? []))));
|
||||
if ($material_names) $policy_bits[] = __('申请所需材料', 'aksrt') . ':' . implode('、', $material_names);
|
||||
echo '<p class="description">' . esc_html(implode(' ', $policy_bits)) . '</p>';
|
||||
|
||||
$this->render_after_sales_page();
|
||||
$this->render_footer();
|
||||
}
|
||||
|
||||
private function render_order_detail_page(string $order_number): void {
|
||||
$repo = \AKSRT\Payment\OrderRepository::get_instance();
|
||||
$order = $repo->get($order_number);
|
||||
@@ -351,13 +321,6 @@ class PayOrders {
|
||||
|
||||
echo '<p><a class="button" href="' . esc_url($this->page_url('aksrt_orders')) . '">← ' . esc_html__('返回订单列表', 'aksrt') . '</a></p>';
|
||||
|
||||
// 售后记录区块:工单卡片 + 处理记录列表(与售后面板共用 .aksrt-as-log 样式)。
|
||||
echo '<style>'
|
||||
. '.aksrt-as-ticket{padding:10px 0;border-bottom:1px solid #f0f0f1;}'
|
||||
. '.aksrt-as-ticket:last-child{padding-bottom:0;border-bottom:0;}'
|
||||
. '.aksrt-as-ticket__head{margin:0 0 4px;}'
|
||||
. $this->after_sale_log_css()
|
||||
. '</style>';
|
||||
echo '<table class="widefat striped" style="max-width:960px;"><tbody>';
|
||||
$rows = [
|
||||
__('订单号', 'aksrt') => $order_number,
|
||||
@@ -375,23 +338,6 @@ class PayOrders {
|
||||
foreach ($rows as $label => $value) {
|
||||
echo '<tr><th style="width:160px;">' . esc_html($label) . '</th><td>' . esc_html((string) $value) . '</td></tr>';
|
||||
}
|
||||
$after_sales = \AKSRT\Payment\AfterSaleService::get_instance()->list_for_order($order_number);
|
||||
if ($after_sales) {
|
||||
echo '<tr><th>' . esc_html__('售后记录', 'aksrt') . '</th><td>';
|
||||
$status_labels = $this->after_sale_status_labels();
|
||||
foreach ($after_sales as $application) {
|
||||
$as_status = (string) ($application['status'] ?? '');
|
||||
echo '<div class="aksrt-as-ticket is-' . esc_attr($as_status) . '">';
|
||||
echo '<p class="aksrt-as-ticket__head"><strong>' . esc_html($status_labels[$as_status] ?? $as_status) . '</strong>'
|
||||
. ' <span class="description">' . esc_html((string) ($application['refund_amount'] ?? '')) . '</span>'
|
||||
. ' <span class="description">' . esc_html((string) ($application['created_at'] ?? '')) . '</span></p>';
|
||||
echo '<p>' . nl2br(esc_html((string) ($application['reason'] ?? ''))) . '</p>';
|
||||
// 处理记录:后台每一次提交都留一条(状态 + 备注 + 时间),与售后面板同款标记。
|
||||
echo $this->render_after_sale_log((array) ($application['handling_log'] ?? []), $status_labels); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
echo '</div>';
|
||||
}
|
||||
echo '</td></tr>';
|
||||
}
|
||||
if (!empty($context)) {
|
||||
echo '<tr><th>' . esc_html__('附加上下文', 'aksrt') . '</th><td><pre style="white-space:pre-wrap;margin:0;">' . esc_html(wp_json_encode($context, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)) . '</pre></td></tr>';
|
||||
}
|
||||
@@ -412,322 +358,6 @@ class PayOrders {
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
private function render_after_sales_page(): void {
|
||||
$service = \AKSRT\Payment\AfterSaleService::get_instance();
|
||||
$status = isset($_GET['after_sale_status']) ? sanitize_key((string) wp_unslash($_GET['after_sale_status'])) : '';
|
||||
// admin_list() 会按状态常量白名单过滤,count() 不会。这里先归一一次,
|
||||
// 否则手改 URL 传个野状态会变成「count 为 0 而 list 返回全部」,分页条直接说谎。
|
||||
if (!in_array($status, $this->after_sale_statuses(), true)) {
|
||||
$status = '';
|
||||
}
|
||||
$date_from = isset($_GET['date_from']) ? sanitize_text_field((string) wp_unslash($_GET['date_from'])) : '';
|
||||
$date_to = isset($_GET['date_to']) ? sanitize_text_field((string) wp_unslash($_GET['date_to'])) : '';
|
||||
$date_from = preg_match('/^\d{4}-\d{2}-\d{2}$/', $date_from) ? $date_from : '';
|
||||
$date_to = preg_match('/^\d{4}-\d{2}-\d{2}$/', $date_to) ? $date_to : '';
|
||||
$filter_args = ['status' => $status, 'date_from' => $date_from, 'date_to' => $date_to];
|
||||
$page_num = max(1, (int) ($_GET['paged'] ?? 1));
|
||||
$per_page = AdminListPagination::DEFAULT_PER_PAGE;
|
||||
|
||||
$items = $service->admin_list($filter_args + ['limit' => $per_page, 'offset' => ($page_num - 1) * $per_page]);
|
||||
$total = $service->count($filter_args);
|
||||
$pages = max(1, (int) ceil($total / $per_page));
|
||||
|
||||
// 页码越界(手工改 URL、或筛选后总数变小)会让 offset 落到数据之外:表格空掉
|
||||
// 且分页条不输出,只能手改 URL 退出。回退到最后一页重查一次。
|
||||
if ($page_num > $pages) {
|
||||
$page_num = $pages;
|
||||
$items = $service->admin_list($filter_args + ['limit' => $per_page, 'offset' => ($page_num - 1) * $per_page]);
|
||||
}
|
||||
|
||||
echo '<form method="get"><input type="hidden" name="page" value="' . esc_attr(self::PAGE_AFTER_SALES_SLUG) . '" />';
|
||||
echo '<select name="after_sale_status"><option value="">' . esc_html__('全部售后状态', 'aksrt') . '</option>';
|
||||
foreach ($this->after_sale_status_labels() as $key => $label) echo '<option value="' . esc_attr($key) . '"' . selected($status, $key, false) . '>' . esc_html($label) . '</option>';
|
||||
echo '</select> <label>' . esc_html__('开始日期', 'aksrt') . ' <input type="date" name="date_from" value="' . esc_attr($date_from) . '" /></label> <label>' . esc_html__('结束日期', 'aksrt') . ' <input type="date" name="date_to" value="' . esc_attr($date_to) . '" /></label> ';
|
||||
submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
|
||||
echo '</form>';
|
||||
echo '<style>'
|
||||
. '.aksrt-as-detail-row>td{padding:0 0 6px;}'
|
||||
. '.aksrt-as-detail-row[hidden]{display:none;}'
|
||||
. '.aksrt-as-detail{margin:0 12px;padding:14px 18px;border:1px solid #dcdcde;background:#fff;box-shadow:0 1px 1px rgba(0,0,0,.04);}'
|
||||
. '.aksrt-as-detail-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px 28px;}'
|
||||
. '.aksrt-as-field .aksrt-as-label{display:block;color:#646970;font-size:12px;margin-bottom:2px;}'
|
||||
. '.aksrt-as-field .aksrt-as-value{display:block;word-break:break-word;}'
|
||||
. '.aksrt-as-field{min-width:0;}'
|
||||
. '.aksrt-as-thumb{width:48px;height:48px;object-fit:cover;border-radius:4px;margin:0 4px 4px 0;vertical-align:middle;}'
|
||||
. '.aksrt-as-detail .aksrt-as-thumb{width:80px;height:80px;}'
|
||||
. '.aksrt-as-review{margin-top:14px;padding-top:12px;border-top:1px solid #dcdcde;display:flex;flex-wrap:wrap;align-items:flex-end;gap:12px 16px;}'
|
||||
. '.aksrt-as-review-head{flex:1 1 100%;}'
|
||||
. '.aksrt-as-review-main{flex:1 1 460px;min-width:0;display:flex;flex-wrap:wrap;align-items:flex-end;gap:10px 12px;}'
|
||||
. '.aksrt-as-review-main p{margin:0;}'
|
||||
. '.aksrt-status-select{min-width:150px;}'
|
||||
. '.aksrt-as-refund-flag{display:inline-flex;align-items:center;gap:6px;white-space:nowrap;margin:0;}'
|
||||
. '.aksrt-as-review textarea{flex:1 1 240px;min-width:0;box-sizing:border-box;margin:0;}'
|
||||
. '.aksrt-as-review-actions{display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end;margin-left:auto;}'
|
||||
. '.aksrt-as-field--wide{grid-column:1/-1;}'
|
||||
. $this->after_sale_log_css()
|
||||
. '@media (min-width:1500px){.aksrt-as-detail-grid{grid-template-columns:repeat(5,minmax(0,1fr));}}'
|
||||
. '@media (max-width:600px){.aksrt-as-detail-grid{grid-template-columns:minmax(0,1fr);}}'
|
||||
. '</style>';
|
||||
echo '<table class="widefat striped"><thead><tr><th>ID</th><th>订单号</th><th>用户</th><th>原因</th><th>申请资料</th><th>申请说明</th><th>退款金额</th><th>状态</th><th>操作 / 处理记录</th></tr></thead><tbody>';
|
||||
if (!$items) echo '<tr><td colspan="9">' . esc_html__('暂无售后申请', 'aksrt') . '</td></tr>';
|
||||
$status_labels = $this->after_sale_status_labels();
|
||||
foreach ($items as $item) {
|
||||
$id = (int) $item['id']; $number = (string) $item['order_number']; $uid = (int) $item['user_id'];
|
||||
$user = $uid > 0 ? get_userdata($uid) : null;
|
||||
$order = \AKSRT\Payment\OrderRepository::get_instance()->get($number);
|
||||
$type = $order ? \AKSRT\Core\DomainModel::resolve_order_product_type((string) ($order['product_type'] ?? ''), (array) ($order['meta_payload'] ?? [])) : '';
|
||||
$type_label = \AKSRT\Core\DomainModel::order_product_type_labels()[$type] ?? $type;
|
||||
$product_name = $order ? (string) (\AKSRT\Payment\UserOrders::get_instance()->decorate_order_view($order, false)['product_name'] ?? '') : '';
|
||||
$refund_amount = (string) ($item['refund_amount'] ?? '0');
|
||||
if ($order && (string) ($order['currency'] ?? '') === 'points') $refund_amount .= ' ' . __('积分', 'aksrt'); else $refund_amount = '¥' . $refund_amount;
|
||||
$apply_form = is_array($item['apply_form'] ?? null) ? $item['apply_form'] : null;
|
||||
if ($apply_form) {
|
||||
$reason_cell = esc_html((string) ($apply_form['reason_label'] ?? ''));
|
||||
$material_lines = [];
|
||||
foreach ((array) ($apply_form['materials'] ?? []) as $m) {
|
||||
$ml = trim((string) ($m['label'] ?? '')); $mv = trim((string) ($m['value'] ?? ''));
|
||||
if ($ml === '' && $mv === '') continue;
|
||||
$material_lines[] = esc_html($ml !== '' ? $ml . ':' . $mv : $mv);
|
||||
}
|
||||
$materials_cell = $material_lines ? implode('<br>', $material_lines) : '—';
|
||||
$desc_text = trim((string) ($apply_form['description'] ?? ''));
|
||||
} else {
|
||||
// 老数据:融合串原样展示在原因列
|
||||
$reason_cell = nl2br(esc_html((string) $item['reason']));
|
||||
$materials_cell = '—';
|
||||
$desc_text = '';
|
||||
}
|
||||
$image_urls = array_values(array_filter((array) ($item['images'] ?? []), 'is_string'));
|
||||
$thumbs = [];
|
||||
foreach ($image_urls as $url) {
|
||||
// 不写原生 loading 懒加载属性:lazyload 契约测试全仓库禁 loading=lazy/eager,
|
||||
// 且后台不加载 lazySizes 运行时,写了也不会被主题那套接管。
|
||||
// 与 UsersMenusVerify 的后台缩略图保持一致(光 <img src>)。
|
||||
$thumbs[] = '<a href="' . esc_url($url) . '" target="_blank" rel="noopener"><img src="' . esc_url($url) . '" alt="" class="aksrt-as-thumb" /></a>';
|
||||
}
|
||||
// 举证图片并入申请说明:缩略图展示在描述文字下方,不再单独成列。
|
||||
$desc_cell = ($desc_text !== '' ? nl2br(esc_html($desc_text)) : '') . ($thumbs ? '<div class="aksrt-as-desc-thumbs" style="margin-top:6px;">' . implode('', $thumbs) . '</div>' : '');
|
||||
if ($desc_cell === '') $desc_cell = '—';
|
||||
$status_label = $status_labels[(string) $item['status']] ?? (string) $item['status'];
|
||||
$order_refunded = is_array($order) && (string) ($order['status'] ?? '') === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED;
|
||||
// 摘要行 + 手风琴详情行:点击「操作」展开整条记录的完整信息与状态面板
|
||||
echo '<tr class="aksrt-as-row">';
|
||||
echo '<td>' . esc_html((string) $id) . '<br><small>' . esc_html((string) ($item['created_at'] ?? '')) . '</small></td>';
|
||||
echo '<td><a href="' . esc_url($this->page_url(self::PAGE_SLUG, ['order' => $number])) . '"><code>' . esc_html($number) . '</code></a><br><span class="description">' . esc_html($type_label) . ($product_name !== '' ? ' · ' . esc_html($product_name) : '') . '</span></td>';
|
||||
echo '<td>' . esc_html($user ? $user->display_name . ' (#' . $uid . ')' : '#' . $uid) . '</td>';
|
||||
echo '<td>' . $reason_cell . '</td>';
|
||||
echo '<td>' . $materials_cell . '</td>';
|
||||
echo '<td>' . $desc_cell . '</td>';
|
||||
echo '<td>' . esc_html($refund_amount) . '</td>';
|
||||
echo '<td>' . esc_html($status_label) . '</td>';
|
||||
echo '<td><a href="#" class="aksrt-toggle-row-actions" data-target="aksrt-as-detail-' . esc_attr((string) $id) . '" aria-expanded="false" aria-controls="aksrt-as-detail-' . esc_attr((string) $id) . '">' . esc_html__('操作', 'aksrt') . '</a></td>';
|
||||
echo '</tr>';
|
||||
echo '<tr class="aksrt-as-detail-row" id="aksrt-as-detail-' . esc_attr((string) $id) . '" hidden><td colspan="9">';
|
||||
$detail_fields = [
|
||||
__('订单号', 'aksrt') => '<a href="' . esc_url($this->page_url(self::PAGE_SLUG, ['order' => $number])) . '"><code>' . esc_html($number) . '</code></a>',
|
||||
__('商品', 'aksrt') => esc_html($type_label . ($product_name !== '' ? ' · ' . $product_name : '')),
|
||||
__('用户', 'aksrt') => esc_html($user ? $user->display_name . ' (#' . $uid . ')' : '#' . $uid),
|
||||
__('申请时间', 'aksrt') => esc_html((string) ($item['created_at'] ?? '')),
|
||||
__('退款金额', 'aksrt') => esc_html($refund_amount),
|
||||
__('当前状态', 'aksrt') => esc_html($status_label),
|
||||
__('售后原因', 'aksrt') => $reason_cell,
|
||||
__('申请资料', 'aksrt') => $materials_cell,
|
||||
];
|
||||
echo '<div class="aksrt-as-detail"><div class="aksrt-as-detail-grid">';
|
||||
foreach ($detail_fields as $field_label => $field_value) {
|
||||
echo '<span class="aksrt-as-field"><span class="aksrt-as-label">' . esc_html($field_label) . '</span><span class="aksrt-as-value">' . $field_value . '</span></span>';
|
||||
}
|
||||
// 处理备注:单独占整行,放在申请说明上方;每次提交一条,含状态与时间。
|
||||
echo '<span class="aksrt-as-field aksrt-as-field--wide"><span class="aksrt-as-label">' . esc_html__('处理备注', 'aksrt') . '</span><span class="aksrt-as-value">'
|
||||
. $this->render_after_sale_log((array) ($item['handling_log'] ?? []), $status_labels) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
. '</span></span>';
|
||||
// 举证图片并入申请说明,单独占整行展示。
|
||||
echo '<span class="aksrt-as-field aksrt-as-field--wide"><span class="aksrt-as-label">' . esc_html__('申请说明', 'aksrt') . '</span><span class="aksrt-as-value">' . $desc_cell . '</span></span>';
|
||||
echo '</div>';
|
||||
$status_options = '';
|
||||
$current_status = (string) $item['status'];
|
||||
foreach ($status_labels as $key => $label) {
|
||||
$status_options .= '<option value="' . esc_attr($key) . '"' . selected($current_status, $key, false) . '>' . esc_html($label) . '</option>';
|
||||
}
|
||||
if (!isset($status_labels[$current_status])) {
|
||||
// 历史状态(如已删除的 approved)不在可选项内:如实呈现且禁用,防止误提交。
|
||||
$status_options .= '<option value="' . esc_attr($current_status) . '" selected="selected" disabled>' . esc_html($current_status) . '</option>';
|
||||
}
|
||||
echo '<div class="aksrt-as-review">';
|
||||
echo '<p class="aksrt-as-review-head" style="margin:0;"><strong>' . esc_html__('售后状态', 'aksrt') . '</strong> <span class="description">' . esc_html__('选择目标状态后点击确认;取消仅收起面板。', 'aksrt') . '</span></p>';
|
||||
echo '<div class="aksrt-as-review-main">';
|
||||
echo '<p><select class="aksrt-status-select">' . $status_options . '</select></p>';
|
||||
echo '<textarea class="aksrt-review-note" rows="2" placeholder="' . esc_attr__('填写处理备注(可留空)', 'aksrt') . '"></textarea>';
|
||||
echo '</div>';
|
||||
// 勾选后在确认时把关联订单推到已退款;订单本已退款时呈现为不可改的已选状态
|
||||
echo '<label class="aksrt-as-refund-flag"><input type="checkbox" class="aksrt-mark-order-refunded" value="1"'
|
||||
. ($order_refunded ? ' checked="checked" disabled' : '')
|
||||
. ' /> ' . esc_html__('标记订单已退款', 'aksrt') . '</label>';
|
||||
echo '<div class="aksrt-as-review-actions">';
|
||||
echo '<button type="button" class="button aksrt-after-sale-cancel">' . esc_html__('取消', 'aksrt') . '</button>';
|
||||
echo '<button type="button" class="button button-primary aksrt-after-sale-confirm"'
|
||||
. ' data-id="' . esc_attr((string) $id) . '"'
|
||||
. ' data-nonce="' . esc_attr(wp_create_nonce('aksrt_after_sale_status_' . $id)) . '">'
|
||||
. esc_html__('确认', 'aksrt') . '</button>';
|
||||
echo '</div></div>';
|
||||
echo '</div></td></tr>';
|
||||
}
|
||||
echo '</tbody></table>';
|
||||
// 翻页链接显式带上当前筛选条件(不含 paged),口径与上方筛选表单一致。
|
||||
$query_args = array_filter([
|
||||
'after_sale_status' => $status !== '' ? $status : null,
|
||||
'date_from' => $date_from !== '' ? $date_from : null,
|
||||
'date_to' => $date_to !== '' ? $date_to : null,
|
||||
], static function ($value): bool {
|
||||
return $value !== null;
|
||||
});
|
||||
AdminListPagination::render(self::PAGE_AFTER_SALES_SLUG, $total, $page_num, $per_page, $query_args);
|
||||
|
||||
// 事件委托:点击「操作」切换手风琴详情;「取消」仅收起面板;「确认」以 POST 提交选中的售后状态
|
||||
echo '<script>(function(){'
|
||||
. 'var postUrl=' . wp_json_encode(esc_url_raw(admin_url('admin-post.php'))) . ';'
|
||||
. 'var tConfirmStatus=' . wp_json_encode(__('确认将售后状态设置为', 'aksrt')) . ';'
|
||||
. 'function closeAllDetails(){'
|
||||
. 'document.querySelectorAll(".aksrt-as-detail-row").forEach(function(x){x.setAttribute("hidden","");});'
|
||||
. 'document.querySelectorAll(".aksrt-toggle-row-actions").forEach(function(t){t.setAttribute("aria-expanded","false");});'
|
||||
. '}'
|
||||
. 'document.addEventListener("click",function(e){'
|
||||
. 'var el=e.target;'
|
||||
. 'var t=el&&el.closest?el.closest(".aksrt-toggle-row-actions"):null;'
|
||||
. 'if(t){e.preventDefault();'
|
||||
. 'var p=document.getElementById(t.getAttribute("data-target"));if(!p)return;'
|
||||
. 'var willOpen=p.hasAttribute("hidden");closeAllDetails();'
|
||||
. 'if(willOpen){p.removeAttribute("hidden");t.setAttribute("aria-expanded","true");'
|
||||
. 'var s=p.querySelector(".aksrt-status-select");if(s)s.focus();}'
|
||||
. 'return;}'
|
||||
. 'var c=el&&el.closest?el.closest(".aksrt-after-sale-cancel"):null;'
|
||||
. 'if(c){e.preventDefault();closeAllDetails();return;}'
|
||||
. 'var a=el&&el.closest?el.closest(".aksrt-after-sale-confirm"):null;'
|
||||
. 'if(!a)return;e.preventDefault();'
|
||||
. 'var panel=a.closest(".aksrt-as-detail-row");'
|
||||
. 'var sel=panel?panel.querySelector(".aksrt-status-select"):null;'
|
||||
. 'var noteEl=panel?panel.querySelector(".aksrt-review-note"):null;'
|
||||
. 'var refundEl=panel?panel.querySelector(".aksrt-mark-order-refunded"):null;'
|
||||
. 'var markRefund=!!(refundEl&&refundEl.checked&&!refundEl.disabled);'
|
||||
. 'var opt=sel?sel.options[sel.selectedIndex]:null;'
|
||||
. 'if(!sel||!opt||opt.disabled){alert(' . wp_json_encode(__('请选择售后状态。', 'aksrt')) . ');return;}'
|
||||
. 'var confirmText=tConfirmStatus+"「"+opt.text+"」?";'
|
||||
. 'if(markRefund)confirmText+=' . wp_json_encode(__('同时会将关联订单标记为已退款。', 'aksrt')) . ';'
|
||||
. 'if(!confirm(confirmText))return;'
|
||||
. 'var f=document.createElement("form");f.method="post";f.action=postUrl;'
|
||||
. 'var fields={action:"aksrt_after_sale_update",after_sale_id:a.getAttribute("data-id"),after_sale_status:sel.value,mark_order_refunded:markRefund?"1":"",_wpnonce:a.getAttribute("data-nonce"),admin_note:noteEl?noteEl.value:""};'
|
||||
. 'Object.keys(fields).forEach(function(k){var i=document.createElement("input");i.type="hidden";i.name=k;i.value=fields[k];f.appendChild(i);});'
|
||||
. 'document.body.appendChild(f);f.submit();'
|
||||
. '});'
|
||||
. '})();</script>';
|
||||
}
|
||||
|
||||
/** 售后状态白名单:admin_list() 据此过滤,count() 不校验,故查询前先按它归一。 */
|
||||
private function after_sale_statuses(): array {
|
||||
return [
|
||||
\AKSRT\Payment\AfterSaleService::STATUS_PENDING,
|
||||
\AKSRT\Payment\AfterSaleService::STATUS_PROCESSING,
|
||||
\AKSRT\Payment\AfterSaleService::STATUS_COMPLETED,
|
||||
\AKSRT\Payment\AfterSaleService::STATUS_REJECTED,
|
||||
\AKSRT\Payment\AfterSaleService::STATUS_CANCELLED,
|
||||
];
|
||||
}
|
||||
|
||||
/** 售后状态中文标签:订单明细页与售后面板(含筛选下拉)共用,取代两处内联数组。 */
|
||||
/** 处理记录列表样式:两个后台页面各自输出同一套规则,不依赖对方加载。 */
|
||||
private function after_sale_log_css(): string {
|
||||
return '.aksrt-as-log{display:grid;gap:5px;margin:6px 0 0;padding:0;list-style:none;}'
|
||||
. '.aksrt-as-log__item{display:flex;flex-wrap:wrap;gap:3px 10px;align-items:baseline;font-size:12px;line-height:1.7;}'
|
||||
. '.aksrt-as-log__time{color:#646970;font-variant-numeric:tabular-nums;}'
|
||||
. '.aksrt-as-log__status{font-weight:600;}'
|
||||
. '.aksrt-as-log__item.is-pending .aksrt-as-log__status{color:#b26200;}'
|
||||
. '.aksrt-as-log__item.is-processing .aksrt-as-log__status{color:#2271b1;}'
|
||||
. '.aksrt-as-log__item.is-completed .aksrt-as-log__status{color:#007017;}'
|
||||
. '.aksrt-as-log__item.is-rejected .aksrt-as-log__status,.aksrt-as-log__item.is-cancelled .aksrt-as-log__status{color:#b32d2e;}'
|
||||
. '.aksrt-as-log__note{flex:1 1 100%;color:#3c434a;}';
|
||||
}
|
||||
|
||||
private function after_sale_status_labels(): array {
|
||||
return [
|
||||
\AKSRT\Payment\AfterSaleService::STATUS_PENDING => '待审核',
|
||||
\AKSRT\Payment\AfterSaleService::STATUS_PROCESSING => '处理中',
|
||||
\AKSRT\Payment\AfterSaleService::STATUS_COMPLETED => '已完成',
|
||||
\AKSRT\Payment\AfterSaleService::STATUS_REJECTED => '已拒绝',
|
||||
\AKSRT\Payment\AfterSaleService::STATUS_CANCELLED => '已取消',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理记录列表:每一次状态提交一条「时间 + 状态 + 备注」,备注为空也留痕。
|
||||
* 后台订单明细页与售后面板共用同一套标记与样式。
|
||||
*
|
||||
* @param list<array<string,mixed>> $log
|
||||
* @param array<string,string> $status_labels
|
||||
*/
|
||||
private function render_after_sale_log(array $log, array $status_labels): string {
|
||||
if (!$log) {
|
||||
return '<p class="description">' . esc_html__('暂无处理记录', 'aksrt') . '</p>';
|
||||
}
|
||||
$service = \AKSRT\Payment\AfterSaleService::class;
|
||||
$html = '<ul class="aksrt-as-log">';
|
||||
foreach ($log as $entry) {
|
||||
$status = (string) ($entry['status'] ?? '');
|
||||
$label = (string) ($entry['source'] ?? '') === $service::SOURCE_USER
|
||||
? ($status === $service::STATUS_CANCELLED ? esc_html__('用户撤销售后', 'aksrt') : esc_html__('用户提交申请', 'aksrt'))
|
||||
: ($status_labels[$status] ?? $status);
|
||||
$note = (string) ($entry['note'] ?? '');
|
||||
$html .= '<li class="aksrt-as-log__item is-' . esc_attr($status) . '">'
|
||||
. '<span class="aksrt-as-log__time">' . esc_html((string) ($entry['created_at'] ?? '')) . '</span>'
|
||||
. '<span class="aksrt-as-log__status">' . esc_html($label) . '</span>'
|
||||
. '<span class="aksrt-as-log__note">' . ($note !== '' ? nl2br(esc_html($note)) : '—') . '</span>'
|
||||
. '</li>';
|
||||
}
|
||||
return $html . '</ul>';
|
||||
}
|
||||
|
||||
public function handle_after_sale_status(): void {
|
||||
if (!current_user_can(self::CAPABILITY)) wp_die(esc_html__('权限不足', 'aksrt'));
|
||||
$id = isset($_POST['after_sale_id']) ? absint($_POST['after_sale_id']) : 0;
|
||||
check_admin_referer('aksrt_after_sale_status_' . $id);
|
||||
$status = isset($_POST['after_sale_status']) ? sanitize_key((string) wp_unslash($_POST['after_sale_status'])) : '';
|
||||
$note = isset($_POST['admin_note']) ? sanitize_textarea_field((string) wp_unslash($_POST['admin_note'])) : '';
|
||||
$mark_refund = isset($_POST['mark_order_refunded']) && (string) wp_unslash($_POST['mark_order_refunded']) === '1';
|
||||
|
||||
// 勾选「标记订单已退款」时先落订单状态:售后工单一旦离开 pending/processing,
|
||||
// OrderRepository 的退款闸门就不再放行,所以顺序不能反。
|
||||
if ($mark_refund && $this->mark_related_order_refunded($id) === 'failed') {
|
||||
wp_safe_redirect($this->page_url(self::PAGE_AFTER_SALES_SLUG, ['aksrt_pay_msg' => 'after_sale_order_refund_failed']));
|
||||
exit;
|
||||
}
|
||||
$result = \AKSRT\Payment\AfterSaleService::get_instance()->set_status($id, $status, $note);
|
||||
$args = ['aksrt_pay_msg' => is_wp_error($result) ? 'after_sale_update_failed' : 'after_sale_updated'];
|
||||
if (is_wp_error($result)) $args['after_sale_error'] = substr($result->get_error_message(), 0, 240);
|
||||
elseif ($mark_refund) $args['aksrt_pay_msg'] = 'after_sale_updated_order_refunded';
|
||||
wp_safe_redirect($this->page_url(self::PAGE_AFTER_SALES_SLUG, $args)); exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把售后申请关联的订单推到已退款。仅在该工单仍为待审核/处理中时放行,
|
||||
* 避免对已关闭的工单或非已支付订单误标退款。
|
||||
*
|
||||
* @return string 'done' | 'failed'
|
||||
*/
|
||||
private function mark_related_order_refunded(int $after_sale_id): string {
|
||||
if (!class_exists('\AKSRT\Payment\AfterSaleService') || !class_exists('\AKSRT\Payment\OrderRepository')) return 'failed';
|
||||
$service = \AKSRT\Payment\AfterSaleService::get_instance();
|
||||
$row = $service->get($after_sale_id);
|
||||
$order_number = is_array($row) ? (string) ($row['order_number'] ?? '') : '';
|
||||
if ($order_number === '' || !$service->can_finalize_order_refund($after_sale_id, $order_number)) return 'failed';
|
||||
|
||||
$repo = \AKSRT\Payment\OrderRepository::get_instance();
|
||||
$order = $repo->get($order_number);
|
||||
if (is_array($order) && (string) ($order['status'] ?? '') === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED) return 'done';
|
||||
return $repo->update_status($order_number, \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED, [
|
||||
'allow_refund' => true,
|
||||
'after_sale_id' => $after_sale_id,
|
||||
]) ? 'done' : 'failed';
|
||||
}
|
||||
|
||||
public function handle_update_order_status(): void {
|
||||
if (!current_user_can(self::CAPABILITY)) {
|
||||
wp_die(esc_html__('权限不足', 'aksrt'));
|
||||
@@ -753,8 +383,10 @@ class PayOrders {
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单列表「退款」:把已支付订单推到已退款。退款必须落到一条进行中的售后工单上,
|
||||
* 工单号由服务端按订单号反查,不经客户端传递。
|
||||
* 订单列表「退款」:把已支付订单推到已退款。
|
||||
*
|
||||
* 退款闸门只剩「订单当前为已支付」——工单系统与订单退款已解耦,
|
||||
* 不再要求先有一条进行中的工单。
|
||||
*/
|
||||
public function handle_refund_order(): void {
|
||||
if (!current_user_can(self::CAPABILITY)) {
|
||||
@@ -773,19 +405,11 @@ class PayOrders {
|
||||
} elseif ((string) ($order['status'] ?? '') !== \AKSRT\Core\DomainModel::ORDER_STATUS_PAID) {
|
||||
$code = 'order_refund_not_paid';
|
||||
} else {
|
||||
$ticket = class_exists('\AKSRT\Payment\AfterSaleService')
|
||||
? \AKSRT\Payment\AfterSaleService::get_instance()->find_active($order_number)
|
||||
: null;
|
||||
if (!is_array($ticket)) {
|
||||
$code = 'order_refund_no_ticket';
|
||||
} else {
|
||||
$ok = $repo->update_status($order_number, \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED, [
|
||||
'allow_refund' => true,
|
||||
'after_sale_id' => (int) ($ticket['id'] ?? 0),
|
||||
'note' => __('管理员在订单列表中标记退款', 'aksrt'),
|
||||
]);
|
||||
$code = $ok ? 'order_refunded' : 'order_refund_failed';
|
||||
}
|
||||
$ok = $repo->update_status($order_number, \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED, [
|
||||
'allow_refund' => true,
|
||||
'note' => __('管理员在订单列表中标记退款', 'aksrt'),
|
||||
]);
|
||||
$code = $ok ? 'order_refunded' : 'order_refund_failed';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -935,7 +559,6 @@ class PayOrders {
|
||||
'order_refund_failed' => __('订单标记退款失败。', 'aksrt'),
|
||||
'order_refund_missing' => __('订单不存在。', 'aksrt'),
|
||||
'order_refund_not_paid' => __('仅已支付订单可以标记退款。', 'aksrt'),
|
||||
'order_refund_no_ticket' => __('该订单没有进行中的售后工单,无法标记退款;请先在「售后服务」中处理。', 'aksrt'),
|
||||
'order_note_updated' => __('订单备注已保存。', 'aksrt'),
|
||||
'order_note_failed' => __('订单备注保存失败。', 'aksrt'),
|
||||
'orders_cleared' => sprintf(
|
||||
@@ -950,21 +573,12 @@ class PayOrders {
|
||||
'shipping_failed' => __('物流状态更新失败。', 'aksrt'),
|
||||
'shipping_created' => __('发货记录已创建。', 'aksrt'),
|
||||
'shipping_create_failed' => __('发货记录创建失败。', 'aksrt'),
|
||||
'after_sale_created' => __('售后工单已创建。', 'aksrt'),
|
||||
'after_sale_create_failed' => __('售后工单创建失败。', 'aksrt'),
|
||||
'after_sale_updated' => __('售后状态已更新。', 'aksrt'),
|
||||
'after_sale_updated_order_refunded' => __('售后状态已更新,关联订单已标记为已退款。', 'aksrt'),
|
||||
'after_sale_order_refund_failed' => __('关联订单标记已退款失败:需售后工单仍为待审核/处理中,且订单为已支付。', 'aksrt'),
|
||||
'after_sale_update_failed' => __('售后状态更新失败,请查看申请状态和错误原因。', 'aksrt'),
|
||||
];
|
||||
if (!isset($map[$code])) {
|
||||
return;
|
||||
}
|
||||
$class = str_contains($code, 'failed') ? 'notice-error' : 'notice-success';
|
||||
echo '<div class="notice ' . esc_attr($class) . ' is-dismissible"><p>' . esc_html($map[$code]) . '</p></div>';
|
||||
if ($code === 'after_sale_update_failed' && isset($_GET['after_sale_error'])) {
|
||||
echo '<div class="notice notice-error"><p>' . esc_html(sanitize_text_field((string) wp_unslash($_GET['after_sale_error']))) . '</p></div>';
|
||||
}
|
||||
}
|
||||
|
||||
private function page_url(string $slug, array $args = []): string {
|
||||
|
||||
@@ -13,6 +13,7 @@ class UsersMenus {
|
||||
use UsersMenusVip;
|
||||
use UsersMenusInviteCode;
|
||||
use UsersMenusTickets;
|
||||
use UsersMenusTicketsCenter;
|
||||
|
||||
public const CAPABILITY = 'manage_options';
|
||||
|
||||
@@ -38,6 +39,7 @@ class UsersMenus {
|
||||
add_action('admin_post_aksrt_users_update_report', [$this, 'handle_update_report']);
|
||||
add_action('admin_post_aksrt_users_process_appeal', [$this, 'handle_process_appeal']);
|
||||
add_action('admin_post_aksrt_users_mutate_vip', [$this, 'handle_mutate_vip']);
|
||||
add_action('admin_post_aksrt_ticket_update', [$this, 'handle_ticket_update']);
|
||||
}
|
||||
|
||||
public function register_menus(): void {
|
||||
@@ -86,6 +88,14 @@ class UsersMenus {
|
||||
'aksrt_user_ban',
|
||||
[$this, 'render_ban_page']
|
||||
);
|
||||
add_submenu_page(
|
||||
'users.php',
|
||||
__('工单管理', 'aksrt'),
|
||||
__('工单管理', 'aksrt'),
|
||||
self::CAPABILITY,
|
||||
'aksrt_tickets',
|
||||
[$this, 'render_tickets_page']
|
||||
);
|
||||
}
|
||||
|
||||
public function pending_notices(): void {
|
||||
@@ -136,6 +146,20 @@ class UsersMenus {
|
||||
echo '<p><a class="button button-primary" href="' . esc_url($url) . '">' . esc_html__('立即处理', 'aksrt') . '</a></p></div>';
|
||||
}
|
||||
}
|
||||
|
||||
if ($page !== 'aksrt_tickets' && class_exists('\AKSRT\Ticket\TicketSettings') && \AKSRT\Ticket\TicketSettings::enabled()) {
|
||||
$pending_tickets = $this->count_pending_tickets();
|
||||
if ($pending_tickets > 0) {
|
||||
$url = admin_url('users.php?page=aksrt_tickets&status=' . \AKSRT\Ticket\TicketStatusService::KEY_PENDING);
|
||||
echo '<div class="notice notice-info is-dismissible"><h3>' . esc_html__('工单待接单', 'aksrt') . '</h3>';
|
||||
echo '<p>' . esc_html(sprintf(
|
||||
/* translators: %d count */
|
||||
__('您有 %d 个工单待接单', 'aksrt'),
|
||||
$pending_tickets
|
||||
)) . '</p>';
|
||||
echo '<p><a class="button button-primary" href="' . esc_url($url) . '">' . esc_html__('立即处理', 'aksrt') . '</a></p></div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function ensure_cap(): void {
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
<?php
|
||||
/**
|
||||
* 工单管理后台页(users.php → aksrt_tickets)。
|
||||
*
|
||||
* 两个 Tab:
|
||||
* 1. tickets — 工单列表:筛选 + 排序表头 + 行内展开详情(沟通记录 / 回复 / 备注 / 状态迁移)
|
||||
* 2. config — 配置:部门 / 状态 / 预设回复
|
||||
*
|
||||
* 写操作走 admin_post(非 REST),与本站其它原生后台页一致;前端 JS 见
|
||||
* assets/js/admin-ticket-center.js。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Admin\Native;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait UsersMenusTicketsCenter {
|
||||
|
||||
use UsersMenusTicketsList;
|
||||
use UsersMenusTicketsConfig;
|
||||
|
||||
private const TICKET_PAGE_SLUG = 'aksrt_tickets';
|
||||
private const TICKET_PER_PAGE = 20;
|
||||
|
||||
/** 待接单工单数(后台 admin_notices 用)。 */
|
||||
private function count_pending_tickets(): int {
|
||||
if (!class_exists('\AKSRT\Ticket\TicketRepository') || !class_exists('\AKSRT\Ticket\TicketStatusService')) {
|
||||
return 0;
|
||||
}
|
||||
return \AKSRT\Ticket\TicketRepository::get_instance()->count_by_status(
|
||||
\AKSRT\Ticket\TicketStatusService::KEY_PENDING
|
||||
);
|
||||
}
|
||||
|
||||
public function render_tickets_page(): void {
|
||||
if (!current_user_can(self::CAPABILITY)) {
|
||||
wp_die(esc_html__('权限不足', 'aksrt'));
|
||||
}
|
||||
$tab = isset($_GET['tab']) ? sanitize_key((string) wp_unslash($_GET['tab'])) : 'tickets';
|
||||
$tab = $tab === 'config' ? 'config' : 'tickets';
|
||||
|
||||
echo '<div class="wrap">';
|
||||
echo '<h1>' . esc_html__('工单管理', 'aksrt') . '</h1>';
|
||||
echo '<style>' . $this->ticket_page_css() . '</style>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
|
||||
$this->render_ticket_notices();
|
||||
echo '<h2 class="nav-tab-wrapper">';
|
||||
foreach (['tickets' => __('工单列表', 'aksrt'), 'config' => __('配置', 'aksrt')] as $key => $label) {
|
||||
printf(
|
||||
'<a href="%s" class="nav-tab %s">%s</a>',
|
||||
esc_url(admin_url('users.php?page=' . self::TICKET_PAGE_SLUG . '&tab=' . $key)),
|
||||
$tab === $key ? 'nav-tab-active' : '',
|
||||
esc_html($label)
|
||||
);
|
||||
}
|
||||
echo '</h2>';
|
||||
|
||||
if ($tab === 'config') {
|
||||
$this->render_ticket_config_tab();
|
||||
} else {
|
||||
$this->render_ticket_list_tab();
|
||||
}
|
||||
echo '</div>';
|
||||
}
|
||||
|
||||
/** 写操作后的 redirect 提示(aksrt_pay_msg 同款模式)。 */
|
||||
private function render_ticket_notices(): void {
|
||||
if (empty($_GET['aksrt_ticket_msg'])) {
|
||||
return;
|
||||
}
|
||||
$code = sanitize_key((string) wp_unslash($_GET['aksrt_ticket_msg']));
|
||||
$messages = [
|
||||
'updated' => __('工单状态已更新。', 'aksrt'),
|
||||
'failed' => __('工单状态更新失败:当前状态不允许该操作。', 'aksrt'),
|
||||
'replied' => __('回复已提交。', 'aksrt'),
|
||||
'reply_failed' => __('回复提交失败。', 'aksrt'),
|
||||
'type_saved' => __('部门已保存。', 'aksrt'),
|
||||
'type_deleted' => __('部门已删除。', 'aksrt'),
|
||||
'type_in_use' => __('该部门下还有工单,无法删除。', 'aksrt'),
|
||||
'status_saved' => __('状态已保存。', 'aksrt'),
|
||||
'status_deleted' => __('状态已删除。', 'aksrt'),
|
||||
'status_default' => __('默认状态不可修改或删除。', 'aksrt'),
|
||||
'status_in_use' => __('该状态下还有工单,无法删除。', 'aksrt'),
|
||||
'prereply_saved' => __('预设回复已保存。', 'aksrt'),
|
||||
'prereply_deleted' => __('预设回复已删除。', 'aksrt'),
|
||||
'prereply_order_failed' => __('预设回复排序失败:需要提交全部条目。', 'aksrt'),
|
||||
'note_saved' => __('备注已保存。', 'aksrt'),
|
||||
'note_deleted' => __('备注已删除。', 'aksrt'),
|
||||
'created' => __('工单已创建。', 'aksrt'),
|
||||
'create_failed' => __('工单创建失败。', 'aksrt'),
|
||||
];
|
||||
if (!isset($messages[$code])) {
|
||||
return;
|
||||
}
|
||||
printf('<div class="notice notice-success is-dismissible"><p>%s</p></div>', esc_html($messages[$code]));
|
||||
}
|
||||
|
||||
/**
|
||||
* 写操作:统一入口,按 $action 分发。nonce 以 action + id 为单位,
|
||||
* 与 PayOrders 的行内处理表单同模式。
|
||||
*/
|
||||
public function handle_ticket_update(): void {
|
||||
if (!current_user_can(self::CAPABILITY)) {
|
||||
wp_die(esc_html__('权限不足', 'aksrt'));
|
||||
}
|
||||
$action = isset($_POST['ticket_action']) ? sanitize_key((string) wp_unslash($_POST['ticket_action'])) : '';
|
||||
$id = isset($_POST['ticket_id']) ? absint($_POST['ticket_id']) : 0;
|
||||
check_admin_referer('aksrt_ticket_' . $action . '_' . $id);
|
||||
|
||||
$code = 'failed';
|
||||
switch ($action) {
|
||||
case 'status':
|
||||
$code = $this->ticket_action_status($id);
|
||||
break;
|
||||
case 'reply':
|
||||
$code = $this->ticket_action_reply($id);
|
||||
break;
|
||||
case 'type':
|
||||
$code = $this->ticket_action_type();
|
||||
break;
|
||||
case 'status_def':
|
||||
$code = $this->ticket_action_status_def();
|
||||
break;
|
||||
case 'prereply':
|
||||
$code = $this->ticket_action_prereply();
|
||||
break;
|
||||
case 'prereply_delete':
|
||||
$code = $this->ticket_action_prereply_delete();
|
||||
break;
|
||||
case 'type_delete':
|
||||
$code = $this->ticket_action_type_delete();
|
||||
break;
|
||||
case 'status_delete':
|
||||
$code = $this->ticket_action_status_delete();
|
||||
break;
|
||||
case 'note':
|
||||
$code = $this->ticket_action_note($id);
|
||||
break;
|
||||
case 'create':
|
||||
$code = $this->ticket_action_create();
|
||||
break;
|
||||
}
|
||||
|
||||
$args = ['page' => self::TICKET_PAGE_SLUG, 'aksrt_ticket_msg' => $code];
|
||||
if (isset($_POST['ticket_tab'])) {
|
||||
$args['tab'] = sanitize_key((string) wp_unslash($_POST['ticket_tab']));
|
||||
}
|
||||
wp_safe_redirect(add_query_arg($args, admin_url('users.php')));
|
||||
exit;
|
||||
}
|
||||
|
||||
/** @return string 提示码 */
|
||||
private function ticket_action_status(int $id): string {
|
||||
if ($id <= 0 || !class_exists('\AKSRT\Ticket\TicketRepository')) {
|
||||
return 'failed';
|
||||
}
|
||||
$repo = \AKSRT\Ticket\TicketRepository::get_instance();
|
||||
$ticket = $repo->find($id);
|
||||
if ($ticket === null) {
|
||||
return 'failed';
|
||||
}
|
||||
$to = isset($_POST['status']) ? sanitize_key((string) wp_unslash($_POST['status'])) : '';
|
||||
if (!\AKSRT\Ticket\TicketStatusService::can_transition((string) $ticket['status'], $to)) {
|
||||
return 'failed';
|
||||
}
|
||||
$fields = ['status' => $to];
|
||||
$type_id = isset($_POST['ticket_type_id']) ? absint($_POST['ticket_type_id']) : 0;
|
||||
if ($type_id > 0 && \AKSRT\Ticket\TicketTypeService::exists($type_id)) {
|
||||
$fields['ticket_type_id'] = $type_id;
|
||||
}
|
||||
$admin_id = isset($_POST['admin_id']) ? absint($_POST['admin_id']) : 0;
|
||||
if ($admin_id > 0 && get_userdata($admin_id)) {
|
||||
$fields['admin_id'] = $admin_id;
|
||||
}
|
||||
if (!$repo->update_fields($id, $fields)) {
|
||||
return 'failed';
|
||||
}
|
||||
$ticket = array_merge($ticket, $fields);
|
||||
if ($to === \AKSRT\Ticket\TicketStatusService::KEY_CLOSED) {
|
||||
\AKSRT\Ticket\TicketNotifier::closed($ticket, 'admin');
|
||||
}
|
||||
return 'updated';
|
||||
}
|
||||
|
||||
/** @return string 提示码 */
|
||||
private function ticket_action_reply(int $id): string {
|
||||
if ($id <= 0 || !class_exists('\AKSRT\Ticket\TicketRepository')) {
|
||||
return 'reply_failed';
|
||||
}
|
||||
$repo = \AKSRT\Ticket\TicketRepository::get_instance();
|
||||
$ticket = $repo->find($id);
|
||||
if ($ticket === null || !empty($ticket['is_closed'])) {
|
||||
return 'reply_failed';
|
||||
}
|
||||
$content = isset($_POST['content']) ? sanitize_textarea_field((string) wp_unslash($_POST['content'])) : '';
|
||||
$content = trim($content);
|
||||
if ($content === '' || mb_strlen($content) > 3000) {
|
||||
return 'reply_failed';
|
||||
}
|
||||
$reply_id = $repo->create_reply($id, 'admin', get_current_user_id(), $content, [], 0);
|
||||
if ($reply_id <= 0) {
|
||||
return 'reply_failed';
|
||||
}
|
||||
$repo->touch_reply($id, 'admin', get_current_user_id());
|
||||
$repo->apply_reply_status($id, $ticket, 'admin');
|
||||
\AKSRT\Ticket\TicketNotifier::user_replied($repo->find($id) ?? $ticket);
|
||||
return 'replied';
|
||||
}
|
||||
|
||||
/** @return string 提示码 */
|
||||
private function ticket_action_type(): string {
|
||||
if (!class_exists('\AKSRT\Ticket\TicketTypeService')) {
|
||||
return 'failed';
|
||||
}
|
||||
$id = isset($_POST['type_id']) ? absint($_POST['type_id']) : 0;
|
||||
$name = isset($_POST['name']) ? sanitize_text_field((string) wp_unslash($_POST['name'])) : '';
|
||||
$name = trim($name);
|
||||
$description = isset($_POST['description']) ? sanitize_text_field((string) wp_unslash($_POST['description'])) : '';
|
||||
$admin_ids = isset($_POST['admin_ids']) ? array_map('absint', (array) $_POST['admin_ids']) : [];
|
||||
|
||||
if ($name === '' || \AKSRT\Ticket\TicketTypeService::name_taken($name, $id)) {
|
||||
return 'failed';
|
||||
}
|
||||
$payload = [
|
||||
'name' => $name,
|
||||
'description' => $description,
|
||||
'admin_ids' => \AKSRT\Ticket\TicketTypeService::sanitize_admin_ids($admin_ids),
|
||||
];
|
||||
if ($id > 0) {
|
||||
if (!\AKSRT\Ticket\TicketTypeService::exists($id)) {
|
||||
return 'failed';
|
||||
}
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket_type');
|
||||
$saved = $wpdb->update($table, [
|
||||
'name' => $payload['name'],
|
||||
'description' => $payload['description'],
|
||||
'admin_ids' => wp_json_encode($payload['admin_ids']),
|
||||
'updated_at' => current_time('mysql'),
|
||||
], ['id' => $id], ['%s', '%s', '%s', '%s'], ['%d']);
|
||||
if ($saved === false) {
|
||||
return 'failed';
|
||||
}
|
||||
\AKSRT\Ticket\TicketTypeService::flush();
|
||||
return 'type_saved';
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket_type');
|
||||
if ($table === '') {
|
||||
return 'failed';
|
||||
}
|
||||
$max = (int) $wpdb->get_var("SELECT MAX(sort_order) FROM {$table}"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$inserted = $wpdb->insert($table, [
|
||||
'name' => $payload['name'],
|
||||
'description' => $payload['description'],
|
||||
'admin_ids' => wp_json_encode($payload['admin_ids']),
|
||||
'sort_order' => $max + 1,
|
||||
'created_at' => current_time('mysql'),
|
||||
], ['%s', '%s', '%s', '%d', '%s']);
|
||||
if ($inserted !== 1) {
|
||||
return 'failed';
|
||||
}
|
||||
\AKSRT\Ticket\TicketTypeService::flush();
|
||||
return 'type_saved';
|
||||
}
|
||||
|
||||
/** @return string 提示码 */
|
||||
private function ticket_action_status_def(): string {
|
||||
if (!class_exists('\AKSRT\Ticket\TicketStatusService')) {
|
||||
return 'failed';
|
||||
}
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket_status');
|
||||
if ($table === '') {
|
||||
return 'failed';
|
||||
}
|
||||
$id = isset($_POST['status_id']) ? absint($_POST['status_id']) : 0;
|
||||
$name = isset($_POST['name']) ? sanitize_text_field((string) wp_unslash($_POST['name'])) : '';
|
||||
$name = trim($name);
|
||||
$color = \AKSRT\Ticket\TicketStatusService::sanitize_color(
|
||||
(string) wp_unslash($_POST['color'] ?? '')
|
||||
);
|
||||
$is_closed = isset($_POST['is_closed']) ? 1 : 0;
|
||||
|
||||
if ($name === '') {
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
if ($id > 0) {
|
||||
$row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM {$table} WHERE id = %d", // phpcs:ignore WordPress.DB.PreparedSQL
|
||||
$id
|
||||
), ARRAY_A);
|
||||
if (!is_array($row)) {
|
||||
return 'failed';
|
||||
}
|
||||
if (!empty($row['is_default'])) {
|
||||
return 'status_default';
|
||||
}
|
||||
$saved = $wpdb->update($table, [
|
||||
'name' => $name,
|
||||
'color' => $color,
|
||||
'is_closed' => $is_closed,
|
||||
'updated_at' => current_time('mysql'),
|
||||
], ['id' => $id], ['%s', '%s', '%d', '%s'], ['%d']);
|
||||
if ($saved === false) {
|
||||
return 'failed';
|
||||
}
|
||||
\AKSRT\Ticket\TicketStatusService::flush();
|
||||
return 'status_saved';
|
||||
}
|
||||
|
||||
$max = (int) $wpdb->get_var("SELECT MAX(sort_order) FROM {$table}"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$inserted = $wpdb->insert($table, [
|
||||
'status_key' => \AKSRT\Ticket\TicketStatusService::make_key($name),
|
||||
'name' => $name,
|
||||
'color' => $color,
|
||||
'is_closed' => $is_closed,
|
||||
'sort_order' => $max + 1,
|
||||
'created_at' => current_time('mysql'),
|
||||
], ['%s', '%s', '%s', '%d', '%d', '%s']);
|
||||
if ($inserted !== 1) {
|
||||
return 'failed';
|
||||
}
|
||||
\AKSRT\Ticket\TicketStatusService::flush();
|
||||
return 'status_saved';
|
||||
}
|
||||
|
||||
/** @return string 提示码 */
|
||||
private function ticket_action_prereply(): string {
|
||||
if (!class_exists('\AKSRT\Ticket\TicketPrereplyService')) {
|
||||
return 'failed';
|
||||
}
|
||||
$id = isset($_POST['prereply_id']) ? absint($_POST['prereply_id']) : 0;
|
||||
$content = isset($_POST['content']) ? sanitize_textarea_field((string) wp_unslash($_POST['content'])) : '';
|
||||
$content = trim($content);
|
||||
if ($content === '') {
|
||||
return 'failed';
|
||||
}
|
||||
if ($id > 0) {
|
||||
if (\AKSRT\Ticket\TicketPrereplyService::find($id) === null) {
|
||||
return 'failed';
|
||||
}
|
||||
return \AKSRT\Ticket\TicketPrereplyService::update($id, $content) ? 'prereply_saved' : 'failed';
|
||||
}
|
||||
return \AKSRT\Ticket\TicketPrereplyService::create($content) > 0 ? 'prereply_saved' : 'failed';
|
||||
}
|
||||
|
||||
/** @return string 提示码 */
|
||||
private function ticket_action_prereply_delete(): string {
|
||||
$id = isset($_POST['prereply_id']) ? absint($_POST['prereply_id']) : 0;
|
||||
if ($id <= 0 || !class_exists('\AKSRT\Ticket\TicketPrereplyService')) {
|
||||
return 'failed';
|
||||
}
|
||||
return \AKSRT\Ticket\TicketPrereplyService::delete($id) ? 'prereply_deleted' : 'failed';
|
||||
}
|
||||
|
||||
/** @return string 提示码 */
|
||||
private function ticket_action_type_delete(): string {
|
||||
$id = isset($_POST['type_id']) ? absint($_POST['type_id']) : 0;
|
||||
if ($id <= 0 || !class_exists('\AKSRT\Ticket\TicketTypeService')) {
|
||||
return 'failed';
|
||||
}
|
||||
if (\AKSRT\Ticket\TicketRepository::get_instance()->count_by_type($id) > 0) {
|
||||
return 'type_in_use';
|
||||
}
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket_type');
|
||||
if ($table === '') {
|
||||
return 'failed';
|
||||
}
|
||||
$wpdb->delete($table, ['id' => $id], ['%d']);
|
||||
\AKSRT\Ticket\TicketTypeService::flush();
|
||||
return 'type_deleted';
|
||||
}
|
||||
|
||||
/** @return string 提示码 */
|
||||
private function ticket_action_status_delete(): string {
|
||||
$id = isset($_POST['status_id']) ? absint($_POST['status_id']) : 0;
|
||||
if ($id <= 0 || !class_exists('\AKSRT\Ticket\TicketStatusService')) {
|
||||
return 'failed';
|
||||
}
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket_status');
|
||||
if ($table === '') {
|
||||
return 'failed';
|
||||
}
|
||||
$row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM {$table} WHERE id = %d", // phpcs:ignore WordPress.DB.PreparedSQL
|
||||
$id
|
||||
), ARRAY_A);
|
||||
if (!is_array($row)) {
|
||||
return 'failed';
|
||||
}
|
||||
if (!empty($row['is_default'])) {
|
||||
return 'status_default';
|
||||
}
|
||||
$key = sanitize_key((string) $row['status_key']);
|
||||
if (\AKSRT\Ticket\TicketRepository::get_instance()->count_by_status($key) > 0) {
|
||||
return 'status_in_use';
|
||||
}
|
||||
$wpdb->delete($table, ['id' => $id], ['%d']);
|
||||
\AKSRT\Ticket\TicketStatusService::flush();
|
||||
return 'status_deleted';
|
||||
}
|
||||
|
||||
/** @return string 提示码 */
|
||||
private function ticket_action_note(int $id): string { if ($id <= 0 || !class_exists('\AKSRT\Ticket\TicketNoteService')) {
|
||||
return 'failed';
|
||||
}
|
||||
$note_id = isset($_POST['note_id']) ? absint($_POST['note_id']) : 0;
|
||||
$content = isset($_POST['content']) ? sanitize_textarea_field((string) wp_unslash($_POST['content'])) : '';
|
||||
$content = trim($content);
|
||||
if ($content === '') {
|
||||
return 'failed';
|
||||
}
|
||||
if ($note_id > 0) {
|
||||
return \AKSRT\Ticket\TicketNoteService::update($note_id, $content) ? 'note_saved' : 'failed';
|
||||
}
|
||||
return \AKSRT\Ticket\TicketNoteService::create($id, $content, get_current_user_id()) > 0
|
||||
? 'note_saved'
|
||||
: 'failed';
|
||||
}
|
||||
|
||||
/** @return string 提示码 */
|
||||
private function ticket_action_create(): string {
|
||||
if (!class_exists('\AKSRT\Ticket\TicketRepository')) {
|
||||
return 'create_failed';
|
||||
}
|
||||
$user_id = isset($_POST['user_id']) ? absint($_POST['user_id']) : 0;
|
||||
$title = isset($_POST['title']) ? sanitize_text_field((string) wp_unslash($_POST['title'])) : '';
|
||||
$title = trim($title);
|
||||
$content = isset($_POST['content']) ? sanitize_textarea_field((string) wp_unslash($_POST['content'])) : '';
|
||||
$content = trim($content);
|
||||
$type_id = isset($_POST['ticket_type_id']) ? absint($_POST['ticket_type_id']) : 0;
|
||||
|
||||
if ($user_id <= 0 || !get_userdata($user_id) || $title === '' || $content === '') {
|
||||
return 'create_failed';
|
||||
}
|
||||
if (!\AKSRT\Ticket\TicketTypeService::exists($type_id)) {
|
||||
return 'create_failed';
|
||||
}
|
||||
$id = \AKSRT\Ticket\TicketRepository::get_instance()->create([
|
||||
'ticket_num' => \AKSRT\Ticket\TicketNumber::generate(),
|
||||
'user_id' => $user_id,
|
||||
'title' => $title,
|
||||
'ticket_type_id' => $type_id,
|
||||
'content' => $content,
|
||||
'order_number' => isset($_POST['order_number']) ? sanitize_text_field((string) wp_unslash($_POST['order_number'])) : '',
|
||||
'status' => \AKSRT\Ticket\TicketStatusService::KEY_ADMIN_REPLIED,
|
||||
'post_admin_id' => get_current_user_id(),
|
||||
'admin_id' => get_current_user_id(),
|
||||
]);
|
||||
if ($id <= 0) {
|
||||
return 'create_failed';
|
||||
}
|
||||
$ticket = \AKSRT\Ticket\TicketRepository::get_instance()->find($id);
|
||||
if (is_array($ticket)) {
|
||||
\AKSRT\Ticket\TicketNotifier::user_replied($ticket);
|
||||
}
|
||||
return 'created';
|
||||
}
|
||||
|
||||
private function ticket_page_url(array $extra = []): string {
|
||||
return add_query_arg(
|
||||
array_merge(['page' => self::TICKET_PAGE_SLUG], $extra),
|
||||
admin_url('users.php')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 行内删除按钮:独立小表单 + 二次确认,nonce 以 action + id 为单位。
|
||||
* 删除类操作不占用列表主表单,避免误删。
|
||||
*/
|
||||
private function ticket_delete_button(string $action, string $field, int $id, string $label): string {
|
||||
return sprintf(
|
||||
'<form method="post" action="%s" style="display:inline;" onsubmit="return confirm(\'%s\');">'
|
||||
. '<input type="hidden" name="action" value="aksrt_ticket_update" />'
|
||||
. '<input type="hidden" name="ticket_action" value="%s" />'
|
||||
. '<input type="hidden" name="ticket_tab" value="config" />'
|
||||
. '<input type="hidden" name="%s" value="%d" />%s'
|
||||
. '<button type="submit" class="button-link-delete">%s</button></form>',
|
||||
esc_url(admin_url('admin-post.php')),
|
||||
esc_js(__('确认删除?', 'aksrt')),
|
||||
esc_attr($action),
|
||||
esc_attr($field),
|
||||
$id,
|
||||
wp_nonce_field('aksrt_ticket_' . $action . '_' . $id, '_wpnonce', true, false),
|
||||
esc_html($label)
|
||||
);
|
||||
}
|
||||
|
||||
/** 工单页共享样式(徽章取状态颜色,两个 Tab 都用)。 */
|
||||
private function ticket_page_css(): string {
|
||||
return '.aksrt-ticket-badge{display:inline-block;padding:2px 10px;border-radius:999px;'
|
||||
. 'background:color-mix(in srgb,var(--aksrt-ticket-color,#6b7280) 14%,transparent);'
|
||||
. 'color:var(--aksrt-ticket-color,#6b7280);font-size:12px;font-weight:600;line-height:1.6;}'
|
||||
. '.aksrt-ticket-detail{display:grid;gap:10px;padding:12px;border:1px solid #dcdcde;border-radius:6px;background:#fff;}'
|
||||
. '.aksrt-ticket-detail__content,.aksrt-ticket-detail__reply-body{white-space:pre-wrap;word-break:break-word;}'
|
||||
. '.aksrt-ticket-detail__replies{display:grid;gap:8px;margin:0;padding:0;list-style:none;}'
|
||||
. '.aksrt-ticket-detail__replies li{padding:10px 12px;border-left:3px solid #c3c4c7;border-radius:4px;background:#f6f7f7;}'
|
||||
. '.aksrt-ticket-detail__replies li.is-admin{border-left-color:#2271b1;background:#f0f6fc;}'
|
||||
. '.aksrt-ticket-detail__reply-head time{color:#646970;font-size:11px;}'
|
||||
. '.aksrt-ticket-detail__quote{margin:0 0 6px;padding:6px 10px;border-left:3px solid #dcdcde;background:#fff;color:#50575e;font-size:12px;}'
|
||||
. '.aksrt-ticket-detail__notes{display:grid;gap:6px;margin:0;padding:0;list-style:none;}'
|
||||
. '.aksrt-ticket-detail__notes time{color:#646970;font-size:11px;}'
|
||||
. '.aksrt-ticket-detail__closed{margin:0;color:#8c8f94;font-size:12px;}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
/**
|
||||
* 工单管理后台页 — 配置 Tab 渲染(部门 / 状态 / 预设回复)。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Admin\Native;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait UsersMenusTicketsConfig {
|
||||
|
||||
private function render_ticket_config_tab(): void {
|
||||
$this->render_ticket_types_panel();
|
||||
$this->render_ticket_statuses_panel();
|
||||
$this->render_ticket_prereplies_panel();
|
||||
}
|
||||
|
||||
/** 部门:表格 + 行内编辑 + 新建表单。 */
|
||||
private function render_ticket_types_panel(): void {
|
||||
$types = \AKSRT\Ticket\TicketTypeService::all();
|
||||
$edit_id = isset($_GET['type_id']) ? absint($_GET['type_id']) : 0;
|
||||
$editing = null;
|
||||
foreach ($types as $row) {
|
||||
if ((int) $row['id'] === $edit_id) {
|
||||
$editing = $row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
echo '<h3>' . esc_html__('工单部门', 'aksrt') . '</h3>';
|
||||
echo '<table class="widefat fixed striped posts" style="max-width:900px;">';
|
||||
echo '<thead><tr><th width="8%">ID</th><th width="24%">' . esc_html__('名称', 'aksrt') . '</th>'
|
||||
. '<th width="38%">' . esc_html__('说明', 'aksrt') . '</th>'
|
||||
. '<th width="20%">' . esc_html__('受理管理员', 'aksrt') . '</th><th width="10%"></th></tr></thead><tbody>';
|
||||
foreach ($types as $row) {
|
||||
$edit_url = add_query_arg(['type_id' => (int) $row['id']], $this->ticket_page_url(['tab' => 'config']));
|
||||
$cells = sprintf(
|
||||
'<tr><td>%d</td><td>%s</td><td>%s</td><td>%s</td><td>',
|
||||
(int) $row['id'],
|
||||
esc_html((string) $row['name']),
|
||||
esc_html((string) $row['description'] !== '' ? (string) $row['description'] : '—'),
|
||||
esc_html($row['admin_ids'] === [] ? '—' : implode(', ', $row['admin_ids']))
|
||||
);
|
||||
$cells .= '<a href="' . esc_url($edit_url) . '">' . esc_html__('编辑', 'aksrt') . '</a> ';
|
||||
$cells .= $this->ticket_delete_button('type_delete', 'type_id', (int) $row['id'], __('删除', 'aksrt'));
|
||||
$cells .= '</td></tr>';
|
||||
echo $cells; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
}
|
||||
echo '</tbody></table>';
|
||||
|
||||
echo '<h4>' . esc_html($editing === null ? __('新增部门', 'aksrt') : __('编辑部门', 'aksrt')) . '</h4>';
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_ticket_update" />';
|
||||
echo '<input type="hidden" name="ticket_action" value="type" />';
|
||||
echo '<input type="hidden" name="ticket_tab" value="config" />';
|
||||
echo '<input type="hidden" name="type_id" value="' . esc_attr((string) ($editing['id'] ?? 0)) . '" />';
|
||||
echo wp_nonce_field('aksrt_ticket_type_' . (int) ($editing['id'] ?? 0), '_wpnonce', true, false);
|
||||
echo '<p><input type="text" name="name" value="' . esc_attr((string) ($editing['name'] ?? '')) . '" placeholder="' . esc_attr__('部门名称', 'aksrt') . '" /></p>';
|
||||
echo '<p><input type="text" name="description" class="large-text" value="' . esc_attr((string) ($editing['description'] ?? '')) . '" placeholder="' . esc_attr__('部门说明(可选)', 'aksrt') . '" /></p>';
|
||||
echo '<p><input type="text" name="admin_ids" class="large-text" value="' . esc_attr(implode(',', (array) ($editing['admin_ids'] ?? []))) . '" placeholder="' . esc_attr__('受理管理员用户 ID,逗号分隔(可选)', 'aksrt') . '" /></p>';
|
||||
submit_button(__('保存部门', 'aksrt'), 'primary', '', false);
|
||||
echo '</form>';
|
||||
}
|
||||
|
||||
/** 自定义状态:默认状态拒绝改删。 */
|
||||
private function render_ticket_statuses_panel(): void {
|
||||
$statuses = \AKSRT\Ticket\TicketStatusService::all();
|
||||
$edit_id = isset($_GET['status_id']) ? absint($_GET['status_id']) : 0;
|
||||
$editing = null;
|
||||
foreach ($statuses as $row) {
|
||||
if ((int) $row['id'] === $edit_id) {
|
||||
$editing = $row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
echo '<h3>' . esc_html__('工单状态', 'aksrt') . '</h3>';
|
||||
echo '<p class="description">' . esc_html__('默认状态不可修改或删除;删除非默认状态前需先清空该状态下的工单。', 'aksrt') . '</p>';
|
||||
echo '<table class="widefat fixed striped posts" style="max-width:760px;">';
|
||||
echo '<thead><tr><th width="8%">ID</th><th width="22%">' . esc_html__('名称', 'aksrt') . '</th>'
|
||||
. '<th width="18%">' . esc_html__('颜色', 'aksrt') . '</th>'
|
||||
. '<th width="16%">' . esc_html__('完结', 'aksrt') . '</th>'
|
||||
. '<th width="16%">' . esc_html__('默认', 'aksrt') . '</th><th width="20%"></th></tr></thead><tbody>';
|
||||
foreach ($statuses as $row) {
|
||||
$status_cells = sprintf(
|
||||
'<tr><td>%d</td><td>%s</td>'
|
||||
. '<td><span class="aksrt-ticket-badge" style="--aksrt-ticket-color:%s">%s</span></td>'
|
||||
. '<td>%s</td><td>%s</td><td>',
|
||||
(int) $row['id'],
|
||||
esc_html((string) $row['name']),
|
||||
esc_attr((string) $row['color']),
|
||||
esc_html((string) $row['color']),
|
||||
esc_html(!empty($row['is_closed']) ? __('是', 'aksrt') : __('否', 'aksrt')),
|
||||
esc_html(!empty($row['is_default']) ? __('是', 'aksrt') : __('否', 'aksrt'))
|
||||
);
|
||||
if (empty($row['is_default'])) {
|
||||
$status_cells .= '<a href="' . esc_url(add_query_arg(['status_id' => (int) $row['id']], $this->ticket_page_url(['tab' => 'config']))) . '">' . esc_html__('编辑', 'aksrt') . '</a> ';
|
||||
$status_cells .= $this->ticket_delete_button('status_delete', 'status_id', (int) $row['id'], __('删除', 'aksrt'));
|
||||
} else {
|
||||
$status_cells .= '—';
|
||||
}
|
||||
$status_cells .= '</td></tr>';
|
||||
echo $status_cells; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
}
|
||||
echo '</tbody></table>';
|
||||
|
||||
echo '<h4>' . esc_html($editing === null ? __('新增状态', 'aksrt') : __('编辑状态', 'aksrt')) . '</h4>';
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_ticket_update" />';
|
||||
echo '<input type="hidden" name="ticket_action" value="status_def" />';
|
||||
echo '<input type="hidden" name="ticket_tab" value="config" />';
|
||||
echo '<input type="hidden" name="status_id" value="' . esc_attr((string) ($editing['id'] ?? 0)) . '" />';
|
||||
echo wp_nonce_field('aksrt_ticket_status_def_' . (int) ($editing['id'] ?? 0), '_wpnonce', true, false);
|
||||
echo '<p><input type="text" name="name" value="' . esc_attr((string) ($editing['name'] ?? '')) . '" placeholder="' . esc_attr__('状态名称', 'aksrt') . '" /> ';
|
||||
echo '<input type="text" name="color" value="' . esc_attr((string) ($editing['color'] ?? '#6b7280')) . '" placeholder="#6b7280" style="width:120px;" /> ';
|
||||
echo '<label><input type="checkbox" name="is_closed" value="1"' . checked(!empty($editing['is_closed']), true, false) . ' /> ' . esc_html__('完结状态', 'aksrt') . '</label> ';
|
||||
submit_button(__('保存状态', 'aksrt'), 'primary', '', false);
|
||||
echo '</p></form>';
|
||||
}
|
||||
|
||||
/** 预设回复:表格 + 行内编辑 + 新建表单。 */
|
||||
private function render_ticket_prereplies_panel(): void {
|
||||
$rows = \AKSRT\Ticket\TicketPrereplyService::all();
|
||||
$edit_id = isset($_GET['prereply_id']) ? absint($_GET['prereply_id']) : 0;
|
||||
$editing = null;
|
||||
foreach ($rows as $row) {
|
||||
if ((int) $row['id'] === $edit_id) {
|
||||
$editing = $row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
echo '<h3>' . esc_html__('预设回复', 'aksrt') . '</h3>';
|
||||
echo '<table class="widefat fixed striped posts" style="max-width:900px;">';
|
||||
echo '<thead><tr><th width="8%">ID</th><th width="72%">' . esc_html__('内容', 'aksrt') . '</th><th width="20%"></th></tr></thead><tbody>';
|
||||
if ($rows === []) {
|
||||
echo '<tr><td colspan="3">' . esc_html__('暂无预设回复', 'aksrt') . '</td></tr>';
|
||||
}
|
||||
foreach ($rows as $row) {
|
||||
$reply_cells = sprintf(
|
||||
'<tr><td>%d</td><td>%s</td><td>',
|
||||
(int) $row['id'],
|
||||
esc_html((string) $row['content'])
|
||||
);
|
||||
$reply_cells .= '<a href="' . esc_url(add_query_arg(['prereply_id' => (int) $row['id']], $this->ticket_page_url(['tab' => 'config']))) . '">' . esc_html__('编辑', 'aksrt') . '</a> ';
|
||||
$reply_cells .= $this->ticket_delete_button('prereply_delete', 'prereply_id', (int) $row['id'], __('删除', 'aksrt'));
|
||||
$reply_cells .= '</td></tr>';
|
||||
echo $reply_cells; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
}
|
||||
echo '</tbody></table>';
|
||||
|
||||
echo '<h4>' . esc_html($editing === null ? __('新增预设回复', 'aksrt') : __('编辑预设回复', 'aksrt')) . '</h4>';
|
||||
echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
|
||||
echo '<input type="hidden" name="action" value="aksrt_ticket_update" />';
|
||||
echo '<input type="hidden" name="ticket_action" value="prereply" />';
|
||||
echo '<input type="hidden" name="ticket_tab" value="config" />';
|
||||
echo '<input type="hidden" name="prereply_id" value="' . esc_attr((string) ($editing['id'] ?? 0)) . '" />';
|
||||
echo wp_nonce_field('aksrt_ticket_prereply_' . (int) ($editing['id'] ?? 0), '_wpnonce', true, false);
|
||||
echo '<p><textarea name="content" rows="3" class="large-text" placeholder="' . esc_attr__('预设回复内容', 'aksrt') . '">' . esc_textarea((string) ($editing['content'] ?? '')) . '</textarea></p>';
|
||||
submit_button(__('保存预设回复', 'aksrt'), 'primary', '', false);
|
||||
echo '</form>';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
/**
|
||||
* 工单管理后台页 — 工单列表 Tab 渲染。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Admin\Native;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait UsersMenusTicketsList {
|
||||
|
||||
private function render_ticket_list_tab(): void {
|
||||
$status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : '';
|
||||
$type_id = isset($_GET['ticket_type_id']) ? absint($_GET['ticket_type_id']) : 0;
|
||||
$s = isset($_GET['s']) ? sanitize_text_field((string) wp_unslash($_GET['s'])) : '';
|
||||
$paged = isset($_GET['paged']) ? max(1, (int) $_GET['paged']) : 1;
|
||||
|
||||
$statuses = \AKSRT\Ticket\TicketStatusService::all();
|
||||
$types = \AKSRT\Ticket\TicketTypeService::all();
|
||||
|
||||
$base_url = remove_query_arg(['status', 'ticket_type_id', 's', 'paged', 'id'], $this->ticket_page_url());
|
||||
|
||||
// 状态 subsubsub(完结态单独放最后,默认视图不显示已关闭工单)
|
||||
echo '<ul class="subsubsub">';
|
||||
$links = ['' => __('全部', 'aksrt')];
|
||||
foreach ($statuses as $row) {
|
||||
if (!empty($row['is_closed'])) {
|
||||
continue;
|
||||
}
|
||||
$links[(string) $row['status_key']] = (string) $row['name'];
|
||||
}
|
||||
$i = 0;
|
||||
foreach ($links as $key => $label) {
|
||||
$url = $key === '' ? $base_url : add_query_arg('status', $key, $base_url);
|
||||
$sep = ++$i < count($links) ? ' |' : '';
|
||||
printf(
|
||||
'<li><a class="%s" href="%s">%s</a>%s</li> ',
|
||||
$status === $key ? 'current' : '',
|
||||
esc_url($url),
|
||||
esc_html($label),
|
||||
esc_html($sep)
|
||||
);
|
||||
}
|
||||
echo '</ul>';
|
||||
|
||||
// 筛选 + 搜索
|
||||
echo '<form method="get" class="search-form" style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin:10px 0;">';
|
||||
echo '<input type="hidden" name="page" value="' . esc_attr(self::TICKET_PAGE_SLUG) . '" />';
|
||||
echo '<select name="status">';
|
||||
echo '<option value="">' . esc_html__('全部状态', 'aksrt') . '</option>';
|
||||
foreach ($statuses as $row) {
|
||||
printf(
|
||||
'<option value="%s"%s>%s</option>',
|
||||
esc_attr((string) $row['status_key']),
|
||||
selected($status, (string) $row['status_key'], false),
|
||||
esc_html((string) $row['name'])
|
||||
);
|
||||
}
|
||||
echo '</select> ';
|
||||
echo '<select name="ticket_type_id">';
|
||||
echo '<option value="">' . esc_html__('全部部门', 'aksrt') . '</option>';
|
||||
foreach ($types as $row) {
|
||||
printf(
|
||||
'<option value="%d"%s>%s</option>',
|
||||
(int) $row['id'],
|
||||
selected($type_id, (int) $row['id'], false),
|
||||
esc_html((string) $row['name'])
|
||||
);
|
||||
}
|
||||
echo '</select> ';
|
||||
echo '<input type="search" name="s" value="' . esc_attr($s) . '" placeholder="' . esc_attr__('工单号 / 标题', 'aksrt') . '" /> ';
|
||||
submit_button(__('筛选', 'aksrt'), 'primary', '', false);
|
||||
echo '</form>';
|
||||
|
||||
// 新建工单入口
|
||||
printf(
|
||||
'<p><button type="button" class="button" data-aksrt-ticket-new>%s</button></p>',
|
||||
esc_html__('代客提交工单', 'aksrt')
|
||||
);
|
||||
|
||||
$repo = \AKSRT\Ticket\TicketRepository::get_instance();
|
||||
$result = $repo->paginate([
|
||||
'status' => $status === '' ? [] : [$status],
|
||||
'ticket_type_id' => $type_id,
|
||||
'keywords' => $s,
|
||||
'limit' => self::TICKET_PER_PAGE,
|
||||
'offset' => ($paged - 1) * self::TICKET_PER_PAGE,
|
||||
], true);
|
||||
$items = $result['items'];
|
||||
$total = $result['total'];
|
||||
$open_id = isset($_GET['id']) ? absint($_GET['id']) : 0;
|
||||
|
||||
echo '<div class="table-box" style="overflow-y:auto;width:100%;">';
|
||||
echo '<table class="widefat fixed striped posts" style="min-width:960px;">';
|
||||
echo '<thead><tr>';
|
||||
echo '<th width="6%">' . esc_html__('状态', 'aksrt') . '</th>';
|
||||
echo '<th width="10%">' . esc_html__('工单号', 'aksrt') . '</th>';
|
||||
echo '<th width="22%">' . esc_html__('标题', 'aksrt') . '</th>';
|
||||
echo '<th width="10%">' . esc_html__('部门', 'aksrt') . '</th>';
|
||||
echo '<th width="10%">' . esc_html__('用户', 'aksrt') . '</th>';
|
||||
echo '<th width="10%">' . esc_html__('跟进人', 'aksrt') . '</th>';
|
||||
echo '<th width="12%">' . esc_html__('提交时间', 'aksrt') . '</th>';
|
||||
echo '<th width="12%">' . esc_html__('最后回复', 'aksrt') . '</th>';
|
||||
echo '</tr></thead><tbody>';
|
||||
|
||||
if ($items === []) {
|
||||
echo '<tr><td colspan="8" align="center"><strong>' . esc_html__('暂无工单', 'aksrt') . '</strong></td></tr>';
|
||||
}
|
||||
|
||||
foreach ($items as $ticket) {
|
||||
$is_open = $open_id === (int) $ticket['id'];
|
||||
printf(
|
||||
'<tr><td><span class="aksrt-ticket-badge" style="--aksrt-ticket-color:%s">%s</span></td>'
|
||||
. '<td>%s</td><td><a href="%s"><strong>%s</strong></a></td><td>%s</td><td>%s</td><td>%s</td>'
|
||||
. '<td>%s</td><td>%s</td></tr>',
|
||||
esc_attr((string) $ticket['status_color']),
|
||||
esc_html((string) $ticket['status_label']),
|
||||
esc_html((string) $ticket['ticket_num']),
|
||||
esc_url(add_query_arg('id', (int) $ticket['id'], $base_url)),
|
||||
esc_html((string) $ticket['title']),
|
||||
esc_html((string) $ticket['ticket_type_name']),
|
||||
esc_html((string) $ticket['user_name']),
|
||||
esc_html((string) $ticket['admin_name'] !== '' ? (string) $ticket['admin_name'] : '—'),
|
||||
esc_html((string) $ticket['post_time']),
|
||||
esc_html((string) $ticket['last_reply_time'] !== '' ? (string) $ticket['last_reply_time'] : '—')
|
||||
);
|
||||
if ($is_open) {
|
||||
echo '<tr><td colspan="8">' . $this->render_ticket_detail_panel($ticket) . '</td></tr>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
}
|
||||
}
|
||||
echo '</tbody></table></div>';
|
||||
|
||||
\AKSRT\Admin\Native\AdminListPagination::render(
|
||||
self::TICKET_PAGE_SLUG,
|
||||
$total,
|
||||
$paged,
|
||||
self::TICKET_PER_PAGE,
|
||||
array_filter([
|
||||
'status' => $status,
|
||||
'ticket_type_id' => $type_id,
|
||||
's' => $s,
|
||||
], static fn($value): bool => $value !== '' && $value !== 0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 行内展开的工单详情:沟通记录 + 回复框 + 状态迁移 + 后台备注。
|
||||
*
|
||||
* @param array<string,mixed> $ticket
|
||||
*/
|
||||
private function render_ticket_detail_panel(array $ticket): string {
|
||||
$id = (int) $ticket['id'];
|
||||
$repo = \AKSRT\Ticket\TicketRepository::get_instance();
|
||||
$replies = $repo->replies($id);
|
||||
$notes = \AKSRT\Ticket\TicketNoteService::for_ticket($id);
|
||||
|
||||
$html = '<div class="aksrt-ticket-detail" data-aksrt-ticket-detail="' . esc_attr((string) $id) . '">';
|
||||
|
||||
// 工单正文
|
||||
$html .= '<div class="aksrt-ticket-detail__meta">';
|
||||
$html .= '<p><strong>' . esc_html__('工单号', 'aksrt') . ':</strong>' . esc_html((string) $ticket['ticket_num']) . '</p>';
|
||||
$html .= '<p><strong>' . esc_html__('提交用户', 'aksrt') . ':</strong>' . esc_html((string) $ticket['user_name']) . ' (#' . esc_html((string) $ticket['user_id']) . ')</p>';
|
||||
if ((string) $ticket['order_number'] !== '') {
|
||||
$html .= '<p><strong>' . esc_html__('关联订单', 'aksrt') . ':</strong>' . esc_html((string) $ticket['order_number']) . '</p>';
|
||||
}
|
||||
$html .= '<p><strong>' . esc_html__('问题描述', 'aksrt') . ':</strong></p>';
|
||||
$html .= '<div class="aksrt-ticket-detail__content">' . nl2br(esc_html((string) $ticket['content'])) . '</div>';
|
||||
$html .= '</div>';
|
||||
|
||||
// 沟通记录
|
||||
$html .= '<h4>' . esc_html__('沟通记录', 'aksrt') . '</h4>';
|
||||
if ($replies === []) {
|
||||
$html .= '<p class="description">' . esc_html__('暂无沟通记录', 'aksrt') . '</p>';
|
||||
}
|
||||
$html .= '<ol class="aksrt-ticket-detail__replies">';
|
||||
foreach ($replies as $reply) {
|
||||
$who = (string) $reply['author_type'] === 'admin'
|
||||
? sprintf(
|
||||
/* translators: %s admin name */
|
||||
__('管理员 %s', 'aksrt'),
|
||||
(string) $reply['author_name']
|
||||
)
|
||||
: (string) $reply['author_name'];
|
||||
$html .= '<li class="is-' . esc_attr((string) $reply['author_type']) . '">';
|
||||
$html .= '<div class="aksrt-ticket-detail__reply-head"><strong>' . esc_html($who) . '</strong>'
|
||||
. ' <time>' . esc_html((string) $reply['created_at']) . '</time></div>';
|
||||
if (is_array($reply['quote'] ?? null)) {
|
||||
$quote = $reply['quote'];
|
||||
$html .= '<blockquote class="aksrt-ticket-detail__quote">'
|
||||
. esc_html(!empty($quote['is_deleted']) ? __('原回复已删除', 'aksrt') : (string) $quote['content'])
|
||||
. '</blockquote>';
|
||||
}
|
||||
$html .= '<div class="aksrt-ticket-detail__reply-body">' . nl2br(esc_html((string) $reply['content'])) . '</div>';
|
||||
$html .= '</li>';
|
||||
}
|
||||
$html .= '</ol>';
|
||||
|
||||
// 管理员回复:已完结工单不再给回复框,只提示已关闭。
|
||||
if (!empty($ticket['is_closed'])) {
|
||||
$html .= '<p class="aksrt-ticket-detail__closed">' . esc_html__('工单已关闭,无法继续回复。', 'aksrt') . '</p>';
|
||||
} else {
|
||||
$html .= '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" class="aksrt-ticket-detail__reply-form">';
|
||||
$html .= '<input type="hidden" name="action" value="aksrt_ticket_update" />';
|
||||
$html .= '<input type="hidden" name="ticket_action" value="reply" />';
|
||||
$html .= '<input type="hidden" name="ticket_id" value="' . esc_attr((string) $id) . '" />';
|
||||
$html .= '<input type="hidden" name="ticket_tab" value="tickets" />';
|
||||
$html .= wp_nonce_field('aksrt_ticket_reply_' . $id, '_wpnonce', true, false);
|
||||
$html .= '<p><textarea name="content" rows="3" class="large-text" placeholder="' . esc_attr__('填写回复内容', 'aksrt') . '"></textarea></p>';
|
||||
$html .= '<p><button type="submit" class="button button-primary">' . esc_html__('提交回复', 'aksrt') . '</button></p>';
|
||||
$html .= '</form>';
|
||||
}
|
||||
|
||||
// 状态迁移 / 部门 / 跟进人
|
||||
$html .= '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" class="aksrt-ticket-detail__status-form">';
|
||||
$html .= '<input type="hidden" name="action" value="aksrt_ticket_update" />';
|
||||
$html .= '<input type="hidden" name="ticket_action" value="status" />';
|
||||
$html .= '<input type="hidden" name="ticket_id" value="' . esc_attr((string) $id) . '" />';
|
||||
$html .= '<input type="hidden" name="ticket_tab" value="tickets" />';
|
||||
$html .= wp_nonce_field('aksrt_ticket_status_' . $id, '_wpnonce', true, false);
|
||||
$html .= '<p><label>' . esc_html__('状态', 'aksrt') . ' <select name="status">';
|
||||
foreach (\AKSRT\Ticket\TicketStatusService::all() as $row) {
|
||||
printf(
|
||||
'<option value="%s"%s>%s</option>',
|
||||
esc_attr((string) $row['status_key']),
|
||||
selected((string) $ticket['status'], (string) $row['status_key'], false),
|
||||
esc_html((string) $row['name'])
|
||||
);
|
||||
}
|
||||
$html .= '</select></label> ';
|
||||
$html .= '<label>' . esc_html__('部门', 'aksrt') . ' <select name="ticket_type_id">';
|
||||
foreach (\AKSRT\Ticket\TicketTypeService::all() as $row) {
|
||||
printf(
|
||||
'<option value="%d"%s>%s</option>',
|
||||
(int) $row['id'],
|
||||
selected((int) $ticket['ticket_type_id'], (int) $row['id'], false),
|
||||
esc_html((string) $row['name'])
|
||||
);
|
||||
}
|
||||
$html .= '</select></label> ';
|
||||
$html .= '<label>' . esc_html__('跟进人 ID', 'aksrt') . ' <input type="number" name="admin_id" value="' . esc_attr((string) $ticket['admin_id']) . '" min="0" style="width:80px;" /></label> ';
|
||||
$html .= '<button type="submit" class="button">' . esc_html__('保存', 'aksrt') . '</button></p>';
|
||||
$html .= '</form>';
|
||||
|
||||
// 后台备注
|
||||
$html .= '<h4>' . esc_html__('后台备注', 'aksrt') . '</h4>';
|
||||
if ($notes === []) {
|
||||
$html .= '<p class="description">' . esc_html__('暂无备注', 'aksrt') . '</p>';
|
||||
}
|
||||
$html .= '<ul class="aksrt-ticket-detail__notes">';
|
||||
foreach ($notes as $note) {
|
||||
$html .= '<li><strong>' . esc_html((string) $note['admin_name']) . '</strong>'
|
||||
. ' <time>' . esc_html((string) $note['created_at']) . '</time>'
|
||||
. '<div>' . nl2br(esc_html((string) $note['content'])) . '</div></li>';
|
||||
}
|
||||
$html .= '</ul>';
|
||||
$html .= '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
|
||||
$html .= '<input type="hidden" name="action" value="aksrt_ticket_update" />';
|
||||
$html .= '<input type="hidden" name="ticket_action" value="note" />';
|
||||
$html .= '<input type="hidden" name="ticket_id" value="' . esc_attr((string) $id) . '" />';
|
||||
$html .= '<input type="hidden" name="ticket_tab" value="tickets" />';
|
||||
$html .= wp_nonce_field('aksrt_ticket_note_' . $id, '_wpnonce', true, false);
|
||||
$html .= '<p><input type="text" name="content" class="large-text" placeholder="' . esc_attr__('添加内部备注(用户不可见)', 'aksrt') . '" /> '
|
||||
. '<button type="submit" class="button">' . esc_html__('添加备注', 'aksrt') . '</button></p>';
|
||||
$html .= '</form>';
|
||||
|
||||
$html .= '</div>';
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
@@ -571,34 +571,6 @@ class SchemaPayment {
|
||||
]],
|
||||
],
|
||||
],
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* 售后策略
|
||||
* ------------------------------------------------------------------ */
|
||||
'after_sale' => [
|
||||
'key' => 'after_sale',
|
||||
'title' => '售后策略',
|
||||
'version' => '1.0.0',
|
||||
'option_key' => 'aksrt_settings',
|
||||
'groups' => [
|
||||
['key' => 'main', 'title' => '售后功能', 'icon' => 'fa fas fa-undo-alt', 'fields' => [
|
||||
['key' => 'after_sale_s', 'label' => '售后功能', 'type' => 'switch', 'default' => true, 'help' => '开启后用户可以申请售后;关闭后不再处理售后,前台「我的订单」不再显示申请售后按钮'],
|
||||
['key' => 'after_sale_time_limit', 'label' => '申请售后时效(天)', 'type' => 'number', 'default' => 7, 'min' => 0, 'max' => 3650, 'help' => '订单支付后多少天内可以申请售后,超过时效将无法申请;0 表示不限制'],
|
||||
['key' => 'after_sale_expired_text', 'label' => '超过时效提示', 'type' => 'textarea', 'sanitize_type' => 'html', 'default' => '<div>抱歉,您的订单已超过售后申请时效</div>', 'rows' => 3, 'help' => '订单超过售后时效后,用户点击申请售后时弹窗提示的内容(支持HTML代码,请注意代码规范)'],
|
||||
]],
|
||||
['key' => 'form', 'title' => '申请表单', 'icon' => 'fa fas fa-file-alt', 'fields' => [
|
||||
['key' => 'after_sale_reasons', 'label' => '预置原因', 'type' => 'group', 'button_title' => '添加原因', 'help' => '用户申请售后时可选择的原因', 'default' => [['t' => '质量问题'], ['t' => '假冒品牌'], ['t' => '其他原因']], 'fields' => [
|
||||
['key' => 't', 'label' => '原因', 'type' => 'input', 'default' => ''],
|
||||
]],
|
||||
['key' => 'after_sale_desc', 'label' => '申请售后说明', 'type' => 'textarea', 'sanitize_type' => 'html', 'default' => '', 'rows' => 5, 'help' => '用户申请售后时显示的内容(支持HTML代码,请注意代码规范)'],
|
||||
['key' => 'after_sale_materials', 'label' => '申请所需材料', 'type' => 'group', 'button_title' => '添加材料', 'help' => '用户提交售后申请时需要提供的资料,如联系邮箱、联系电话;此处的信息收集,仅用于售后联系与审核', 'default' => [['t' => '联系邮箱'], ['t' => '联系电话']], 'fields' => [
|
||||
['key' => 't', 'label' => '资料项', 'type' => 'input', 'default' => ''],
|
||||
]],
|
||||
['key' => 'after_sale_reason_placeholder', 'label' => '申请说明占位文案', 'type' => 'input', 'default' => '必填,请您详细填写申请原因', 'help' => '前台申请售后弹窗中「申请说明」输入框的提示文字'],
|
||||
['key' => 'after_sale_img_s', 'label' => '申请售后可上传图片', 'type' => 'switch', 'default' => true, 'help' => '开启后申请售后弹窗中出现图片上传入口(最多上传3张)'],
|
||||
]],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -81,8 +81,8 @@ class SchemaPermissions {
|
||||
['label' => '每日签到', 'value' => 'signin'],
|
||||
['label' => '前台投稿', 'value' => 'front_submit'],
|
||||
['label' => '打赏用户', 'value' => 'tip_user'],
|
||||
['label' => '申请售后', 'value' => 'after_sale_apply'],
|
||||
['label' => '撤销售后', 'value' => 'after_sale_cancel'],
|
||||
['label' => '提交工单', 'value' => 'ticket_create'],
|
||||
['label' => '回复工单', 'value' => 'ticket_reply'],
|
||||
]],
|
||||
]],
|
||||
['key' => 'providers', 'title' => '服务商配置', 'dependsOn' => ['key' => 'enabled', 'value' => true], 'fields' => $provider_fields],
|
||||
|
||||
@@ -612,6 +612,7 @@ class SchemaUsers {
|
||||
],
|
||||
],
|
||||
],
|
||||
'ticket' => Users\SchemaUsersTicket::build(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
/**
|
||||
* 工单配置分区(域 users,分区 ticket)。
|
||||
*
|
||||
* 字段键、顺序、类型、默认值与文案需与
|
||||
* assets/admin/src/schema/sections/users/ticket.js 保持 1:1
|
||||
* (tests/widget-system/ticket-schema-contract.test.cjs 守护)。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Admin\Schema\Users;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
class SchemaUsersTicket {
|
||||
|
||||
public static function build(): array {
|
||||
return [
|
||||
'key' => 'ticket',
|
||||
'title' => '工单中心',
|
||||
'version' => '1.0.0',
|
||||
'option_key' => 'aksrt_settings',
|
||||
'groups' => [
|
||||
['key' => 'main', 'title' => '工单功能', 'icon' => 'fa fas fa-headset', 'fields' => [
|
||||
['key' => 'ticket_s', 'label' => '工单功能', 'type' => 'switch', 'default' => true, 'help' => '开启后用户可在用户中心提交工单;关闭后工单中心不再开放,后台工单列表仍可查看历史工单'],
|
||||
['key' => 'ticket_num_prefix', 'label' => '工单号前缀', 'type' => 'input', 'default' => 'T', 'help' => '工单号形如「前缀+年月日+6位随机数」,仅支持字母与数字'],
|
||||
['key' => 'ticket_notice_open', 'label' => '提交后显示平台公告', 'type' => 'switch', 'default' => true, 'help' => '开启后用户提交工单成功时展示下方公告内容'],
|
||||
['key' => 'ticket_notice_text', 'label' => '公告内容', 'type' => 'textarea', 'sanitize_type' => 'html', 'rows' => 3, 'default' => '<div>提交工单后请耐心等待处理</div>', 'help' => '用户提交工单后展示的内容(支持HTML代码,请注意代码规范)'],
|
||||
]],
|
||||
['key' => 'form', 'title' => '提交设置', 'icon' => 'fa fas fa-file-alt', 'fields' => [
|
||||
['key' => 'ticket_attachment_s', 'label' => '允许上传图片', 'type' => 'switch', 'default' => true, 'help' => '开启后工单内容与回复可上传图片(复用评论图片上传的大小与类型校验)'],
|
||||
['key' => 'ticket_attachment_max', 'label' => '单个工单图片上限', 'type' => 'number', 'default' => 5, 'min' => 1, 'max' => 9, 'help' => '工单内容与每条回复各自可上传的图片数量上限'],
|
||||
['key' => 'ticket_order_link_s', 'label' => '允许关联订单', 'type' => 'switch', 'default' => true, 'help' => '开启后新建工单时可选择关联本人一个已支付订单;关闭后工单与订单完全解耦'],
|
||||
['key' => 'ticket_urge_interval', 'label' => '催单冷却(分钟)', 'type' => 'number', 'default' => 15, 'min' => 1, 'max' => 1440, 'help' => '同一工单两次催单之间的最小间隔'],
|
||||
]],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ class DBMigrator {
|
||||
use DBMigratorPart3;
|
||||
use DBMigratorIdentity;
|
||||
use DBMigratorVip;
|
||||
use DBMigratorTicket;
|
||||
|
||||
/** Current schema version; changing it re-runs dbDelta. */
|
||||
// 2.5.1: order_aftersale 新增 images 列(售后举证图片)。dbDelta 仅在版本变化时
|
||||
@@ -20,7 +21,10 @@ class DBMigrator {
|
||||
// 2.6.0: order_aftersale 新增 handling_log 列(处理记录:每次状态提交一条
|
||||
// 「状态 + 备注 + 来源 + 时间」),取代单一的 admin_note / reviewed_at / completed_at,
|
||||
// 并移除从未被写入的 gateway_request_id / gateway_refund_id / gateway_result。
|
||||
const SCHEMA_VERSION = '2.6.0';
|
||||
// 3.0.0: 售后(order_aftersale)整体替换为工单系统(6 张 aksrt_ticket* 表)。
|
||||
// 工单不再绑定订单退款流程:标题 + 部门 + 描述为必填,关联订单为可选项;
|
||||
// 沟通记录从工单上的 JSON 日志升级为独立的 ticket_reply 行(支持引用回复)。
|
||||
const SCHEMA_VERSION = '3.0.0';
|
||||
|
||||
const OPT_KEY = 'aksrt_db_schema_version';
|
||||
|
||||
|
||||
@@ -204,26 +204,6 @@ trait DBMigratorPart2 {
|
||||
) 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,
|
||||
handling_log LONGTEXT DEFAULT NULL,
|
||||
refund_amount VARCHAR(32) NOT NULL DEFAULT '0',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
created_at DATETIME NOT 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,
|
||||
@@ -555,5 +535,6 @@ trait DBMigratorPart2 {
|
||||
|
||||
self::migrate_identity_tables($prefix, $charset_collate);
|
||||
self::migrate_vip_tables($prefix, $charset_collate);
|
||||
self::migrate_ticket_tables($prefix, $charset_collate);
|
||||
}
|
||||
}
|
||||
@@ -25,8 +25,13 @@ trait DBMigratorPart3 {
|
||||
'dl_token' => 'aksrt_dl_token',
|
||||
'notification' => 'aksrt_notification',
|
||||
'order' => 'aksrt_order',
|
||||
'order_aftersale' => 'aksrt_order_aftersale',
|
||||
'order_item' => 'aksrt_order_item',
|
||||
'ticket' => 'aksrt_ticket',
|
||||
'ticket_reply' => 'aksrt_ticket_reply',
|
||||
'ticket_status' => 'aksrt_ticket_status',
|
||||
'ticket_type' => 'aksrt_ticket_type',
|
||||
'ticket_prereply' => 'aksrt_ticket_prereply',
|
||||
'ticket_note' => 'aksrt_ticket_note',
|
||||
'revenue' => 'aksrt_revenue',
|
||||
'withdrawal' => 'aksrt_withdrawal',
|
||||
'report' => 'aksrt_report',
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
/**
|
||||
* Ticket (工单) tables.
|
||||
*
|
||||
* 6 张表取代旧的 aksrt_order_aftersale:工单不再绑定订单退款流程,
|
||||
* 标题 + 部门 + 描述为必填,关联订单为可选项。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Core;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait DBMigratorTicket {
|
||||
|
||||
private static function migrate_ticket_tables(string $prefix, string $charset_collate): void {
|
||||
$ticket = "CREATE TABLE {$prefix}aksrt_ticket (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
ticket_num VARCHAR(32) NOT NULL,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
ticket_type_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
content TEXT NOT NULL,
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'pending',
|
||||
order_number VARCHAR(32) NOT NULL DEFAULT '',
|
||||
attachment LONGTEXT DEFAULT NULL,
|
||||
last_reply_time DATETIME DEFAULT NULL,
|
||||
post_time DATETIME NOT NULL,
|
||||
notes TEXT DEFAULT NULL,
|
||||
admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
last_reply_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
post_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uniq_ticket_num (ticket_num),
|
||||
KEY idx_user_created (user_id, created_at),
|
||||
KEY idx_status_created (status, created_at),
|
||||
KEY idx_type_status (ticket_type_id, status),
|
||||
KEY idx_order (order_number)
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($ticket);
|
||||
|
||||
$reply = "CREATE TABLE {$prefix}aksrt_ticket_reply (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
ticket_id BIGINT UNSIGNED NOT NULL,
|
||||
author_type VARCHAR(16) NOT NULL DEFAULT 'user',
|
||||
author_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
content TEXT NOT NULL,
|
||||
attachment LONGTEXT DEFAULT NULL,
|
||||
quote_reply_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_ticket_created (ticket_id, created_at),
|
||||
KEY idx_quote (quote_reply_id)
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($reply);
|
||||
|
||||
// status_key 是工单上 aksrt_ticket.status 的外延键:管理员改状态名只动 name,
|
||||
// 存量工单的 status 不受影响。默认状态(is_default)不可改删。
|
||||
$status = "CREATE TABLE {$prefix}aksrt_ticket_status (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
status_key VARCHAR(24) NOT NULL,
|
||||
name VARCHAR(60) NOT NULL,
|
||||
color VARCHAR(20) NOT NULL DEFAULT '#6b7280',
|
||||
is_closed TINYINT(1) NOT NULL DEFAULT 0,
|
||||
is_default TINYINT(1) NOT NULL DEFAULT 0,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uniq_status_key (status_key)
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($status);
|
||||
|
||||
$type = "CREATE TABLE {$prefix}aksrt_ticket_type (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
description TEXT DEFAULT NULL,
|
||||
admin_ids LONGTEXT DEFAULT NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (id)
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($type);
|
||||
|
||||
$prereply = "CREATE TABLE {$prefix}aksrt_ticket_prereply (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
content TEXT NOT NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($prereply);
|
||||
|
||||
$note = "CREATE TABLE {$prefix}aksrt_ticket_note (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
ticket_id BIGINT UNSIGNED NOT NULL,
|
||||
admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
content TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_ticket_created (ticket_id, created_at)
|
||||
) ENGINE=InnoDB {$charset_collate};";
|
||||
dbDelta($note);
|
||||
|
||||
self::seed_ticket_statuses($prefix);
|
||||
self::seed_ticket_types($prefix);
|
||||
}
|
||||
|
||||
/**
|
||||
* 工单状态 seeds:按 status_key 幂等补齐,不覆盖管理员已改过的名称与颜色。
|
||||
* 「待接单」标记 is_default,后台拒绝改删。
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private static function seed_ticket_statuses(string $prefix): void {
|
||||
global $wpdb;
|
||||
$table = $prefix . 'aksrt_ticket_status';
|
||||
$now = current_time('mysql');
|
||||
$seeds = [
|
||||
['pending', '待接单', '#f59e0b', 0, 1],
|
||||
['handling', '处理中', '#8b5cf6', 0, 0],
|
||||
['user_replied', '待回复', '#3b82f6', 0, 0],
|
||||
['admin_replied', '已回复', '#10b981', 0, 0],
|
||||
['closed', '已关闭', '#6b7280', 1, 0],
|
||||
];
|
||||
|
||||
$exists = $wpdb->get_col("SELECT status_key FROM {$table}"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL
|
||||
if (!is_array($exists)) {
|
||||
$exists = [];
|
||||
}
|
||||
|
||||
$sort = 0;
|
||||
foreach ($seeds as [$key, $label, $color, $is_closed, $is_default]) {
|
||||
if (in_array($key, $exists, true)) {
|
||||
continue;
|
||||
}
|
||||
$wpdb->insert($table, [
|
||||
'status_key' => $key,
|
||||
'name' => $label,
|
||||
'color' => $color,
|
||||
'is_closed' => $is_closed,
|
||||
'is_default' => $is_default,
|
||||
'sort_order' => $sort++,
|
||||
'created_at' => $now,
|
||||
], ['%s', '%s', '%s', '%d', '%d', '%d', '%s']);
|
||||
}
|
||||
}
|
||||
|
||||
/** 工单部门 seeds:按 name 幂等补齐。 */
|
||||
private static function seed_ticket_types(string $prefix): void {
|
||||
global $wpdb;
|
||||
$table = $prefix . 'aksrt_ticket_type';
|
||||
$now = current_time('mysql');
|
||||
$seeds = ['技术支持', '账号问题', '财务问题', '产品建议', '其他'];
|
||||
|
||||
$exists = $wpdb->get_col("SELECT name FROM {$table}"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL
|
||||
if (!is_array($exists)) {
|
||||
$exists = [];
|
||||
}
|
||||
|
||||
$sort = 0;
|
||||
foreach ($seeds as $label) {
|
||||
if (in_array($label, $exists, true)) {
|
||||
continue;
|
||||
}
|
||||
$wpdb->insert($table, [
|
||||
'name' => $label,
|
||||
'sort_order' => $sort++,
|
||||
'created_at' => $now,
|
||||
], ['%s', '%d', '%s']);
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
-10
@@ -385,9 +385,11 @@ trait ScriptsPart1b {
|
||||
// Orders is a lazy user-center panel, so its independent assets must
|
||||
// be ready before client-side panel replacement can insert its markup.
|
||||
$has_orders_panel = false;
|
||||
$has_tickets_panel = false;
|
||||
if (class_exists('\AKSRT\User\Center')) {
|
||||
$dashboard_tabs = \AKSRT\User\Center::get_instance()->get_dashboard_tabs();
|
||||
$has_orders_panel = isset($dashboard_tabs['orders']);
|
||||
$has_tickets_panel = isset($dashboard_tabs['tickets']);
|
||||
}
|
||||
if ($has_orders_panel) {
|
||||
$orders_css = get_template_directory() . '/assets/css/routes/orders.css';
|
||||
@@ -411,16 +413,27 @@ trait ScriptsPart1b {
|
||||
);
|
||||
}
|
||||
|
||||
// 售后弹窗(申请 / 超时效提示 / 售后详细):与订单中心同面板、同条件入队。
|
||||
$order_after_sale_js = get_template_directory() . '/assets/js/order-after-sale.js';
|
||||
if (file_exists($order_after_sale_js)) {
|
||||
wp_enqueue_script(
|
||||
'aksrt-order-after-sale',
|
||||
$base_uri . '/assets/js/order-after-sale.js',
|
||||
['loader_js'],
|
||||
(string) filemtime($order_after_sale_js),
|
||||
true
|
||||
);
|
||||
// 工单中心(提交 / 详情 / 回复弹窗):与订单中心同面板、同条件入队。
|
||||
if ($has_tickets_panel) {
|
||||
$tickets_css = get_template_directory() . '/assets/css/routes/tickets.css';
|
||||
if (file_exists($tickets_css)) {
|
||||
wp_enqueue_style(
|
||||
'aksrt-tickets',
|
||||
$base_uri . '/assets/css/routes/tickets.css',
|
||||
[$core_dep],
|
||||
(string) filemtime($tickets_css)
|
||||
);
|
||||
}
|
||||
$ticket_center_js = get_template_directory() . '/assets/js/ticket-center.js';
|
||||
if (file_exists($ticket_center_js)) {
|
||||
wp_enqueue_script(
|
||||
'aksrt-ticket-center',
|
||||
$base_uri . '/assets/js/ticket-center.js',
|
||||
['loader_js'],
|
||||
(string) filemtime($ticket_center_js),
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,6 +453,9 @@ trait ScriptsPart1b {
|
||||
if ($has_orders_panel && wp_script_is('aksrt-order-center', 'registered')) {
|
||||
$user_center_deps[] = 'aksrt-order-center';
|
||||
}
|
||||
if ($has_tickets_panel && wp_script_is('aksrt-ticket-center', 'registered')) {
|
||||
$user_center_deps[] = 'aksrt-ticket-center';
|
||||
}
|
||||
wp_enqueue_script(
|
||||
'aksrt-route-user-center',
|
||||
$base_uri . '/assets/js/routes/user-center.js',
|
||||
|
||||
+12
-14
@@ -498,22 +498,20 @@ trait ScriptsPart1c {
|
||||
'desc' => (string) ($report['desc'] ?? ''),
|
||||
];
|
||||
})(),
|
||||
// 售后策略数据(order-after-sale.js):预置原因、所需材料、申请说明与
|
||||
// 占位文案、图片开关、超时效提示均读取后台「支付&付费 → 售后策略」。
|
||||
'afterSale' => (function () {
|
||||
if (!class_exists('\AKSRT\Settings\PaymentRuntime')) {
|
||||
return ['enabled' => false, 'reasons' => [], 'materials' => [], 'desc' => '', 'imgEnabled' => false, 'maxImages' => 3, 'reasonPlaceholder' => '', 'expiredText' => ''];
|
||||
// 工单配置(ticket-center.js):附件开关与上限、提交公告、催单冷却。
|
||||
'ticket' => (function () {
|
||||
if (!class_exists('\AKSRT\Ticket\TicketSettings')) {
|
||||
return ['enabled' => false, 'attachmentEnabled' => false, 'attachmentMax' => 5, 'notice' => ['open' => false, 'text' => ''], 'urgeInterval' => 15];
|
||||
}
|
||||
$after_sale = \AKSRT\Settings\PaymentRuntime::get_after_sale_settings();
|
||||
return [
|
||||
'enabled' => !empty($after_sale['enabled']),
|
||||
'reasons' => array_values((array) ($after_sale['reasons'] ?? [])),
|
||||
'materials' => array_values((array) ($after_sale['materials'] ?? [])),
|
||||
'desc' => (string) ($after_sale['desc'] ?? ''),
|
||||
'imgEnabled' => !empty($after_sale['img_enabled']),
|
||||
'maxImages' => 3,
|
||||
'reasonPlaceholder' => (string) ($after_sale['reason_placeholder'] ?? ''),
|
||||
'expiredText' => (string) ($after_sale['expired_text'] ?? ''),
|
||||
'enabled' => \AKSRT\Ticket\TicketSettings::enabled(),
|
||||
'attachmentEnabled' => \AKSRT\Ticket\TicketSettings::attachment_enabled(),
|
||||
'attachmentMax' => \AKSRT\Ticket\TicketSettings::attachment_max(),
|
||||
'notice' => [
|
||||
'open' => \AKSRT\Ticket\TicketSettings::notice_open(),
|
||||
'text' => \AKSRT\Ticket\TicketSettings::notice_text(),
|
||||
],
|
||||
'urgeInterval' => \AKSRT\Ticket\TicketSettings::urge_interval(),
|
||||
];
|
||||
})(),
|
||||
'stickyEnabled' => class_exists('\AKSRT\Settings\Settings') ? !empty(\AKSRT\Settings\Settings::get_post_list_style_settings()['sticky_s']) : true,
|
||||
|
||||
@@ -1,427 +0,0 @@
|
||||
<?php
|
||||
/** User applications and administrator status management for order after-sales. */
|
||||
|
||||
namespace AKSRT\Payment;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class AfterSaleService {
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_PROCESSING = 'processing';
|
||||
public const STATUS_REJECTED = 'rejected';
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
/** 处理记录来源:后台提交 / 用户提交(申请与撤销)。 */
|
||||
public const SOURCE_ADMIN = 'admin';
|
||||
public const SOURCE_USER = 'user';
|
||||
/** 处理记录上限:与 MetaAssetService::MAX_RECORDS 同口径,超出后只保留最近的一段。 */
|
||||
private const MAX_LOG_ENTRIES = 50;
|
||||
private static ?self $instance = null;
|
||||
|
||||
public static function get_instance(): self { return self::$instance ??= new self(); }
|
||||
|
||||
public function init(): void { add_action('rest_api_init', [$this, 'register_routes']); }
|
||||
|
||||
public function register_routes(): void {
|
||||
$number = '(?P<order_number>[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) {
|
||||
$captcha = $this->require_captcha($request, 'after_sale_apply');
|
||||
if (is_wp_error($captcha)) {
|
||||
return $captcha;
|
||||
}
|
||||
$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) {
|
||||
$captcha = $this->require_captcha($request, 'after_sale_cancel');
|
||||
if (is_wp_error($captcha)) {
|
||||
return $captcha;
|
||||
}
|
||||
$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]);
|
||||
}
|
||||
|
||||
/** 人机验证(申请售后 / 撤销售后):场景未启用时 aksrt_require_captcha 直接放行。 */
|
||||
private function require_captcha(\WP_REST_Request $request, string $scene) {
|
||||
if (!function_exists('aksrt_require_captcha')) {
|
||||
return true;
|
||||
}
|
||||
$params = $request->get_json_params();
|
||||
return aksrt_require_captcha($scene, is_array($params) ? $params : []);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $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]);
|
||||
// 只挡「仍在流转」的工单。已完结(completed)的工单不挡——能否再售后由
|
||||
// 「订单是否已退款」决定,而订单已退款时上面的 STATUS_PAID 校验已先挡掉。
|
||||
$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]);
|
||||
$now = current_time('mysql');
|
||||
$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' => $now,
|
||||
'handling_log' => $this->encode_log([$this->log_entry(self::STATUS_PENDING, '', self::SOURCE_USER, $now)]),
|
||||
], ['%s', '%d', '%s', '%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<string,mixed> $form
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 退款金额:积分单取整,现金单经 AssetAmount 归一为两位小数;
|
||||
* 金额无法解析时返回 null,由 create() 拒掉这次申请。
|
||||
*/
|
||||
private function refund_amount(array $order): ?string {
|
||||
if ((string) ($order['currency'] ?? '') === 'points') return (string) max(0, (int) ($order['amount'] ?? 0));
|
||||
try { return \AKSRT\Ledger\AssetAmount::balance((string) ($order['amount'] ?? '0'))->decimal(); } catch (\InvalidArgumentException $e) { return null; }
|
||||
}
|
||||
|
||||
/** 最新一条售后工单(列表视图只取最新态,避免整表拉取)。 */
|
||||
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]);
|
||||
$this->append_log((int) $row['id'], $row, self::STATUS_CANCELLED, '', self::SOURCE_USER);
|
||||
return $this->get((int) $row['id']);
|
||||
} finally {
|
||||
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock));
|
||||
}
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
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<array<string,mixed>> */
|
||||
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_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));
|
||||
}
|
||||
|
||||
/** 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], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员直接设置售后工单状态:纯状态更新,不触发订单、资产与支付渠道联动。
|
||||
*
|
||||
* 每次提交都往 handling_log 落一条「状态 + 备注 + 来源 + 时间」,备注为空也留痕,
|
||||
* 后台与前台才能回放整条处理链路。读取-追加-写回需要与 create()/cancel_for_user()
|
||||
* 同一把订单锁,否则两条并发提交会互相覆盖日志。
|
||||
*/
|
||||
public function set_status(int $id, string $status, string $note = '') {
|
||||
if (!current_user_can('manage_options')) return new \WP_Error('aksrt_after_sale_forbidden', __('权限不足', 'aksrt'), ['status' => 403]);
|
||||
$status = sanitize_key($status);
|
||||
if (!in_array($status, [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_COMPLETED, self::STATUS_REJECTED, self::STATUS_CANCELLED], true)) return new \WP_Error('aksrt_after_sale_status', __('售后状态无效', 'aksrt'), ['status' => 400]);
|
||||
$row = $this->get($id);
|
||||
if (!$row) return new \WP_Error('aksrt_after_sale_state', __('售后申请不存在', 'aksrt'), ['status' => 409]);
|
||||
global $wpdb;
|
||||
$lock = 'aksrt_after_sale_' . md5((string) ($row['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) return new \WP_Error('aksrt_after_sale_state', __('售后申请不存在', 'aksrt'), ['status' => 409]);
|
||||
$note = sanitize_textarea_field($note);
|
||||
if (!$this->update($id, ['status' => $status])) return new \WP_Error('aksrt_after_sale_db', __('售后状态保存失败', 'aksrt'), ['status' => 500]);
|
||||
$this->append_log($id, $row, $status, $note, self::SOURCE_ADMIN);
|
||||
return $this->get($id);
|
||||
} finally {
|
||||
$wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加一条处理记录。日志按时间正序存放,超出 MAX_LOG_ENTRIES 条时只保留最近的一段。
|
||||
*
|
||||
* @param array<string,mixed> $current 追加前的工单行(含已规范化的 handling_log)。
|
||||
*/
|
||||
private function append_log(int $id, array $current, string $status, string $note, string $source): void {
|
||||
$log = is_array($current['handling_log'] ?? null) ? $current['handling_log'] : [];
|
||||
$log[] = $this->log_entry($status, $note, $source, current_time('mysql'));
|
||||
if (count($log) > self::MAX_LOG_ENTRIES) {
|
||||
$log = array_slice($log, -self::MAX_LOG_ENTRIES);
|
||||
}
|
||||
$this->update($id, ['handling_log' => $this->encode_log($log)]);
|
||||
}
|
||||
|
||||
/** @return array<string,string> */
|
||||
private function log_entry(string $status, string $note, string $source, string $created_at): array {
|
||||
return [
|
||||
'status' => $status,
|
||||
'note' => $note,
|
||||
'source' => $source,
|
||||
'created_at' => $created_at,
|
||||
];
|
||||
}
|
||||
|
||||
private function encode_log(array $log): string {
|
||||
return wp_json_encode($log, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
/** 该订单当前仍在处理中的售后工单(待审核/处理中),退款闸门只认它。 */
|
||||
public 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') ORDER BY id DESC LIMIT 1", $number), ARRAY_A);
|
||||
return is_array($row) ? $this->normalize($row) : null;
|
||||
}
|
||||
|
||||
/** 按 ID 读取售后申请(含关联订单号),供后台操作面板解析关联订单。 */
|
||||
public 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<string,mixed> $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;
|
||||
$row['handling_log'] = $this->normalize_log(json_decode((string) ($row['handling_log'] ?? ''), true));
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理记录规范化:只保留 status 非空的条目,source 只认 user,其余归 admin;
|
||||
* 非数组(含损坏 JSON)整体视为空,界面对老工单按「暂无处理记录」处理。
|
||||
*
|
||||
* @return list<array<string,string>>
|
||||
*/
|
||||
private function normalize_log($log): array {
|
||||
if (!is_array($log)) return [];
|
||||
$clean = [];
|
||||
foreach ($log as $entry) {
|
||||
if (!is_array($entry)) continue;
|
||||
$status = sanitize_key((string) ($entry['status'] ?? ''));
|
||||
if ($status === '') continue;
|
||||
$clean[] = [
|
||||
'status' => $status,
|
||||
'note' => (string) ($entry['note'] ?? ''),
|
||||
'source' => (string) ($entry['source'] ?? '') === self::SOURCE_USER ? self::SOURCE_USER : self::SOURCE_ADMIN,
|
||||
'created_at' => (string) ($entry['created_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
return $clean;
|
||||
}
|
||||
}
|
||||
@@ -136,7 +136,7 @@ class BalanceGateway implements GatewayInterface {
|
||||
}
|
||||
|
||||
public function refund(string $order_number, string $amount, string $request_id = ''): array {
|
||||
return ['success' => false, 'status' => 'failed', 'message' => __('余额订单售后请通过用户中心申请并由管理员审核', 'aksrt')];
|
||||
return ['success' => false, 'status' => 'failed', 'message' => __('余额退款请联系管理员处理', 'aksrt')];
|
||||
}
|
||||
|
||||
private function balance_enabled(): bool {
|
||||
|
||||
@@ -16,7 +16,6 @@ final class OrderCenterQuery {
|
||||
public const STATUS_ALL = 'all';
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_PAID = 'paid';
|
||||
public const STATUS_AFTER_SALE = 'after_sale';
|
||||
public const STATUS_REFUNDED = 'refunded';
|
||||
public const STATUS_CLOSED = 'closed';
|
||||
|
||||
@@ -24,7 +23,6 @@ final class OrderCenterQuery {
|
||||
self::STATUS_ALL,
|
||||
self::STATUS_PENDING,
|
||||
self::STATUS_PAID,
|
||||
self::STATUS_AFTER_SALE,
|
||||
self::STATUS_REFUNDED,
|
||||
self::STATUS_CLOSED,
|
||||
];
|
||||
@@ -94,7 +92,7 @@ final class OrderCenterQuery {
|
||||
|
||||
/** @return string[] */
|
||||
public function database_statuses(): array {
|
||||
if ($this->status() === self::STATUS_ALL || $this->status() === self::STATUS_AFTER_SALE) {
|
||||
if ($this->status() === self::STATUS_ALL) {
|
||||
return array_merge(array_keys(DomainModel::order_status_labels()), ['refunding']);
|
||||
}
|
||||
if ($this->status() === self::STATUS_PAID) {
|
||||
@@ -103,10 +101,6 @@ final class OrderCenterQuery {
|
||||
return [$this->status()];
|
||||
}
|
||||
|
||||
/** 售后中:订单带有「待审核 / 处理中」工单,与订单自身状态无关。 */
|
||||
public function filters_open_after_sale(): bool {
|
||||
return $this->status() === self::STATUS_AFTER_SALE;
|
||||
}
|
||||
|
||||
/** @return array<string, scalar> */
|
||||
public function url_values(): array {
|
||||
@@ -122,7 +116,6 @@ final class OrderCenterQuery {
|
||||
public function repository_args(): array {
|
||||
return [
|
||||
'status_in' => $this->database_statuses(),
|
||||
'after_sale_open' => $this->filters_open_after_sale(),
|
||||
'product_type' => $this->product_type(),
|
||||
'order_number_prefix' => $this->order_number(),
|
||||
'limit' => $this->per_page(),
|
||||
|
||||
@@ -511,10 +511,6 @@ trait OrderRepositoryAdmin {
|
||||
'count' => 0,
|
||||
'latest' => [],
|
||||
],
|
||||
'after_sale' => [
|
||||
'count' => 0,
|
||||
'latest' => [],
|
||||
],
|
||||
'coupon' => [],
|
||||
'pricing' => [
|
||||
'subtotal' => '0.00',
|
||||
@@ -591,10 +587,6 @@ trait OrderRepositoryAdmin {
|
||||
'count' => 0,
|
||||
'latest' => [],
|
||||
],
|
||||
'after_sale' => [
|
||||
'count' => 0,
|
||||
'latest' => [],
|
||||
],
|
||||
'coupon' => $coupon,
|
||||
'pricing' => [
|
||||
'subtotal' => $subtotal,
|
||||
|
||||
@@ -361,11 +361,6 @@ trait OrderRepositoryCore {
|
||||
if ($new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED && empty($extra['allow_refund'])) {
|
||||
return false;
|
||||
}
|
||||
if ($new_status === \AKSRT\Core\DomainModel::ORDER_STATUS_REFUNDED
|
||||
&& (!class_exists(AfterSaleService::class) || !AfterSaleService::get_instance()->can_finalize_order_refund((int) ($extra['after_sale_id'] ?? 0), $order_number))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$preflight = $this->get($order_number);
|
||||
if (!$preflight) {
|
||||
@@ -762,19 +757,6 @@ trait OrderRepositoryCore {
|
||||
}
|
||||
}
|
||||
|
||||
// 售后中:只认「待审核 / 处理中」两张在流转状态,与订单自身状态正交,
|
||||
// 因此作为独立谓词叠加上去(工单已完结或已撤销不算售后中)。
|
||||
if (!empty($args['after_sale_open']) && class_exists(AfterSaleService::class)) {
|
||||
$open = [
|
||||
AfterSaleService::STATUS_PENDING,
|
||||
AfterSaleService::STATUS_PROCESSING,
|
||||
];
|
||||
$where[] = 'order_number IN (SELECT order_number FROM '
|
||||
. \AKSRT\Core\DBMigrator::table('order_aftersale')
|
||||
. ' WHERE status IN (' . implode(', ', array_fill(0, count($open), '%s')) . '))';
|
||||
array_push($params, ...$open);
|
||||
}
|
||||
|
||||
$product_type = sanitize_key((string) ($args['product_type'] ?? ''));
|
||||
if ($product_type !== '') {
|
||||
if ($product_type === \AKSRT\Core\DomainModel::ORDER_PRODUCT_RESOURCE) {
|
||||
|
||||
@@ -183,9 +183,6 @@ class UserOrders {
|
||||
$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 = [
|
||||
@@ -214,7 +211,6 @@ class UserOrders {
|
||||
'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),
|
||||
@@ -229,84 +225,10 @@ class UserOrders {
|
||||
$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<int,array<string,mixed>> $records
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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'];
|
||||
$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:工单仍在流转(待审核 / 处理中),不允许重复申请;
|
||||
// - rejected:审核未通过,允许补充资料后再次申请;
|
||||
// - cancelled:用户自行取消,允许重新发起;
|
||||
// - completed:工单已完结,但只要订单没被另标「已退款」(enabled 仍为 true),
|
||||
// 依旧允许再次申请。能否再售后由「订单是否已退款」决定,不由工单状态决定:
|
||||
// 订单已退款时 $group 变成 refunded、enabled 直接为 false,上面那条自然挡死。
|
||||
$latest_status = (string) ($latest['status'] ?? '');
|
||||
$reapplyable_statuses = [
|
||||
AfterSaleService::STATUS_REJECTED,
|
||||
AfterSaleService::STATUS_CANCELLED,
|
||||
AfterSaleService::STATUS_COMPLETED,
|
||||
];
|
||||
|
||||
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'] ?? ''),
|
||||
// 处理记录:每次状态提交一条(状态 + 备注 + 来源 + 时间),前台时间线的唯一数据源。
|
||||
'handling_log' => array_values((array) ($latest['handling_log'] ?? [])),
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function empty_view(OrderCenterQuery $query): array {
|
||||
|
||||
@@ -569,87 +569,6 @@ class PaymentRuntime {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 售后策略(后台「支付&付费 → 售后策略」分区)的运行时读取。
|
||||
*
|
||||
* read_domain 走 SchemaProvider 的带默认读取:用户从未保存过该分区时,
|
||||
* 预置原因 / 所需材料 / 文案等键仍取 schema 默认值(语义对齐
|
||||
* UserRuntime::ban_settings 对禁封/举报分区的读取)。
|
||||
*
|
||||
* @return array{
|
||||
* enabled:bool,time_limit:int,expired_text:string,
|
||||
* reasons:array<int,array{slug:string,name:string}>,materials:string[],
|
||||
* desc:string,img_enabled:bool,reason_placeholder:string
|
||||
* }
|
||||
*/
|
||||
public static function get_after_sale_settings(): array {
|
||||
$s = class_exists('\AKSRT\Admin\SchemaProvider')
|
||||
? Settings::read_domain('payment', 'after_sale')
|
||||
: Settings::get_all();
|
||||
if (!is_array($s)) {
|
||||
$s = [];
|
||||
}
|
||||
|
||||
$reasons = [];
|
||||
foreach ((array) ($s['after_sale_reasons'] ?? []) as $row) {
|
||||
$text = trim((string) (is_array($row) ? ($row['t'] ?? '') : $row));
|
||||
if ($text === '') {
|
||||
continue;
|
||||
}
|
||||
$reasons[] = ['slug' => self::after_sale_slug($text), 'name' => sanitize_text_field($text)];
|
||||
}
|
||||
|
||||
$materials = [];
|
||||
foreach ((array) ($s['after_sale_materials'] ?? []) as $row) {
|
||||
$text = trim((string) (is_array($row) ? ($row['t'] ?? '') : $row));
|
||||
if ($text !== '') {
|
||||
$materials[] = sanitize_text_field($text);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'enabled' => !isset($s['after_sale_s']) || !empty($s['after_sale_s']),
|
||||
'time_limit' => max(0, min(3650, (int) ($s['after_sale_time_limit'] ?? 7))),
|
||||
'expired_text' => wp_kses_post((string) ($s['after_sale_expired_text'] ?? '')),
|
||||
'reasons' => $reasons,
|
||||
'materials' => $materials,
|
||||
'desc' => wp_kses_post((string) ($s['after_sale_desc'] ?? '')),
|
||||
'img_enabled' => !empty($s['after_sale_img_s']),
|
||||
'reason_placeholder' => sanitize_text_field((string) ($s['after_sale_reason_placeholder'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单是否已超过售后申请时效(自支付时间起算;时效 <=0 或缺支付时间视为未过期)。
|
||||
*
|
||||
* 时区口径与 UserOrders::get_order_countdown 保持一致:均以站点本地时间
|
||||
* 比较存储的 DATETIME 与当前时间。
|
||||
*/
|
||||
public static function after_sale_expired(string $paid_at): bool {
|
||||
$limit = (int) (self::get_after_sale_settings()['time_limit'] ?? 0);
|
||||
if ($limit <= 0) {
|
||||
return false;
|
||||
}
|
||||
$paid_at = trim($paid_at);
|
||||
if ($paid_at === '') {
|
||||
return false;
|
||||
}
|
||||
$paid_timestamp = strtotime($paid_at);
|
||||
if ($paid_timestamp === false) {
|
||||
return false;
|
||||
}
|
||||
return current_time('timestamp') > $paid_timestamp + $limit * DAY_IN_SECONDS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预置原因 slug:sanitize_key 无法处理中文标签(会被清洗成空串),
|
||||
* 与 UserRuntime::ban_settings 同策略——ASCII 标签取 key,其余取稳定短哈希。
|
||||
*/
|
||||
private static function after_sale_slug(string $text): string {
|
||||
$key = sanitize_key($text);
|
||||
return $key !== '' ? $key : substr(md5($text), 0, 12);
|
||||
}
|
||||
|
||||
private static function count_structured_lines(string $raw): int {
|
||||
$lines = preg_split('/[\r\n]+/', $raw, -1, PREG_SPLIT_NO_EMPTY);
|
||||
$count = 0;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
/**
|
||||
* 工单附件:清洗、渲染与下载。
|
||||
*
|
||||
* 上传链路不重写——复用 UserController::rest_upload_comment_image(),
|
||||
* 由 TicketService 注册 /ticket/upload-attachment 并挂工单专属权限回调
|
||||
* (与 UserController 的 /report/upload-image 同构)。因此附件形态限定为图片。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ticket;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class TicketAttachment {
|
||||
|
||||
/**
|
||||
* 入参清洗:只保留非空字符串 URL,按配置上限截断。
|
||||
*
|
||||
* @param mixed $raw
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function sanitize($raw, ?int $max = null): array {
|
||||
if (!is_array($raw)) {
|
||||
return [];
|
||||
}
|
||||
$max = $max ?? TicketSettings::attachment_max();
|
||||
$clean = [];
|
||||
foreach ($raw as $url) {
|
||||
if (!is_string($url) || count($clean) >= $max) {
|
||||
continue;
|
||||
}
|
||||
$url = esc_url_raw(trim($url));
|
||||
if ($url !== '') {
|
||||
$clean[] = $url;
|
||||
}
|
||||
}
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 存储态 JSON → 展示态数组。
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function decode($stored): array {
|
||||
if (is_array($stored)) {
|
||||
return self::sanitize($stored, 9);
|
||||
}
|
||||
$decoded = json_decode((string) $stored, true);
|
||||
return is_array($decoded) ? self::sanitize($decoded, 9) : [];
|
||||
}
|
||||
|
||||
/** @return string */
|
||||
public static function encode(array $urls): string {
|
||||
return wp_json_encode(array_values($urls), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
<?php
|
||||
/**
|
||||
* 工单配置 CRUD trait:自定义状态 / 部门 / 预设回复 / 后台备注。
|
||||
*
|
||||
* 由 TicketRestAdmin 组合,工单本身的读写留在 TicketRestAdmin。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ticket;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait TicketConfigRest {
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 状态
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
public function list_statuses(\WP_REST_Request $request) {
|
||||
return rest_ensure_response(['success' => true, 'data' => TicketStatusService::all()]);
|
||||
}
|
||||
|
||||
public function create_status(\WP_REST_Request $request) {
|
||||
$name = trim((string) $request->get_param('name'));
|
||||
if ($name === '' || mb_strlen($name) > 60) {
|
||||
return new \WP_Error('aksrt_ticket_status_name', __('请填写有效的状态名称', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket_status');
|
||||
if ($table === '') {
|
||||
return new \WP_Error('aksrt_ticket_db', __('状态表不可用', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$max = (int) $wpdb->get_var("SELECT MAX(sort_order) FROM {$table}"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$inserted = $wpdb->insert($table, [
|
||||
'status_key' => TicketStatusService::make_key($name),
|
||||
'name' => $name,
|
||||
'color' => TicketStatusService::sanitize_color((string) $request->get_param('color')),
|
||||
'is_closed' => absint($request->get_param('is_closed')) === 1 ? 1 : 0,
|
||||
'sort_order' => $max + 1,
|
||||
'created_at' => current_time('mysql'),
|
||||
], ['%s', '%s', '%s', '%d', '%d', '%s']);
|
||||
if ($inserted !== 1) {
|
||||
return new \WP_Error('aksrt_ticket_db', __('状态创建失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
TicketStatusService::flush();
|
||||
return rest_ensure_response(['success' => true]);
|
||||
}
|
||||
|
||||
public function update_status(\WP_REST_Request $request) {
|
||||
$id = absint($request->get_param('id'));
|
||||
$row = $this->status_row($id);
|
||||
if ($row === null) {
|
||||
return new \WP_Error('aksrt_ticket_status_missing', __('工单状态不存在', 'aksrt'), ['status' => 404]);
|
||||
}
|
||||
if (!empty($row['is_default'])) {
|
||||
return new \WP_Error('aksrt_ticket_status_default', __('默认状态不可修改', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
$name = trim((string) $request->get_param('name'));
|
||||
if ($name === '' || mb_strlen($name) > 60) {
|
||||
return new \WP_Error('aksrt_ticket_status_name', __('请填写有效的状态名称', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket_status');
|
||||
$updated = $wpdb->update($table, [
|
||||
'name' => $name,
|
||||
'color' => TicketStatusService::sanitize_color((string) $request->get_param('color')),
|
||||
'is_closed' => absint($request->get_param('is_closed')) === 1 ? 1 : 0,
|
||||
'updated_at' => current_time('mysql'),
|
||||
], ['id' => $id], ['%s', '%s', '%d', '%s'], ['%d']);
|
||||
if ($updated === false) {
|
||||
return new \WP_Error('aksrt_ticket_db', __('状态保存失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
TicketStatusService::flush();
|
||||
return rest_ensure_response(['success' => true]);
|
||||
}
|
||||
|
||||
public function delete_status(\WP_REST_Request $request) {
|
||||
$id = absint($request->get_param('id'));
|
||||
$row = $this->status_row($id);
|
||||
if ($row === null) {
|
||||
return new \WP_Error('aksrt_ticket_status_missing', __('工单状态不存在', 'aksrt'), ['status' => 404]);
|
||||
}
|
||||
if (!empty($row['is_default'])) {
|
||||
return new \WP_Error('aksrt_ticket_status_default', __('默认状态不可删除', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
if (TicketRepository::get_instance()->count_by_status((string) $row['status_key']) > 0) {
|
||||
return new \WP_Error('aksrt_ticket_status_in_use', __('该状态下还有工单,无法删除', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket_status');
|
||||
$wpdb->delete($table, ['id' => $id], ['%d']);
|
||||
TicketStatusService::flush();
|
||||
return rest_ensure_response(['success' => true]);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
private function status_row(int $id): ?array {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket_status');
|
||||
if ($table === '' || $id <= 0) {
|
||||
return null;
|
||||
}
|
||||
$row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM {$table} WHERE id = %d", // phpcs:ignore WordPress.DB.PreparedSQL
|
||||
$id
|
||||
), ARRAY_A);
|
||||
return is_array($row) ? $row : null;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 部门
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
public function list_types(\WP_REST_Request $request) {
|
||||
return rest_ensure_response(['success' => true, 'data' => TicketTypeService::all()]);
|
||||
}
|
||||
|
||||
public function create_type(\WP_REST_Request $request) {
|
||||
$name = trim((string) $request->get_param('name'));
|
||||
if ($name === '' || mb_strlen($name) > 120) {
|
||||
return new \WP_Error('aksrt_ticket_type_name', __('请填写有效的部门名称', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if (TicketTypeService::name_taken($name)) {
|
||||
return new \WP_Error('aksrt_ticket_type_duplicate', __('该部门已存在', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket_type');
|
||||
if ($table === '') {
|
||||
return new \WP_Error('aksrt_ticket_db', __('部门表不可用', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$max = (int) $wpdb->get_var("SELECT MAX(sort_order) FROM {$table}"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
$inserted = $wpdb->insert($table, [
|
||||
'name' => $name,
|
||||
'description' => sanitize_text_field((string) $request->get_param('description')),
|
||||
'admin_ids' => wp_json_encode(TicketTypeService::sanitize_admin_ids((array) $request->get_param('admin_ids'))),
|
||||
'sort_order' => $max + 1,
|
||||
'created_at' => current_time('mysql'),
|
||||
], ['%s', '%s', '%s', '%d', '%s']);
|
||||
if ($inserted !== 1) {
|
||||
return new \WP_Error('aksrt_ticket_db', __('部门创建失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
TicketTypeService::flush();
|
||||
return rest_ensure_response(['success' => true]);
|
||||
}
|
||||
|
||||
public function update_type(\WP_REST_Request $request) {
|
||||
$id = absint($request->get_param('id'));
|
||||
if (!TicketTypeService::exists($id)) {
|
||||
return new \WP_Error('aksrt_ticket_type_missing', __('工单部门不存在', 'aksrt'), ['status' => 404]);
|
||||
}
|
||||
$name = trim((string) $request->get_param('name'));
|
||||
if ($name === '' || mb_strlen($name) > 120) {
|
||||
return new \WP_Error('aksrt_ticket_type_name', __('请填写有效的部门名称', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if (TicketTypeService::name_taken($name, $id)) {
|
||||
return new \WP_Error('aksrt_ticket_type_duplicate', __('该部门已存在', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket_type');
|
||||
$updated = $wpdb->update($table, [
|
||||
'name' => $name,
|
||||
'description' => sanitize_text_field((string) $request->get_param('description')),
|
||||
'admin_ids' => wp_json_encode(TicketTypeService::sanitize_admin_ids((array) $request->get_param('admin_ids'))),
|
||||
'updated_at' => current_time('mysql'),
|
||||
], ['id' => $id], ['%s', '%s', '%s', '%s'], ['%d']);
|
||||
if ($updated === false) {
|
||||
return new \WP_Error('aksrt_ticket_db', __('部门保存失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
TicketTypeService::flush();
|
||||
return rest_ensure_response(['success' => true]);
|
||||
}
|
||||
|
||||
public function delete_type(\WP_REST_Request $request) {
|
||||
$id = absint($request->get_param('id'));
|
||||
if (!TicketTypeService::exists($id)) {
|
||||
return new \WP_Error('aksrt_ticket_type_missing', __('工单部门不存在', 'aksrt'), ['status' => 404]);
|
||||
}
|
||||
if (TicketRepository::get_instance()->count_by_type($id) > 0) {
|
||||
return new \WP_Error('aksrt_ticket_type_in_use', __('该部门下还有工单,无法删除', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket_type');
|
||||
$wpdb->delete($table, ['id' => $id], ['%d']);
|
||||
TicketTypeService::flush();
|
||||
return rest_ensure_response(['success' => true]);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 预设回复
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
public function list_prereplies(\WP_REST_Request $request) {
|
||||
return rest_ensure_response(['success' => true, 'data' => TicketPrereplyService::all()]);
|
||||
}
|
||||
|
||||
public function create_prereply(\WP_REST_Request $request) {
|
||||
$content = trim((string) $request->get_param('content'));
|
||||
if ($content === '' || mb_strlen($content) > 2000) {
|
||||
return new \WP_Error('aksrt_ticket_prereply_content', __('请填写有效的预设回复', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if (TicketPrereplyService::create($content) <= 0) {
|
||||
return new \WP_Error('aksrt_ticket_db', __('预设回复创建失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
return rest_ensure_response(['success' => true]);
|
||||
}
|
||||
|
||||
public function update_prereply(\WP_REST_Request $request) {
|
||||
$id = absint($request->get_param('id'));
|
||||
if (TicketPrereplyService::find($id) === null) {
|
||||
return new \WP_Error('aksrt_ticket_prereply_missing', __('预设回复不存在', 'aksrt'), ['status' => 404]);
|
||||
}
|
||||
$content = trim((string) $request->get_param('content'));
|
||||
if ($content === '' || mb_strlen($content) > 2000) {
|
||||
return new \WP_Error('aksrt_ticket_prereply_content', __('请填写有效的预设回复', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if (!TicketPrereplyService::update($id, $content)) {
|
||||
return new \WP_Error('aksrt_ticket_db', __('预设回复保存失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
return rest_ensure_response(['success' => true]);
|
||||
}
|
||||
|
||||
public function delete_prereply(\WP_REST_Request $request) {
|
||||
$id = absint($request->get_param('id'));
|
||||
if (!TicketPrereplyService::delete($id)) {
|
||||
return new \WP_Error('aksrt_ticket_db', __('预设回复删除失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
return rest_ensure_response(['success' => true]);
|
||||
}
|
||||
|
||||
public function reorder_prereplies(\WP_REST_Request $request) {
|
||||
if (!TicketPrereplyService::reorder((array) $request->get_param('ids'))) {
|
||||
return new \WP_Error('aksrt_ticket_prereply_order', __('预设回复排序失败', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
return rest_ensure_response(['success' => true]);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 后台备注
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
public function list_notes(\WP_REST_Request $request) {
|
||||
$id = absint($request->get_param('id'));
|
||||
return rest_ensure_response(['success' => true, 'data' => TicketNoteService::for_ticket($id)]);
|
||||
}
|
||||
|
||||
public function create_note(\WP_REST_Request $request) {
|
||||
$id = absint($request->get_param('id'));
|
||||
if (TicketRepository::get_instance()->find($id) === null) {
|
||||
return new \WP_Error('aksrt_ticket_missing', __('工单不存在', 'aksrt'), ['status' => 404]);
|
||||
}
|
||||
$content = trim((string) $request->get_param('content'));
|
||||
if ($content === '' || mb_strlen($content) > 2000) {
|
||||
return new \WP_Error('aksrt_ticket_note_content', __('请填写有效的备注内容', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if (TicketNoteService::create($id, $content, get_current_user_id()) <= 0) {
|
||||
return new \WP_Error('aksrt_ticket_db', __('备注创建失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
return rest_ensure_response(['success' => true]);
|
||||
}
|
||||
|
||||
public function update_note(\WP_REST_Request $request) {
|
||||
$id = absint($request->get_param('id'));
|
||||
if (TicketNoteService::find($id) === null) {
|
||||
return new \WP_Error('aksrt_ticket_note_missing', __('备注不存在', 'aksrt'), ['status' => 404]);
|
||||
}
|
||||
$content = trim((string) $request->get_param('content'));
|
||||
if ($content === '' || mb_strlen($content) > 2000) {
|
||||
return new \WP_Error('aksrt_ticket_note_content', __('请填写有效的备注内容', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if (!TicketNoteService::update($id, $content)) {
|
||||
return new \WP_Error('aksrt_ticket_db', __('备注保存失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
return rest_ensure_response(['success' => true]);
|
||||
}
|
||||
|
||||
public function delete_note(\WP_REST_Request $request) {
|
||||
$id = absint($request->get_param('id'));
|
||||
if (!TicketNoteService::delete($id)) {
|
||||
return new \WP_Error('aksrt_ticket_db', __('备注删除失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
return rest_ensure_response(['success' => true]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
/**
|
||||
* 工单后台备注:管理员内部时间线,对用户不可见。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ticket;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class TicketNoteService {
|
||||
|
||||
/**
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public static function for_ticket(int $ticket_id): array {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket_note');
|
||||
if ($table === '' || $ticket_id <= 0) {
|
||||
return [];
|
||||
}
|
||||
$rows = $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT * FROM {$table} WHERE ticket_id = %d ORDER BY id ASC", // phpcs:ignore WordPress.DB.PreparedSQL
|
||||
$ticket_id
|
||||
), ARRAY_A);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$out[] = self::normalize($row);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public static function find(int $id): ?array {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket_note');
|
||||
if ($table === '' || $id <= 0) {
|
||||
return null;
|
||||
}
|
||||
$row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM {$table} WHERE id = %d", // phpcs:ignore WordPress.DB.PreparedSQL
|
||||
$id
|
||||
), ARRAY_A);
|
||||
return is_array($row) ? self::normalize($row) : null;
|
||||
}
|
||||
|
||||
public static function create(int $ticket_id, string $content, int $admin_id): int {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket_note');
|
||||
if ($table === '' || $ticket_id <= 0 || $content === '') {
|
||||
return 0;
|
||||
}
|
||||
$inserted = $wpdb->insert($table, [
|
||||
'ticket_id' => $ticket_id,
|
||||
'admin_id' => $admin_id,
|
||||
'content' => $content,
|
||||
'created_at' => current_time('mysql'),
|
||||
], ['%d', '%d', '%s', '%s']);
|
||||
return $inserted === 1 ? (int) $wpdb->insert_id : 0;
|
||||
}
|
||||
|
||||
public static function update(int $id, string $content): bool {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket_note');
|
||||
if ($table === '' || $id <= 0) {
|
||||
return false;
|
||||
}
|
||||
return $wpdb->update($table, ['content' => $content], ['id' => $id], ['%s'], ['%d']) !== false;
|
||||
}
|
||||
|
||||
public static function delete(int $id): bool {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket_note');
|
||||
if ($table === '' || $id <= 0) {
|
||||
return false;
|
||||
}
|
||||
return $wpdb->delete($table, ['id' => $id], ['%d']) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $row
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private static function normalize(array $row): array {
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'ticket_id' => (int) ($row['ticket_id'] ?? 0),
|
||||
'admin_id' => (int) ($row['admin_id'] ?? 0),
|
||||
'content' => (string) ($row['content'] ?? ''),
|
||||
'admin_name' => self::admin_name((int) ($row['admin_id'] ?? 0)),
|
||||
'created_at' => (string) ($row['created_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
private static function admin_name(int $admin_id): string {
|
||||
if ($admin_id <= 0) {
|
||||
return '';
|
||||
}
|
||||
$user = get_userdata($admin_id);
|
||||
return $user instanceof \WP_User ? $user->display_name : '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
/**
|
||||
* 工单站内信通知。
|
||||
*
|
||||
* 复用 AKSRT\Messages\Message::send()(持久化到 aksrt_notification,支持 target_url 深链)。
|
||||
* 邮件 / SMS 不在此范围:主题侧的邮件与短信是另一套实现,接入属于独立范围。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ticket;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class TicketNotifier {
|
||||
|
||||
/** 用户新建工单 → 通知管理员。 */
|
||||
public static function admins_new_ticket(array $ticket): void {
|
||||
$number = (string) ($ticket['ticket_num'] ?? '');
|
||||
$title = (string) ($ticket['title'] ?? '');
|
||||
$content = sprintf(
|
||||
/* translators: 1: ticket number 2: ticket title */
|
||||
__('新工单 %1$s:%2$s', 'aksrt'),
|
||||
$number,
|
||||
$title
|
||||
);
|
||||
self::to_admins('ticket_new', $content, __('新工单待处理', 'aksrt'));
|
||||
}
|
||||
|
||||
/** 管理员回复 → 通知工单归属用户。 */
|
||||
public static function user_replied(array $ticket): void {
|
||||
$user_id = (int) ($ticket['user_id'] ?? 0);
|
||||
if ($user_id <= 0) {
|
||||
return;
|
||||
}
|
||||
$content = sprintf(
|
||||
/* translators: 1: ticket number 2: ticket title */
|
||||
__('您的工单 %1$s(%2$s)收到新回复', 'aksrt'),
|
||||
(string) ($ticket['ticket_num'] ?? ''),
|
||||
(string) ($ticket['title'] ?? '')
|
||||
);
|
||||
self::send($user_id, 'ticket_reply', $content, __('工单有新回复', 'aksrt'), self::user_url());
|
||||
}
|
||||
|
||||
/** 用户回复 / 催单 → 通知跟进人。 */
|
||||
public static function admin_activity(array $ticket, string $kind): void {
|
||||
$admin_id = (int) ($ticket['admin_id'] ?? 0);
|
||||
if ($admin_id <= 0) {
|
||||
return;
|
||||
}
|
||||
if ($kind === 'urge') {
|
||||
$content = sprintf(
|
||||
/* translators: 1: ticket number 2: ticket title */
|
||||
__('用户催单 %1$s(%2$s)', 'aksrt'),
|
||||
(string) ($ticket['ticket_num'] ?? ''),
|
||||
(string) ($ticket['title'] ?? '')
|
||||
);
|
||||
$title = __('工单被催单', 'aksrt');
|
||||
} else {
|
||||
$content = sprintf(
|
||||
/* translators: 1: ticket number 2: ticket title */
|
||||
__('用户回复了工单 %1$s(%2$s)', 'aksrt'),
|
||||
(string) ($ticket['ticket_num'] ?? ''),
|
||||
(string) ($ticket['title'] ?? '')
|
||||
);
|
||||
$title = __('工单有新回复', 'aksrt');
|
||||
}
|
||||
self::send($admin_id, 'ticket_' . $kind, $content, $title, self::admin_url());
|
||||
}
|
||||
|
||||
/** 工单关闭 → 通知对方。 */
|
||||
public static function closed(array $ticket, string $closer): void {
|
||||
$content = sprintf(
|
||||
/* translators: 1: ticket number 2: ticket title */
|
||||
__('工单 %1$s(%2$s)已关闭', 'aksrt'),
|
||||
(string) ($ticket['ticket_num'] ?? ''),
|
||||
(string) ($ticket['title'] ?? '')
|
||||
);
|
||||
if ($closer === 'admin') {
|
||||
$user_id = (int) ($ticket['user_id'] ?? 0);
|
||||
if ($user_id > 0) {
|
||||
self::send($user_id, 'ticket_closed', $content, __('工单已关闭', 'aksrt'), self::user_url());
|
||||
}
|
||||
return;
|
||||
}
|
||||
$admin_id = (int) ($ticket['admin_id'] ?? 0);
|
||||
if ($admin_id > 0) {
|
||||
self::send($admin_id, 'ticket_closed', $content, __('工单已关闭', 'aksrt'), self::admin_url());
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<int> $user_ids */
|
||||
private static function send(int $user_id, string $type, string $content, string $title, string $url): void {
|
||||
if (!class_exists('\AKSRT\Messages\Message')) {
|
||||
return;
|
||||
}
|
||||
\AKSRT\Messages\Message::get_instance()->send($user_id, $type, $content, [
|
||||
'title' => $title,
|
||||
'target_url' => $url,
|
||||
]);
|
||||
}
|
||||
|
||||
private static function to_admins(string $type, string $content, string $title): void {
|
||||
if (!class_exists('WP_User_Query')) {
|
||||
return;
|
||||
}
|
||||
$admins = get_users(['role' => 'administrator', 'fields' => 'ID', 'number' => 20]);
|
||||
foreach ($admins as $admin_id) {
|
||||
self::send((int) $admin_id, $type, $content, $title, self::admin_url());
|
||||
}
|
||||
}
|
||||
|
||||
private static function admin_url(): string {
|
||||
return admin_url('users.php?page=aksrt_tickets');
|
||||
}
|
||||
|
||||
private static function user_url(): string {
|
||||
return function_exists('aksrt_get_user_center_url')
|
||||
? aksrt_get_user_center_url('tickets')
|
||||
: home_url('/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
/**
|
||||
* 工单号生成:前缀 + 年月日 + 6 位随机数字,冲突时重试。
|
||||
*
|
||||
* 与订单号(20/22 位纯数字)区分:工单号对外可读、可口头传达,
|
||||
* 形如 T20261025XXXXXX。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ticket;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class TicketNumber {
|
||||
|
||||
private const MAX_ATTEMPTS = 12;
|
||||
|
||||
public static function generate(): string {
|
||||
$prefix = TicketSettings::number_prefix();
|
||||
$date = current_time('Ymd');
|
||||
|
||||
for ($attempt = 0; $attempt < self::MAX_ATTEMPTS; $attempt++) {
|
||||
$candidate = $prefix . $date . wp_rand(100000, 999999);
|
||||
if (!self::exists($candidate)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
// 极端冲突下追加随机后缀,保证仍可落库(uniq_ticket_num 会兜底)。
|
||||
return $prefix . $date . wp_rand(100000, 999999) . wp_rand(100, 999);
|
||||
}
|
||||
|
||||
private static function exists(string $ticket_num): bool {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket');
|
||||
if ($table === '') {
|
||||
return false;
|
||||
}
|
||||
$found = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT id FROM {$table} WHERE ticket_num = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL
|
||||
$ticket_num
|
||||
));
|
||||
return $found !== null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
/**
|
||||
* 工单预设回复:管理员维护的快捷话术,用户/管理员回复框可一键插入。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ticket;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class TicketPrereplyService {
|
||||
|
||||
/**
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public static function all(): array {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket_prereply');
|
||||
if ($table === '') {
|
||||
return [];
|
||||
}
|
||||
$rows = $wpdb->get_results("SELECT * FROM {$table} ORDER BY sort_order ASC, id ASC", ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$out[] = [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'content' => (string) ($row['content'] ?? ''),
|
||||
'sort_order' => (int) ($row['sort_order'] ?? 0),
|
||||
'created_at' => (string) ($row['created_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 前台只暴露 id + content。
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public static function public_list(): array {
|
||||
$out = [];
|
||||
foreach (self::all() as $row) {
|
||||
$out[] = ['id' => $row['id'], 'content' => $row['content']];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public static function find(int $id): ?array {
|
||||
foreach (self::all() as $row) {
|
||||
if ((int) $row['id'] === $id) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 新建时取当前最大 sort_order + 1,追加到末尾。 */
|
||||
private static function next_sort_order(): int {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket_prereply');
|
||||
if ($table === '') {
|
||||
return 0;
|
||||
}
|
||||
$max = $wpdb->get_var("SELECT MAX(sort_order) FROM {$table}"); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
return (int) $max + 1;
|
||||
}
|
||||
|
||||
public static function create(string $content): int {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket_prereply');
|
||||
if ($table === '') {
|
||||
return 0;
|
||||
}
|
||||
$inserted = $wpdb->insert($table, [
|
||||
'content' => $content,
|
||||
'sort_order' => self::next_sort_order(),
|
||||
'created_at' => current_time('mysql'),
|
||||
], ['%s', '%d', '%s']);
|
||||
return $inserted === 1 ? (int) $wpdb->insert_id : 0;
|
||||
}
|
||||
|
||||
public static function update(int $id, string $content): bool {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket_prereply');
|
||||
if ($table === '' || $id <= 0) {
|
||||
return false;
|
||||
}
|
||||
return $wpdb->update($table, ['content' => $content], ['id' => $id], ['%s'], ['%d']) !== false;
|
||||
}
|
||||
|
||||
public static function delete(int $id): bool {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket_prereply');
|
||||
if ($table === '' || $id <= 0) {
|
||||
return false;
|
||||
}
|
||||
return $wpdb->delete($table, ['id' => $id], ['%d']) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 排序:入参必须是全量 ID(数量不符即拒绝,避免漏排导致顺序错乱)。
|
||||
*
|
||||
* @param list<int> $ids
|
||||
*/
|
||||
public static function reorder(array $ids): bool {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket_prereply');
|
||||
if ($table === '') {
|
||||
return false;
|
||||
}
|
||||
$ids = array_values(array_filter(array_map('intval', $ids), static fn(int $id): bool => $id > 0));
|
||||
$existing = array_map(static fn(array $row): int => (int) $row['id'], self::all());
|
||||
if (count($ids) !== count($existing) || array_diff($ids, $existing) !== []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($ids as $position => $id) {
|
||||
$wpdb->update($table, ['sort_order' => (int) $position], ['id' => $id], ['%d'], ['%d']);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
/**
|
||||
* 工单查询 trait:列表 WHERE 拼装、分页与统计。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ticket;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait TicketQuery {
|
||||
|
||||
/**
|
||||
* 列表查询的 WHERE 片段与参数。
|
||||
*
|
||||
* 用户视角只可能看到自己的工单;管理视角可按用户 / 部门 / 状态 / 关键字过滤。
|
||||
* 管理侧默认隐藏已完结状态(对齐参考实现的 whereNotIn([3,4]))。
|
||||
*
|
||||
* @param array<string,mixed> $args
|
||||
* @return array{0:string,1:list<mixed>}
|
||||
*/
|
||||
private function ticket_where(array $args, bool $is_admin): array {
|
||||
global $wpdb;
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
if (!$is_admin) {
|
||||
$where[] = 'user_id = %d';
|
||||
$params[] = (int) ($args['user_id'] ?? 0);
|
||||
} else {
|
||||
$user_id = (int) ($args['user_id'] ?? 0);
|
||||
if ($user_id > 0) {
|
||||
$where[] = 'user_id = %d';
|
||||
$params[] = $user_id;
|
||||
}
|
||||
}
|
||||
|
||||
$statuses = [];
|
||||
foreach ((array) ($args['status'] ?? []) as $status) {
|
||||
$status = sanitize_key((string) $status);
|
||||
if ($status !== '') {
|
||||
$statuses[] = $status;
|
||||
}
|
||||
}
|
||||
if ($statuses !== []) {
|
||||
$where[] = 'status IN (' . implode(', ', array_fill(0, count($statuses), '%s')) . ')';
|
||||
$params = array_merge($params, $statuses);
|
||||
} elseif ($is_admin) {
|
||||
$closed = TicketStatusService::is_closed(TicketStatusService::KEY_CLOSED);
|
||||
if ($closed) {
|
||||
$where[] = 'status <> %s';
|
||||
$params[] = TicketStatusService::KEY_CLOSED;
|
||||
}
|
||||
}
|
||||
|
||||
$type_id = (int) ($args['ticket_type_id'] ?? 0);
|
||||
if ($type_id > 0) {
|
||||
$where[] = 'ticket_type_id = %d';
|
||||
$params[] = $type_id;
|
||||
}
|
||||
|
||||
$keywords = sanitize_text_field((string) ($args['keywords'] ?? ''));
|
||||
if ($keywords !== '') {
|
||||
$like = '%' . $wpdb->esc_like($keywords) . '%';
|
||||
$where[] = '(ticket_num LIKE %s OR title LIKE %s)';
|
||||
$params[] = $like;
|
||||
$params[] = $like;
|
||||
}
|
||||
|
||||
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) === 1) {
|
||||
$where[] = 'created_at ' . $operator . ' %s';
|
||||
$params[] = $operator === '>=' ? $date . ' 00:00:00' : $date . ' 23:59:59';
|
||||
}
|
||||
}
|
||||
|
||||
unset($wpdb);
|
||||
return [
|
||||
$where === [] ? '1 = 1' : implode(' AND ', $where),
|
||||
$params,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $args
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
private function ticket_rows(array $args, bool $is_admin): array {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket');
|
||||
if ($table === '') {
|
||||
return [];
|
||||
}
|
||||
[$where, $params] = $this->ticket_where($args, $is_admin);
|
||||
$limit = max(1, min(100, (int) ($args['limit'] ?? 20)));
|
||||
$offset = max(0, (int) ($args['offset'] ?? 0));
|
||||
$sql = "SELECT * FROM {$table} WHERE {$where} ORDER BY last_reply_time DESC, id DESC LIMIT %d OFFSET %d";
|
||||
$params[] = $limit;
|
||||
$params[] = $offset;
|
||||
$rows = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A); // phpcs:ignore WordPress.DB.PreparedSQL
|
||||
return is_array($rows) ? $rows : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $args
|
||||
*/
|
||||
private function ticket_count(array $args, bool $is_admin): int {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket');
|
||||
if ($table === '') {
|
||||
return 0;
|
||||
}
|
||||
[$where, $params] = $this->ticket_where($args, $is_admin);
|
||||
$sql = "SELECT COUNT(*) FROM {$table} WHERE {$where}";
|
||||
$count = $params === []
|
||||
? $wpdb->get_var($sql) // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
: $wpdb->get_var($wpdb->prepare($sql, $params)); // phpcs:ignore WordPress.DB.PreparedSQL
|
||||
return (int) $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 各状态计数:侧栏统计卡与后台 badge 用。
|
||||
* 用户视角只统计自己的;管理视角统计全站(可按部门过滤)。
|
||||
*
|
||||
* @param array<string,mixed> $args
|
||||
* @return array<string,int> status_key => count
|
||||
*/
|
||||
private function ticket_statistic(array $args, bool $is_admin): array {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket');
|
||||
$out = [];
|
||||
foreach (array_keys(TicketStatusService::map()) as $key) {
|
||||
$out[(string) $key] = 0;
|
||||
}
|
||||
if ($table === '') {
|
||||
return $out;
|
||||
}
|
||||
|
||||
$query_args = [
|
||||
'user_id' => (int) ($args['user_id'] ?? 0),
|
||||
'ticket_type_id' => $args['ticket_type_id'] ?? 0,
|
||||
];
|
||||
[$where, $params] = $this->ticket_where($query_args, $is_admin);
|
||||
$sql = "SELECT status, COUNT(*) AS total FROM {$table} WHERE {$where} GROUP BY status";
|
||||
$rows = $wpdb->get_results($params === [] ? $sql : $wpdb->prepare($sql, $params), ARRAY_A); // phpcs:ignore WordPress.DB.PreparedSQL
|
||||
if (is_array($rows)) {
|
||||
foreach ($rows as $row) {
|
||||
$key = sanitize_key((string) ($row['status'] ?? ''));
|
||||
if ($key !== '') {
|
||||
$out[$key] = (int) ($row['total'] ?? 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
/**
|
||||
* 工单状态机 trait:迁移校验、落库与通知触发点。
|
||||
*
|
||||
* 方法统一加 _ticket 后缀,避免与 TicketRepository 的对外同名包装器冲突。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ticket;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
trait TicketStatus {
|
||||
|
||||
/**
|
||||
* 执行状态迁移。合法性由 TicketStatusService::can_transition() 判定。
|
||||
*
|
||||
* @param array<string,mixed> $ticket 迁移前的工单行(normalize 后)
|
||||
*/
|
||||
private function transition_ticket(int $ticket_id, array $ticket, string $to, string $actor, int $actor_id): bool {
|
||||
$from = (string) ($ticket['status'] ?? '');
|
||||
if ($ticket_id <= 0 || !TicketStatusService::can_transition($from, $to)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket');
|
||||
if ($table === '') {
|
||||
return false;
|
||||
}
|
||||
$updated = $wpdb->update($table, [
|
||||
'status' => $to,
|
||||
'updated_at' => current_time('mysql'),
|
||||
], ['id' => $ticket_id], ['%s', '%s'], ['%d']);
|
||||
if ($updated === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$ticket['status'] = $to;
|
||||
if ($to === TicketStatusService::KEY_CLOSED) {
|
||||
TicketNotifier::closed($ticket, $actor);
|
||||
}
|
||||
unset($actor_id);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 「接收」:仅从未接单转处理中。
|
||||
*
|
||||
* @param array<string,mixed> $ticket
|
||||
*/
|
||||
private function receive_ticket(int $ticket_id, array $ticket): bool {
|
||||
if ((string) ($ticket['status'] ?? '') !== TicketStatusService::KEY_PENDING) {
|
||||
return false;
|
||||
}
|
||||
return $this->transition_ticket($ticket_id, $ticket, TicketStatusService::KEY_HANDLING, 'admin', get_current_user_id());
|
||||
}
|
||||
|
||||
/**
|
||||
* 「已解决」:直接落到已关闭。不允许从未接单一步跳(can_transition 已挡)。
|
||||
*
|
||||
* @param array<string,mixed> $ticket
|
||||
*/
|
||||
private function resolve_ticket(int $ticket_id, array $ticket): bool {
|
||||
return $this->transition_ticket($ticket_id, $ticket, TicketStatusService::KEY_CLOSED, 'admin', get_current_user_id());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $ticket
|
||||
*/
|
||||
private function close_ticket(int $ticket_id, array $ticket, string $actor): bool {
|
||||
if (TicketStatusService::is_closed((string) ($ticket['status'] ?? ''))) {
|
||||
return false;
|
||||
}
|
||||
return $this->transition_ticket($ticket_id, $ticket, TicketStatusService::KEY_CLOSED, $actor, get_current_user_id());
|
||||
}
|
||||
|
||||
/**
|
||||
* 回复后的状态改写:管理员回复 → 已回复;用户回复 → 待回复。
|
||||
*
|
||||
* @param array<string,mixed> $ticket
|
||||
*/
|
||||
private function reply_status(int $ticket_id, array $ticket, string $author_type): bool {
|
||||
$target = $author_type === 'admin'
|
||||
? TicketStatusService::KEY_ADMIN_REPLIED
|
||||
: TicketStatusService::KEY_USER_REPLIED;
|
||||
$from = (string) ($ticket['status'] ?? '');
|
||||
if ($from === $target) {
|
||||
return true;
|
||||
}
|
||||
// 已完结工单上的回复不改变状态(回复本身由调用方拒绝)。
|
||||
if (TicketStatusService::is_closed($from)) {
|
||||
return false;
|
||||
}
|
||||
return $this->transition_ticket($ticket_id, $ticket, $target, $author_type, get_current_user_id());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
<?php
|
||||
/**
|
||||
* 工单仓储:全部 SQL 集中在此,上层(REST / 后台页)不直接碰 $wpdb。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ticket;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class TicketRepository {
|
||||
|
||||
use TicketQuery;
|
||||
use TicketStatus;
|
||||
|
||||
private static ?self $instance = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
return self::$instance ??= new self();
|
||||
}
|
||||
|
||||
private function __construct() {}
|
||||
|
||||
private function table(): string {
|
||||
return \AKSRT\Core\DBMigrator::table('ticket');
|
||||
}
|
||||
|
||||
private function reply_table(): string {
|
||||
return \AKSRT\Core\DBMigrator::table('ticket_reply');
|
||||
}
|
||||
|
||||
public function create(array $data): int {
|
||||
global $wpdb;
|
||||
$table = $this->table();
|
||||
if ($table === '') {
|
||||
return 0;
|
||||
}
|
||||
$now = current_time('mysql');
|
||||
$inserted = $wpdb->insert($table, [
|
||||
'ticket_num' => (string) ($data['ticket_num'] ?? ''),
|
||||
'user_id' => (int) ($data['user_id'] ?? 0),
|
||||
'title' => (string) ($data['title'] ?? ''),
|
||||
'ticket_type_id' => (int) ($data['ticket_type_id'] ?? 0),
|
||||
'content' => (string) ($data['content'] ?? ''),
|
||||
'status' => (string) ($data['status'] ?? TicketStatusService::KEY_PENDING),
|
||||
'order_number' => (string) ($data['order_number'] ?? ''),
|
||||
'attachment' => (string) ($data['attachment'] ?? ''),
|
||||
'post_time' => $now,
|
||||
'notes' => (string) ($data['notes'] ?? ''),
|
||||
'admin_id' => (int) ($data['admin_id'] ?? 0),
|
||||
'post_admin_id' => (int) ($data['post_admin_id'] ?? 0),
|
||||
'created_at' => $now,
|
||||
], [
|
||||
'%s', '%d', '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s',
|
||||
]);
|
||||
return $inserted === 1 ? (int) $wpdb->insert_id : 0;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public function find(int $id): ?array {
|
||||
global $wpdb;
|
||||
$table = $this->table();
|
||||
if ($table === '' || $id <= 0) {
|
||||
return null;
|
||||
}
|
||||
$row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM {$table} WHERE id = %d", // phpcs:ignore WordPress.DB.PreparedSQL
|
||||
$id
|
||||
), ARRAY_A);
|
||||
return is_array($row) ? $this->normalize($row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 归属校验读取:用户视角必须命中本人。
|
||||
*
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public function find_for_user(int $id, int $user_id): ?array {
|
||||
$row = $this->find($id);
|
||||
if ($row === null || (int) $row['user_id'] !== $user_id) {
|
||||
return null;
|
||||
}
|
||||
return $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 工单号反查(后台深链 / 通知跳转)。
|
||||
*
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public function find_by_number(string $ticket_num): ?array {
|
||||
global $wpdb;
|
||||
$table = $this->table();
|
||||
if ($table === '' || $ticket_num === '') {
|
||||
return null;
|
||||
}
|
||||
$row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM {$table} WHERE ticket_num = %s", // phpcs:ignore WordPress.DB.PreparedSQL
|
||||
$ticket_num
|
||||
), ARRAY_A);
|
||||
return is_array($row) ? $this->normalize($row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $args
|
||||
* @return array{items:list<array<string,mixed>>,total:int}
|
||||
*/
|
||||
public function paginate(array $args, bool $is_admin): array {
|
||||
return [
|
||||
'items' => array_map([$this, 'normalize'], $this->ticket_rows($args, $is_admin)),
|
||||
'total' => $this->ticket_count($args, $is_admin),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $args
|
||||
* @return array<string,int>
|
||||
*/
|
||||
public function statistic(array $args, bool $is_admin): array {
|
||||
return $this->ticket_statistic($args, $is_admin);
|
||||
}
|
||||
|
||||
public function update_fields(int $id, array $fields): bool {
|
||||
global $wpdb;
|
||||
$table = $this->table();
|
||||
if ($table === '' || $id <= 0 || $fields === []) {
|
||||
return false;
|
||||
}
|
||||
$formats = array_map(static fn($value): string => is_int($value) ? '%d' : '%s', array_values($fields));
|
||||
return $wpdb->update($table, $fields, ['id' => $id], $formats, ['%d']) !== false;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 状态机(对外入口,内部规则在 TicketStatus trait)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $ticket
|
||||
*/
|
||||
public function receive(int $id, array $ticket): bool {
|
||||
return $this->receive_ticket($id, $ticket);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $ticket
|
||||
*/
|
||||
public function resolve(int $id, array $ticket): bool {
|
||||
return $this->resolve_ticket($id, $ticket);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $ticket
|
||||
*/
|
||||
public function close(int $id, array $ticket, string $actor): bool {
|
||||
return $this->close_ticket($id, $ticket, $actor);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $ticket
|
||||
*/
|
||||
public function apply_reply_status(int $id, array $ticket, string $author_type): bool {
|
||||
return $this->reply_status($id, $ticket, $author_type);
|
||||
}
|
||||
|
||||
public function count_by_status(string $status_key): int {
|
||||
global $wpdb;
|
||||
$table = $this->table();
|
||||
if ($table === '' || $status_key === '') {
|
||||
return 0;
|
||||
}
|
||||
return (int) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$table} WHERE status = %s", // phpcs:ignore WordPress.DB.PreparedSQL
|
||||
$status_key
|
||||
));
|
||||
}
|
||||
|
||||
public function count_by_type(int $type_id): int {
|
||||
global $wpdb;
|
||||
$table = $this->table();
|
||||
if ($table === '' || $type_id <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return (int) $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM {$table} WHERE ticket_type_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL
|
||||
$type_id
|
||||
));
|
||||
}
|
||||
|
||||
public function touch_reply(int $id, string $author_type, int $author_id): void {
|
||||
global $wpdb;
|
||||
$table = $this->table();
|
||||
if ($table === '') {
|
||||
return;
|
||||
}
|
||||
$fields = ['last_reply_time' => current_time('mysql'), 'updated_at' => current_time('mysql')];
|
||||
if ($author_type === 'admin') {
|
||||
$fields['last_reply_admin_id'] = $author_id;
|
||||
if ($author_id > 0) {
|
||||
$fields['admin_id'] = $author_id;
|
||||
}
|
||||
}
|
||||
$formats = array_map(static fn($value): string => is_int($value) ? '%d' : '%s', array_values($fields));
|
||||
$wpdb->update($table, $fields, ['id' => $id], $formats, ['%d']);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 沟通记录
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
public function create_reply(int $ticket_id, string $author_type, int $author_id, string $content, array $attachment, int $quote_reply_id = 0): int {
|
||||
global $wpdb;
|
||||
$table = $this->reply_table();
|
||||
if ($table === '' || $ticket_id <= 0 || $content === '') {
|
||||
return 0;
|
||||
}
|
||||
$inserted = $wpdb->insert($table, [
|
||||
'ticket_id' => $ticket_id,
|
||||
'author_type' => $author_type,
|
||||
'author_id' => $author_id,
|
||||
'content' => $content,
|
||||
'attachment' => TicketAttachment::encode($attachment),
|
||||
'quote_reply_id' => max(0, $quote_reply_id),
|
||||
'created_at' => current_time('mysql'),
|
||||
], ['%d', '%s', '%d', '%s', '%s', '%d', '%s']);
|
||||
return $inserted === 1 ? (int) $wpdb->insert_id : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 沟通记录(按时间正序)+ 每条的作者名与引用摘要。
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function replies(int $ticket_id): array {
|
||||
global $wpdb;
|
||||
$table = $this->reply_table();
|
||||
if ($table === '' || $ticket_id <= 0) {
|
||||
return [];
|
||||
}
|
||||
$rows = $wpdb->get_results($wpdb->prepare(
|
||||
"SELECT * FROM {$table} WHERE ticket_id = %d ORDER BY created_at ASC, id ASC", // phpcs:ignore WordPress.DB.PreparedSQL
|
||||
$ticket_id
|
||||
), ARRAY_A);
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$out[] = $this->normalize_reply($row);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public function find_reply(int $reply_id): ?array {
|
||||
global $wpdb;
|
||||
$table = $this->reply_table();
|
||||
if ($table === '' || $reply_id <= 0) {
|
||||
return null;
|
||||
}
|
||||
$row = $wpdb->get_row($wpdb->prepare(
|
||||
"SELECT * FROM {$table} WHERE id = %d", // phpcs:ignore WordPress.DB.PreparedSQL
|
||||
$reply_id
|
||||
), ARRAY_A);
|
||||
return is_array($row) ? $this->normalize_reply($row) : null;
|
||||
}
|
||||
|
||||
public function delete_reply(int $reply_id): bool {
|
||||
global $wpdb;
|
||||
$table = $this->reply_table();
|
||||
if ($table === '' || $reply_id <= 0) {
|
||||
return false;
|
||||
}
|
||||
return $wpdb->delete($table, ['id' => $reply_id], ['%d']) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 某工单下管理员最后一次回复时间(邮件/通知文案用,暂留供后续接入)。
|
||||
*/
|
||||
public function last_admin_reply_time(int $ticket_id): string {
|
||||
global $wpdb;
|
||||
$table = $this->reply_table();
|
||||
if ($table === '' || $ticket_id <= 0) {
|
||||
return '';
|
||||
}
|
||||
$value = $wpdb->get_var($wpdb->prepare(
|
||||
"SELECT created_at FROM {$table} WHERE ticket_id = %d AND author_type = 'admin' ORDER BY id DESC LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL
|
||||
$ticket_id
|
||||
));
|
||||
return $value === null ? '' : (string) $value;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 规范化
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $row
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function normalize(array $row): array {
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'ticket_num' => (string) ($row['ticket_num'] ?? ''),
|
||||
'user_id' => (int) ($row['user_id'] ?? 0),
|
||||
'title' => (string) ($row['title'] ?? ''),
|
||||
'ticket_type_id' => (int) ($row['ticket_type_id'] ?? 0),
|
||||
'ticket_type_name' => TicketTypeService::name((int) ($row['ticket_type_id'] ?? 0)),
|
||||
'content' => (string) ($row['content'] ?? ''),
|
||||
'status' => sanitize_key((string) ($row['status'] ?? '')),
|
||||
'status_label' => TicketStatusService::label(sanitize_key((string) ($row['status'] ?? ''))),
|
||||
'status_color' => TicketStatusService::color(sanitize_key((string) ($row['status'] ?? ''))),
|
||||
'is_closed' => TicketStatusService::is_closed(sanitize_key((string) ($row['status'] ?? ''))),
|
||||
'order_number' => (string) ($row['order_number'] ?? ''),
|
||||
'attachment' => TicketAttachment::decode($row['attachment'] ?? ''),
|
||||
'last_reply_time' => (string) ($row['last_reply_time'] ?? ''),
|
||||
'post_time' => (string) ($row['post_time'] ?? ''),
|
||||
'notes' => (string) ($row['notes'] ?? ''),
|
||||
'admin_id' => (int) ($row['admin_id'] ?? 0),
|
||||
'admin_name' => $this->user_name((int) ($row['admin_id'] ?? 0)),
|
||||
'last_reply_admin_id' => (int) ($row['last_reply_admin_id'] ?? 0),
|
||||
'post_admin_id' => (int) ($row['post_admin_id'] ?? 0),
|
||||
'user_name' => $this->user_name((int) ($row['user_id'] ?? 0)),
|
||||
'created_at' => (string) ($row['created_at'] ?? ''),
|
||||
'updated_at' => (string) ($row['updated_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $row
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function normalize_reply(array $row): array {
|
||||
$author_type = (string) ($row['author_type'] ?? '') === 'admin' ? 'admin' : 'user';
|
||||
$author_id = (int) ($row['author_id'] ?? 0);
|
||||
$reply_id = (int) ($row['id'] ?? 0);
|
||||
$quote_id = (int) ($row['quote_reply_id'] ?? 0);
|
||||
return [
|
||||
'id' => $reply_id,
|
||||
'ticket_id' => (int) ($row['ticket_id'] ?? 0),
|
||||
'author_type' => $author_type,
|
||||
'author_id' => $author_id,
|
||||
'author_name' => $this->user_name($author_id),
|
||||
'content' => (string) ($row['content'] ?? ''),
|
||||
'attachment' => TicketAttachment::decode($row['attachment'] ?? ''),
|
||||
'quote_reply_id' => $quote_id,
|
||||
'quote' => $quote_id > 0 ? $this->quote_summary($quote_id, $reply_id) : null,
|
||||
'created_at' => (string) ($row['created_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 引用摘要:被引用的回复可能已被删除,此时返回 is_deleted 标记,
|
||||
* 由界面显示「原回复已删除」而不是空白块。
|
||||
*
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private function quote_summary(int $quote_id, int $self_id): ?array {
|
||||
if ($quote_id === $self_id) {
|
||||
return null;
|
||||
}
|
||||
$quoted = $this->find_reply($quote_id);
|
||||
if ($quoted === null) {
|
||||
return [
|
||||
'id' => $quote_id,
|
||||
'content' => '',
|
||||
'author_type' => '',
|
||||
'author_name' => '',
|
||||
'created_at' => '',
|
||||
'is_deleted' => true,
|
||||
];
|
||||
}
|
||||
return [
|
||||
'id' => $quoted['id'],
|
||||
'content' => mb_substr((string) $quoted['content'], 0, 100),
|
||||
'author_type' => $quoted['author_type'],
|
||||
'author_name' => $quoted['author_name'],
|
||||
'created_at' => $quoted['created_at'],
|
||||
'is_deleted' => false,
|
||||
];
|
||||
}
|
||||
|
||||
private function user_name(int $user_id): string {
|
||||
if ($user_id <= 0) {
|
||||
return '';
|
||||
}
|
||||
$user = get_userdata($user_id);
|
||||
return $user instanceof \WP_User ? $user->display_name : '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
<?php
|
||||
/**
|
||||
* 工单 REST — 用户侧。
|
||||
*
|
||||
* 命名空间 aksrt/v1;每条路由都带 permission_callback,写操作过人机验证。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ticket;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class TicketRest {
|
||||
|
||||
private const CAPTCHA_SCENE_CREATE = 'ticket_create';
|
||||
private const CAPTCHA_SCENE_REPLY = 'ticket_reply';
|
||||
|
||||
public function register_routes(): void {
|
||||
$namespace = 'aksrt/v1';
|
||||
$id = '(?P<id>[0-9]+)';
|
||||
|
||||
register_rest_route($namespace, '/tickets', [
|
||||
[
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'list_tickets'],
|
||||
'permission_callback' => [$this, 'require_user'],
|
||||
'args' => $this->list_args(),
|
||||
],
|
||||
[
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'create_ticket'],
|
||||
'permission_callback' => [$this, 'require_user'],
|
||||
'args' => [
|
||||
'title' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'ticket_type_id' => ['required' => true, 'sanitize_callback' => 'absint'],
|
||||
'content' => ['required' => true, 'sanitize_callback' => 'sanitize_textarea_field'],
|
||||
'order_number' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'attachment' => ['required' => false, 'type' => 'array'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/statistic', [
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'statistic'],
|
||||
'permission_callback' => [$this, 'require_user'],
|
||||
]);
|
||||
|
||||
// 静态子路径必须早于 /tickets/{id} 注册,否则 WordPress 会先用动态规则匹配。
|
||||
register_rest_route($namespace, '/tickets/meta', [
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'meta'],
|
||||
'permission_callback' => [$this, 'require_user'],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/prereply', [
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'prereplies'],
|
||||
'permission_callback' => [$this, 'require_user'],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/' . $id, [
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'get_ticket'],
|
||||
'permission_callback' => [$this, 'require_user'],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/' . $id . '/reply', [
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'reply'],
|
||||
'permission_callback' => [$this, 'require_user'],
|
||||
'args' => [
|
||||
'content' => ['required' => true, 'sanitize_callback' => 'sanitize_textarea_field'],
|
||||
'attachment' => ['required' => false, 'type' => 'array'],
|
||||
'quote_reply_id' => ['required' => false, 'sanitize_callback' => 'absint'],
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/' . $id . '/urge', [
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'urge'],
|
||||
'permission_callback' => [$this, 'require_user'],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/' . $id . '/close', [
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'close'],
|
||||
'permission_callback' => [$this, 'require_user'],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/ticket/upload-attachment', [
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [\AKSRT\Admin\UserController::class, 'rest_upload_comment_image'],
|
||||
'permission_callback' => [$this, 'require_attachment_upload'],
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function list_args(): array {
|
||||
return [
|
||||
'keywords' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'status' => ['required' => false, 'sanitize_callback' => [$this, 'sanitize_status_list']],
|
||||
'ticket_type_id' => ['required' => false, 'sanitize_callback' => 'absint'],
|
||||
'page' => ['required' => false, 'sanitize_callback' => 'absint'],
|
||||
'per_page' => ['required' => false, 'sanitize_callback' => 'absint'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* status 允许传字符串或数组(后台多选筛选复用同一读取器)。
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function sanitize_status_list($value): array {
|
||||
$values = is_array($value) ? $value : explode(',', (string) $value);
|
||||
$out = [];
|
||||
foreach ((array) $values as $item) {
|
||||
$key = sanitize_key((string) $item);
|
||||
if ($key !== '' && TicketStatusService::exists($key)) {
|
||||
$out[] = $key;
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($out));
|
||||
}
|
||||
|
||||
public function require_user() {
|
||||
if (!is_user_logged_in()) {
|
||||
return new \WP_Error('aksrt_need_login', __('请先登录', 'aksrt'), ['status' => 401]);
|
||||
}
|
||||
$nonce = isset($_SERVER['HTTP_X_WP_NONCE']) ? sanitize_text_field((string) wp_unslash($_SERVER['HTTP_X_WP_NONCE'])) : '';
|
||||
return $nonce !== '' && wp_verify_nonce($nonce, 'wp_rest')
|
||||
? true
|
||||
: new \WP_Error('aksrt_rest_nonce', __('安全验证失败,请刷新页面后重试', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
|
||||
public function require_attachment_upload() {
|
||||
$user = $this->require_user();
|
||||
if (is_wp_error($user)) {
|
||||
return $user;
|
||||
}
|
||||
if (!TicketSettings::enabled() || !TicketSettings::attachment_enabled()) {
|
||||
return new \WP_Error('aksrt_ticket_attachment_forbidden', __('工单附件上传未开启', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function list_tickets(\WP_REST_Request $request) {
|
||||
if (!$this->ensure_enabled()) {
|
||||
return $this->disabled();
|
||||
}
|
||||
$page = max(1, (int) $request->get_param('page'));
|
||||
$per_page = max(1, min(50, (int) $request->get_param('per_page') ?: 20));
|
||||
$result = TicketRepository::get_instance()->paginate([
|
||||
'user_id' => get_current_user_id(),
|
||||
'keywords' => $request->get_param('keywords'),
|
||||
'status' => $request->get_param('status'),
|
||||
'ticket_type_id' => $request->get_param('ticket_type_id'),
|
||||
'limit' => $per_page,
|
||||
'offset' => ($page - 1) * $per_page,
|
||||
], false);
|
||||
|
||||
return rest_ensure_response([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'items' => $result['items'],
|
||||
'total' => $result['total'],
|
||||
'page' => $page,
|
||||
'per_page' => $per_page,
|
||||
'total_pages' => max(1, (int) ceil($result['total'] / $per_page)),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function statistic(\WP_REST_Request $request) {
|
||||
if (!$this->ensure_enabled()) {
|
||||
return $this->disabled();
|
||||
}
|
||||
return rest_ensure_response([
|
||||
'success' => true,
|
||||
'data' => TicketRepository::get_instance()->statistic(['user_id' => get_current_user_id()], false),
|
||||
]);
|
||||
}
|
||||
|
||||
/** 下拉数据一次性下发:部门 + 状态 + 可关联订单 + 配置。 */
|
||||
public function meta(\WP_REST_Request $request) {
|
||||
if (!$this->ensure_enabled()) {
|
||||
return $this->disabled();
|
||||
}
|
||||
return rest_ensure_response([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'types' => TicketTypeService::public_list(),
|
||||
'statuses' => TicketStatusService::all(),
|
||||
'prereplies' => TicketPrereplyService::public_list(),
|
||||
'orders' => $this->linkable_orders(),
|
||||
'notice' => [
|
||||
'open' => TicketSettings::notice_open(),
|
||||
'text' => TicketSettings::notice_text(),
|
||||
],
|
||||
'attachment' => [
|
||||
'enabled' => TicketSettings::attachment_enabled(),
|
||||
'max' => TicketSettings::attachment_max(),
|
||||
],
|
||||
'urgeInterval' => TicketSettings::urge_interval(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function prereplies(\WP_REST_Request $request) {
|
||||
return rest_ensure_response([
|
||||
'success' => true,
|
||||
'data' => TicketPrereplyService::public_list(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function get_ticket(\WP_REST_Request $request) {
|
||||
if (!$this->ensure_enabled()) {
|
||||
return $this->disabled();
|
||||
}
|
||||
$id = absint($request->get_param('id'));
|
||||
$repo = TicketRepository::get_instance();
|
||||
$ticket = $repo->find_for_user($id, get_current_user_id());
|
||||
if ($ticket === null) {
|
||||
return new \WP_Error('aksrt_ticket_missing', __('工单不存在', 'aksrt'), ['status' => 404]);
|
||||
}
|
||||
$ticket['replies'] = $repo->replies($id);
|
||||
$ticket['notes'] = [];
|
||||
return rest_ensure_response(['success' => true, 'data' => $ticket]);
|
||||
}
|
||||
|
||||
public function create_ticket(\WP_REST_Request $request) {
|
||||
if (!$this->ensure_enabled()) {
|
||||
return $this->disabled();
|
||||
}
|
||||
$captcha = $this->require_captcha($request, self::CAPTCHA_SCENE_CREATE);
|
||||
if (is_wp_error($captcha)) {
|
||||
return $captcha;
|
||||
}
|
||||
|
||||
$title = trim((string) $request->get_param('title'));
|
||||
$content = trim((string) $request->get_param('content'));
|
||||
$type_id = absint($request->get_param('ticket_type_id'));
|
||||
$order_number = sanitize_text_field((string) $request->get_param('order_number'));
|
||||
|
||||
if ($title === '' || mb_strlen($title) > 200) {
|
||||
return new \WP_Error('aksrt_ticket_title', __('请填写有效的工单标题', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if ($content === '' || mb_strlen($content) > 5000) {
|
||||
return new \WP_Error('aksrt_ticket_content', __('请填写有效的问题描述', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if (!TicketTypeService::exists($type_id)) {
|
||||
return new \WP_Error('aksrt_ticket_type', __('请选择工单部门', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if ($order_number !== '' && !$this->owns_paid_order($order_number)) {
|
||||
return new \WP_Error('aksrt_ticket_order', __('关联订单无效', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
|
||||
$attachment = TicketAttachment::sanitize($request->get_param('attachment'));
|
||||
$id = TicketRepository::get_instance()->create([
|
||||
'ticket_num' => TicketNumber::generate(),
|
||||
'user_id' => get_current_user_id(),
|
||||
'title' => $title,
|
||||
'ticket_type_id' => $type_id,
|
||||
'content' => $content,
|
||||
'order_number' => $order_number,
|
||||
'attachment' => TicketAttachment::encode($attachment),
|
||||
'status' => TicketStatusService::KEY_PENDING,
|
||||
]);
|
||||
if ($id <= 0) {
|
||||
return new \WP_Error('aksrt_ticket_db', __('工单提交失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
|
||||
$ticket = TicketRepository::get_instance()->find($id);
|
||||
if (is_array($ticket)) {
|
||||
TicketNotifier::admins_new_ticket($ticket);
|
||||
}
|
||||
return rest_ensure_response(['success' => true, 'data' => ['id' => $id]]);
|
||||
}
|
||||
|
||||
public function reply(\WP_REST_Request $request) {
|
||||
if (!$this->ensure_enabled()) {
|
||||
return $this->disabled();
|
||||
}
|
||||
$captcha = $this->require_captcha($request, self::CAPTCHA_SCENE_REPLY);
|
||||
if (is_wp_error($captcha)) {
|
||||
return $captcha;
|
||||
}
|
||||
|
||||
$repo = TicketRepository::get_instance();
|
||||
$id = absint($request->get_param('id'));
|
||||
$ticket = $repo->find_for_user($id, get_current_user_id());
|
||||
if ($ticket === null) {
|
||||
return new \WP_Error('aksrt_ticket_missing', __('工单不存在', 'aksrt'), ['status' => 404]);
|
||||
}
|
||||
if (!empty($ticket['is_closed'])) {
|
||||
return new \WP_Error('aksrt_ticket_closed', __('工单已关闭,无法回复', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
|
||||
$content = trim((string) $request->get_param('content'));
|
||||
if ($content === '' || mb_strlen($content) > 3000) {
|
||||
return new \WP_Error('aksrt_ticket_reply_content', __('请填写有效的回复内容', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$quote_id = absint($request->get_param('quote_reply_id'));
|
||||
if ($quote_id > 0) {
|
||||
$quoted = $repo->find_reply($quote_id);
|
||||
if ($quoted === null || (int) $quoted['ticket_id'] !== $id) {
|
||||
$quote_id = 0;
|
||||
}
|
||||
}
|
||||
|
||||
$attachment = TicketAttachment::sanitize($request->get_param('attachment'));
|
||||
$reply_id = $repo->create_reply($id, 'user', get_current_user_id(), $content, $attachment, $quote_id);
|
||||
if ($reply_id <= 0) {
|
||||
return new \WP_Error('aksrt_ticket_db', __('回复提交失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
|
||||
$repo->touch_reply($id, 'user', get_current_user_id());
|
||||
$repo->apply_reply_status($id, $ticket, 'user');
|
||||
TicketNotifier::admin_activity($repo->find($id) ?? $ticket, 'reply');
|
||||
return rest_ensure_response(['success' => true, 'data' => ['reply_id' => $reply_id]]);
|
||||
}
|
||||
|
||||
public function urge(\WP_REST_Request $request) {
|
||||
if (!$this->ensure_enabled()) {
|
||||
return $this->disabled();
|
||||
}
|
||||
$repo = TicketRepository::get_instance();
|
||||
$id = absint($request->get_param('id'));
|
||||
$ticket = $repo->find_for_user($id, get_current_user_id());
|
||||
if ($ticket === null) {
|
||||
return new \WP_Error('aksrt_ticket_missing', __('工单不存在', 'aksrt'), ['status' => 404]);
|
||||
}
|
||||
if (!empty($ticket['is_closed'])) {
|
||||
return new \WP_Error('aksrt_ticket_closed', __('工单已关闭,无法催单', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
$last = get_transient('aksrt_ticket_urge_' . $id);
|
||||
if ($last !== false && (int) $last + TicketSettings::urge_interval() * MINUTE_IN_SECONDS > time()) {
|
||||
return new \WP_Error('aksrt_ticket_urge_limit', __('刚刚已催单,请稍后再试', 'aksrt'), ['status' => 429]);
|
||||
}
|
||||
set_transient('aksrt_ticket_urge_' . $id, time(), TicketSettings::urge_interval() * MINUTE_IN_SECONDS + 60);
|
||||
TicketNotifier::admin_activity($ticket, 'urge');
|
||||
return rest_ensure_response(['success' => true]);
|
||||
}
|
||||
|
||||
public function close(\WP_REST_Request $request) {
|
||||
if (!$this->ensure_enabled()) {
|
||||
return $this->disabled();
|
||||
}
|
||||
$repo = TicketRepository::get_instance();
|
||||
$id = absint($request->get_param('id'));
|
||||
$ticket = $repo->find_for_user($id, get_current_user_id());
|
||||
if ($ticket === null) {
|
||||
return new \WP_Error('aksrt_ticket_missing', __('工单不存在', 'aksrt'), ['status' => 404]);
|
||||
}
|
||||
if (!$repo->close($id, $ticket, 'user')) {
|
||||
return new \WP_Error('aksrt_ticket_state', __('工单状态不允许关闭', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
return rest_ensure_response(['success' => true]);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 内部工具
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
private function ensure_enabled(): bool {
|
||||
return TicketSettings::enabled();
|
||||
}
|
||||
|
||||
private function disabled() {
|
||||
return new \WP_Error('aksrt_ticket_disabled', __('工单功能暂未开启', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 可关联订单:本人已支付订单(未退款、未关闭)。
|
||||
*
|
||||
* OrderRepository::get_user_orders() 返回的是归一化订单行(无 product_name),
|
||||
* 标题从 meta_payload 取,取不到回落订单号。
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
private function linkable_orders(): array {
|
||||
if (!TicketSettings::order_link_enabled() || !class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
return [];
|
||||
}
|
||||
$orders = \AKSRT\Payment\OrderRepository::get_instance()->get_user_orders(get_current_user_id(), [
|
||||
'status' => \AKSRT\Core\DomainModel::ORDER_STATUS_PAID,
|
||||
'limit' => 50,
|
||||
]);
|
||||
if (!is_array($orders)) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach ($orders as $order) {
|
||||
$meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : [];
|
||||
$out[] = [
|
||||
'order_number' => (string) ($order['order_number'] ?? ''),
|
||||
'product_name' => (string) ($meta['product_name'] ?? $meta['title'] ?? ''),
|
||||
'amount' => (string) ($order['amount'] ?? '0'),
|
||||
'currency' => (string) ($order['currency'] ?? ''),
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function owns_paid_order(string $order_number): bool {
|
||||
if ($order_number === '' || !class_exists('\AKSRT\Payment\OrderRepository')) {
|
||||
return false;
|
||||
}
|
||||
$order = \AKSRT\Payment\OrderRepository::get_instance()->get_user_order(get_current_user_id(), $order_number);
|
||||
return is_array($order)
|
||||
&& (string) ($order['status'] ?? '') === \AKSRT\Core\DomainModel::ORDER_STATUS_PAID;
|
||||
}
|
||||
|
||||
/**
|
||||
* 人机验证:场景未启用时直接放行;用户取消验证时返回可辨识的错误。
|
||||
*
|
||||
* @param array<string,mixed> $params
|
||||
*/
|
||||
private function require_captcha(\WP_REST_Request $request, string $scene) {
|
||||
if (!function_exists('aksrt_require_captcha')) {
|
||||
return true;
|
||||
}
|
||||
$params = $request->get_json_params();
|
||||
return aksrt_require_captcha($scene, is_array($params) ? $params : []);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
<?php
|
||||
/**
|
||||
* 工单 REST — 管理侧(后台工单页 AJAX)。
|
||||
*
|
||||
* 命名空间 aksrt/v1/admin;全部路由要求 manage_options。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ticket;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class TicketRestAdmin {
|
||||
|
||||
use TicketConfigRest;
|
||||
|
||||
public function register_routes(): void {
|
||||
$namespace = 'aksrt/v1/admin';
|
||||
$id = '(?P<id>[0-9]+)';
|
||||
|
||||
register_rest_route($namespace, '/tickets', [
|
||||
[
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'list_tickets'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
'args' => $this->list_args(),
|
||||
],
|
||||
[
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'create_ticket'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
'args' => [
|
||||
'user_id' => ['required' => true, 'sanitize_callback' => 'absint'],
|
||||
'title' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'ticket_type_id' => ['required' => true, 'sanitize_callback' => 'absint'],
|
||||
'content' => ['required' => true, 'sanitize_callback' => 'sanitize_textarea_field'],
|
||||
'order_number' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'attachment' => ['required' => false, 'type' => 'array'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/statistic', [
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'statistic'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/statuses', [
|
||||
[
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'list_statuses'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
],
|
||||
[
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'create_status'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
'args' => [
|
||||
'name' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'color' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'is_closed' => ['required' => false, 'sanitize_callback' => 'absint'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/statuses/' . $id, [
|
||||
'methods' => \WP_REST_Server::EDITABLE,
|
||||
'callback' => [$this, 'update_status'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
'args' => [
|
||||
'name' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'color' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'is_closed' => ['required' => false, 'sanitize_callback' => 'absint'],
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/statuses/' . $id . '/delete', [
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'delete_status'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/types', [
|
||||
[
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'list_types'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
],
|
||||
[
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'create_type'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
'args' => [
|
||||
'name' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'description' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'admin_ids' => ['required' => false, 'type' => 'array'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/types/' . $id, [
|
||||
'methods' => \WP_REST_Server::EDITABLE,
|
||||
'callback' => [$this, 'update_type'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
'args' => [
|
||||
'name' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'description' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'admin_ids' => ['required' => false, 'type' => 'array'],
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/types/' . $id . '/delete', [
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'delete_type'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/prereplies', [
|
||||
[
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'list_prereplies'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
],
|
||||
[
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'create_prereply'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
'args' => ['content' => ['required' => true, 'sanitize_callback' => 'sanitize_textarea_field']],
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/prereplies/' . $id, [
|
||||
'methods' => \WP_REST_Server::EDITABLE,
|
||||
'callback' => [$this, 'update_prereply'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
'args' => ['content' => ['required' => false, 'sanitize_callback' => 'sanitize_textarea_field']],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/prereplies/' . $id . '/delete', [
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'delete_prereply'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/prereplies/order', [
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'reorder_prereplies'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
'args' => ['ids' => ['required' => true, 'type' => 'array']],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/' . $id, [
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'get_ticket'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/' . $id . '/reply', [
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'reply'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
'args' => [
|
||||
'content' => ['required' => true, 'sanitize_callback' => 'sanitize_textarea_field'],
|
||||
'attachment' => ['required' => false, 'type' => 'array'],
|
||||
'quote_reply_id' => ['required' => false, 'sanitize_callback' => 'absint'],
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/' . $id . '/status', [
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'set_status'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
'args' => [
|
||||
'status' => ['required' => true, 'sanitize_callback' => 'sanitize_key'],
|
||||
'ticket_type_id' => ['required' => false, 'sanitize_callback' => 'absint'],
|
||||
'admin_id' => ['required' => false, 'sanitize_callback' => 'absint'],
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/' . $id . '/receive', [
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'receive'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/' . $id . '/resolve', [
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'resolve'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/' . $id . '/close', [
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'close'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/' . $id . '/notes', [
|
||||
[
|
||||
'methods' => \WP_REST_Server::READABLE,
|
||||
'callback' => [$this, 'list_notes'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
],
|
||||
[
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'create_note'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
'args' => ['content' => ['required' => true, 'sanitize_callback' => 'sanitize_textarea_field']],
|
||||
],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/notes/' . $id, [
|
||||
'methods' => \WP_REST_Server::EDITABLE,
|
||||
'callback' => [$this, 'update_note'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
'args' => ['content' => ['required' => false, 'sanitize_callback' => 'sanitize_textarea_field']],
|
||||
]);
|
||||
|
||||
register_rest_route($namespace, '/tickets/notes/' . $id . '/delete', [
|
||||
'methods' => \WP_REST_Server::CREATABLE,
|
||||
'callback' => [$this, 'delete_note'],
|
||||
'permission_callback' => [$this, 'require_admin'],
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function list_args(): array {
|
||||
return [
|
||||
'keywords' => ['required' => false, 'sanitize_callback' => 'sanitize_text_field'],
|
||||
'status' => ['required' => false, 'sanitize_callback' => [$this, 'sanitize_status_list']],
|
||||
'ticket_type_id' => ['required' => false, 'sanitize_callback' => 'absint'],
|
||||
'user_id' => ['required' => false, 'sanitize_callback' => 'absint'],
|
||||
'admin_id' => ['required' => false, 'sanitize_callback' => 'absint'],
|
||||
'page' => ['required' => false, 'sanitize_callback' => 'absint'],
|
||||
'per_page' => ['required' => false, 'sanitize_callback' => 'absint'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function sanitize_status_list($value): array {
|
||||
$values = is_array($value) ? $value : explode(',', (string) $value);
|
||||
$out = [];
|
||||
foreach ((array) $values as $item) {
|
||||
$key = sanitize_key((string) $item);
|
||||
if ($key !== '' && TicketStatusService::exists($key)) {
|
||||
$out[] = $key;
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($out));
|
||||
}
|
||||
|
||||
public function require_admin() {
|
||||
if (!current_user_can('manage_options')) {
|
||||
return new \WP_Error('aksrt_ticket_forbidden', __('权限不足', 'aksrt'), ['status' => 403]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 工单
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
public function list_tickets(\WP_REST_Request $request) {
|
||||
$page = max(1, (int) $request->get_param('page'));
|
||||
$per_page = max(1, min(100, (int) $request->get_param('per_page') ?: 20));
|
||||
$result = TicketRepository::get_instance()->paginate($this->read_filters($request, $per_page, $page), true);
|
||||
return rest_ensure_response([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'items' => $result['items'],
|
||||
'total' => $result['total'],
|
||||
'page' => $page,
|
||||
'per_page' => $per_page,
|
||||
'total_pages' => max(1, (int) ceil($result['total'] / $per_page)),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function statistic(\WP_REST_Request $request) {
|
||||
$filters = [
|
||||
'ticket_type_id' => absint($request->get_param('ticket_type_id')),
|
||||
];
|
||||
return rest_ensure_response([
|
||||
'success' => true,
|
||||
'data' => TicketRepository::get_instance()->statistic($filters, true),
|
||||
]);
|
||||
}
|
||||
|
||||
public function get_ticket(\WP_REST_Request $request) {
|
||||
$repo = TicketRepository::get_instance();
|
||||
$ticket = $repo->find(absint($request->get_param('id')));
|
||||
if ($ticket === null) {
|
||||
return new \WP_Error('aksrt_ticket_missing', __('工单不存在', 'aksrt'), ['status' => 404]);
|
||||
}
|
||||
$ticket['replies'] = $repo->replies((int) $ticket['id']);
|
||||
$ticket['notes'] = TicketNoteService::for_ticket((int) $ticket['id']);
|
||||
$ticket['prereplies'] = TicketPrereplyService::public_list();
|
||||
return rest_ensure_response(['success' => true, 'data' => $ticket]);
|
||||
}
|
||||
|
||||
public function create_ticket(\WP_REST_Request $request) {
|
||||
$user_id = absint($request->get_param('user_id'));
|
||||
$title = trim((string) $request->get_param('title'));
|
||||
$content = trim((string) $request->get_param('content'));
|
||||
$type_id = absint($request->get_param('ticket_type_id'));
|
||||
|
||||
if ($user_id <= 0 || !get_userdata($user_id)) {
|
||||
return new \WP_Error('aksrt_ticket_user', __('请选择工单所属用户', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if ($title === '' || mb_strlen($title) > 200) {
|
||||
return new \WP_Error('aksrt_ticket_title', __('请填写有效的工单标题', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if ($content === '' || mb_strlen($content) > 5000) {
|
||||
return new \WP_Error('aksrt_ticket_content', __('请填写有效的问题描述', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
if (!TicketTypeService::exists($type_id)) {
|
||||
return new \WP_Error('aksrt_ticket_type', __('请选择工单部门', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
|
||||
$attachment = TicketAttachment::sanitize($request->get_param('attachment'));
|
||||
$id = TicketRepository::get_instance()->create([
|
||||
'ticket_num' => TicketNumber::generate(),
|
||||
'user_id' => $user_id,
|
||||
'title' => $title,
|
||||
'ticket_type_id' => $type_id,
|
||||
'content' => $content,
|
||||
'order_number' => sanitize_text_field((string) $request->get_param('order_number')),
|
||||
'attachment' => TicketAttachment::encode($attachment),
|
||||
'status' => TicketStatusService::KEY_ADMIN_REPLIED,
|
||||
'post_admin_id' => get_current_user_id(),
|
||||
'admin_id' => get_current_user_id(),
|
||||
]);
|
||||
if ($id <= 0) {
|
||||
return new \WP_Error('aksrt_ticket_db', __('工单创建失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$ticket = TicketRepository::get_instance()->find($id);
|
||||
if (is_array($ticket)) {
|
||||
TicketNotifier::user_replied($ticket);
|
||||
}
|
||||
return rest_ensure_response(['success' => true, 'data' => ['id' => $id]]);
|
||||
}
|
||||
|
||||
public function reply(\WP_REST_Request $request) {
|
||||
$repo = TicketRepository::get_instance();
|
||||
$id = absint($request->get_param('id'));
|
||||
$ticket = $repo->find($id);
|
||||
if ($ticket === null) {
|
||||
return new \WP_Error('aksrt_ticket_missing', __('工单不存在', 'aksrt'), ['status' => 404]);
|
||||
}
|
||||
if (!empty($ticket['is_closed'])) {
|
||||
return new \WP_Error('aksrt_ticket_closed', __('工单已关闭,无法回复', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
$content = trim((string) $request->get_param('content'));
|
||||
if ($content === '' || mb_strlen($content) > 3000) {
|
||||
return new \WP_Error('aksrt_ticket_reply_content', __('请填写有效的回复内容', 'aksrt'), ['status' => 400]);
|
||||
}
|
||||
$quote_id = absint($request->get_param('quote_reply_id'));
|
||||
if ($quote_id > 0) {
|
||||
$quoted = $repo->find_reply($quote_id);
|
||||
if ($quoted === null || (int) $quoted['ticket_id'] !== $id) {
|
||||
$quote_id = 0;
|
||||
}
|
||||
}
|
||||
|
||||
$attachment = TicketAttachment::sanitize($request->get_param('attachment'));
|
||||
$reply_id = $repo->create_reply($id, 'admin', get_current_user_id(), $content, $attachment, $quote_id);
|
||||
if ($reply_id <= 0) {
|
||||
return new \WP_Error('aksrt_ticket_db', __('回复提交失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$repo->touch_reply($id, 'admin', get_current_user_id());
|
||||
$repo->apply_reply_status($id, $ticket, 'admin');
|
||||
TicketNotifier::user_replied($repo->find($id) ?? $ticket);
|
||||
return rest_ensure_response(['success' => true, 'data' => ['reply_id' => $reply_id]]);
|
||||
}
|
||||
|
||||
public function set_status(\WP_REST_Request $request) {
|
||||
$repo = TicketRepository::get_instance();
|
||||
$id = absint($request->get_param('id'));
|
||||
$ticket = $repo->find($id);
|
||||
if ($ticket === null) {
|
||||
return new \WP_Error('aksrt_ticket_missing', __('工单不存在', 'aksrt'), ['status' => 404]);
|
||||
}
|
||||
$to = sanitize_key((string) $request->get_param('status'));
|
||||
if (!TicketStatusService::can_transition((string) $ticket['status'], $to)) {
|
||||
return new \WP_Error('aksrt_ticket_state', __('工单状态不允许该变更', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
|
||||
$fields = ['status' => $to];
|
||||
$type_id = absint($request->get_param('ticket_type_id'));
|
||||
if ($type_id > 0 && TicketTypeService::exists($type_id)) {
|
||||
$fields['ticket_type_id'] = $type_id;
|
||||
}
|
||||
$admin_id = absint($request->get_param('admin_id'));
|
||||
if ($admin_id > 0 && get_userdata($admin_id)) {
|
||||
$fields['admin_id'] = $admin_id;
|
||||
}
|
||||
if (!$repo->update_fields($id, $fields)) {
|
||||
return new \WP_Error('aksrt_ticket_db', __('工单状态保存失败', 'aksrt'), ['status' => 500]);
|
||||
}
|
||||
$ticket = array_merge($ticket, $fields);
|
||||
if ($to === TicketStatusService::KEY_CLOSED) {
|
||||
TicketNotifier::closed($ticket, 'admin');
|
||||
}
|
||||
return rest_ensure_response(['success' => true, 'data' => $repo->find($id)]);
|
||||
}
|
||||
|
||||
public function receive(\WP_REST_Request $request) {
|
||||
return $this->simple_action($request, 'receive');
|
||||
}
|
||||
|
||||
public function resolve(\WP_REST_Request $request) {
|
||||
return $this->simple_action($request, 'resolve');
|
||||
}
|
||||
|
||||
public function close(\WP_REST_Request $request) {
|
||||
return $this->simple_action($request, 'close');
|
||||
}
|
||||
|
||||
/** 接收 / 已解决 / 关闭:三个动作都只是状态迁移,共用同一外壳。 */
|
||||
private function simple_action(\WP_REST_Request $request, string $action) {
|
||||
$repo = TicketRepository::get_instance();
|
||||
$id = absint($request->get_param('id'));
|
||||
$ticket = $repo->find($id);
|
||||
if ($ticket === null) {
|
||||
return new \WP_Error('aksrt_ticket_missing', __('工单不存在', 'aksrt'), ['status' => 404]);
|
||||
}
|
||||
$ok = match ($action) {
|
||||
'receive' => $repo->receive($id, $ticket),
|
||||
'resolve' => $repo->resolve($id, $ticket),
|
||||
default => $repo->close($id, $ticket, 'admin'),
|
||||
};
|
||||
if (!$ok) {
|
||||
return new \WP_Error('aksrt_ticket_state', __('工单状态不允许该操作', 'aksrt'), ['status' => 409]);
|
||||
}
|
||||
return rest_ensure_response(['success' => true, 'data' => $repo->find($id)]);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* 内部
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function read_filters(\WP_REST_Request $request, int $per_page, int $page): array {
|
||||
return [
|
||||
'keywords' => $request->get_param('keywords'),
|
||||
'status' => $request->get_param('status'),
|
||||
'ticket_type_id' => $request->get_param('ticket_type_id'),
|
||||
'user_id' => $request->get_param('user_id'),
|
||||
'admin_id' => $request->get_param('admin_id'),
|
||||
'limit' => $per_page,
|
||||
'offset' => ($page - 1) * $per_page,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
/**
|
||||
* 工单系统入口(薄壳)。
|
||||
*
|
||||
* 只负责单例、REST 注册与配置缓存失效;业务在 TicketRepository /
|
||||
* TicketRest / TicketRestAdmin / TicketConfigRest 与各 Service 中。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ticket;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class TicketService {
|
||||
|
||||
private static ?self $instance = null;
|
||||
|
||||
public static function get_instance(): self {
|
||||
return self::$instance ??= new self();
|
||||
}
|
||||
|
||||
public function init(): void {
|
||||
add_action('rest_api_init', [$this, 'register_routes']);
|
||||
add_action('aksrt_settings_domain_saved', static function (string $domain, string $section): void {
|
||||
if ($domain === 'users' && $section === TicketSettings::SECTION) {
|
||||
TicketTypeService::flush();
|
||||
TicketStatusService::flush();
|
||||
}
|
||||
}, 10, 2);
|
||||
}
|
||||
|
||||
public function register_routes(): void {
|
||||
(new TicketRest())->register_routes();
|
||||
(new TicketRestAdmin())->register_routes();
|
||||
}
|
||||
|
||||
/** 供后台页与前台模板复用的下拉数据(不经过 REST)。 */
|
||||
public static function meta(): array {
|
||||
return [
|
||||
'types' => TicketTypeService::all(),
|
||||
'statuses' => TicketStatusService::all(),
|
||||
'prereplies' => TicketPrereplyService::all(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
/**
|
||||
* Ticket settings reader — 工单配置(域 users,分区 ticket)。
|
||||
*
|
||||
* 字段键、顺序、类型与默认值需与 assets/admin/src/schema/sections/users/ticket.js
|
||||
* 及 src/Admin/Schema/Users/SchemaUsersTicket.php 保持 1:1。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ticket;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class TicketSettings {
|
||||
|
||||
/** 默认分区名,SchemaProvider 据此定位 schema。 */
|
||||
public const SECTION = 'ticket';
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public static function all(): array {
|
||||
$s = class_exists('\AKSRT\Settings\Settings')
|
||||
? \AKSRT\Settings\Settings::read_domain('users', self::SECTION)
|
||||
: [];
|
||||
return is_array($s) ? $s : [];
|
||||
}
|
||||
|
||||
public static function enabled(): bool {
|
||||
$s = self::all();
|
||||
return !isset($s['ticket_s']) || !empty($s['ticket_s']);
|
||||
}
|
||||
|
||||
/** 工单号前缀(仅保留安全字符,空值回落 T)。 */
|
||||
public static function number_prefix(): string {
|
||||
$raw = sanitize_key((string) (self::all()['ticket_num_prefix'] ?? 'T'));
|
||||
return $raw !== '' ? strtoupper($raw) : 'T';
|
||||
}
|
||||
|
||||
public static function notice_open(): bool {
|
||||
return !empty(self::all()['ticket_notice_open']);
|
||||
}
|
||||
|
||||
public static function notice_text(): string {
|
||||
return wp_kses_post((string) (self::all()['ticket_notice_text'] ?? ''));
|
||||
}
|
||||
|
||||
public static function attachment_enabled(): bool {
|
||||
return !empty(self::all()['ticket_attachment_s']);
|
||||
}
|
||||
|
||||
/** 单个工单(主内容 + 每条回复)的附件数量上限。 */
|
||||
public static function attachment_max(): int {
|
||||
return max(1, min(9, (int) (self::all()['ticket_attachment_max'] ?? 5)));
|
||||
}
|
||||
|
||||
/** 是否允许在新建工单时关联订单。 */
|
||||
public static function order_link_enabled(): bool {
|
||||
return !empty(self::all()['ticket_order_link_s']);
|
||||
}
|
||||
|
||||
/** 催单冷却分钟数。 */
|
||||
public static function urge_interval(): int {
|
||||
return max(1, min(1440, (int) (self::all()['ticket_urge_interval'] ?? 15)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
/**
|
||||
* 工单状态(自定义):CRUD + seeds 兜底 + 状态机迁移规则。
|
||||
*
|
||||
* 状态是数据驱动的:管理员可增删改,因此状态机不写死合法目标集合,
|
||||
* 只校验「目标状态必须存在」以及几条业务不变量(已完结不可流转、
|
||||
* 「已解决」不允许从未接单直接跳、默认状态不可改删)。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ticket;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class TicketStatusService {
|
||||
|
||||
/** 内置状态 key:seeds 写入后不可改删的默认态。 */
|
||||
public const KEY_PENDING = 'pending';
|
||||
public const KEY_HANDLING = 'handling';
|
||||
public const KEY_USER_REPLIED = 'user_replied';
|
||||
public const KEY_ADMIN_REPLIED = 'admin_replied';
|
||||
public const KEY_CLOSED = 'closed';
|
||||
|
||||
/** @var array<string,array<string,mixed>>|null status_key => row */
|
||||
private static ?array $cache = null;
|
||||
|
||||
public static function flush(): void {
|
||||
self::$cache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 全部状态,按 sort_order / id 正序。
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public static function all(): array {
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket_status');
|
||||
if ($table === '') {
|
||||
return [];
|
||||
}
|
||||
$rows = $wpdb->get_results("SELECT * FROM {$table} ORDER BY sort_order ASC, id ASC", ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
self::$cache = [];
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$normalized = self::normalize($row);
|
||||
self::$cache[(string) $normalized['status_key']] = $normalized;
|
||||
$out[] = $normalized;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return array<string,array<string,mixed>> status_key => row */
|
||||
public static function map(): array {
|
||||
if (self::$cache === null) {
|
||||
self::all();
|
||||
}
|
||||
return self::$cache ?? [];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public static function find(string $status_key): ?array {
|
||||
$map = self::map();
|
||||
return $map[$status_key] ?? null;
|
||||
}
|
||||
|
||||
public static function exists(string $status_key): bool {
|
||||
return self::find($status_key) !== null;
|
||||
}
|
||||
|
||||
public static function is_closed(string $status_key): bool {
|
||||
$row = self::find($status_key);
|
||||
return $row !== null && !empty($row['is_closed']);
|
||||
}
|
||||
|
||||
/** 状态展示名;未知 key 回落 key 本身(工单可能引用已被删的状态)。 */
|
||||
public static function label(string $status_key): string {
|
||||
$row = self::find($status_key);
|
||||
return $row !== null ? (string) $row['name'] : $status_key;
|
||||
}
|
||||
|
||||
public static function color(string $status_key): string {
|
||||
$row = self::find($status_key);
|
||||
return $row !== null ? (string) $row['color'] : '#6b7280';
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态机:从 $from 迁移到 $to 是否合法。
|
||||
*
|
||||
* 只挡确定非法的组合,其余交给「目标状态必须存在」:
|
||||
* - 已完结状态(is_closed)不可再流转;
|
||||
* - 「已解决/关闭」类终态不允许从未接单一步跳过去(必须先接收或回复),
|
||||
* 避免管理员误把刚提交的工单直接关掉;
|
||||
* - 同状态原地提交视为合法(幂等,便于后台重复点击)。
|
||||
*/
|
||||
public static function can_transition(string $from, string $to): bool {
|
||||
if ($to === '' || !self::exists($to)) {
|
||||
return false;
|
||||
}
|
||||
if ($from === $to) {
|
||||
return true;
|
||||
}
|
||||
if (self::is_closed($from)) {
|
||||
return false;
|
||||
}
|
||||
if ($from === self::KEY_PENDING && $to === self::KEY_CLOSED) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $row
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private static function normalize(array $row): array {
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'status_key' => sanitize_key((string) ($row['status_key'] ?? '')),
|
||||
'name' => sanitize_text_field((string) ($row['name'] ?? '')),
|
||||
'color' => self::sanitize_color((string) ($row['color'] ?? '')),
|
||||
'is_closed' => !empty($row['is_closed']),
|
||||
'is_default' => !empty($row['is_default']),
|
||||
'sort_order' => (int) ($row['sort_order'] ?? 0),
|
||||
'created_at' => (string) ($row['created_at'] ?? ''),
|
||||
'updated_at' => (string) ($row['updated_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/** 颜色只接受 #rgb / #rrggbb,其余回落默认灰。 */
|
||||
public static function sanitize_color(string $color): string {
|
||||
$color = strtolower(trim($color));
|
||||
if (preg_match('/^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/', $color) !== 1) {
|
||||
return '#6b7280';
|
||||
}
|
||||
return $color;
|
||||
}
|
||||
|
||||
/** 新建状态时生成唯一 status_key(ASCII 标签取 key,其余取短哈希)。 */
|
||||
public static function make_key(string $name): string {
|
||||
$key = sanitize_key($name);
|
||||
if ($key === '') {
|
||||
$key = 'st' . substr(md5($name . (string) wp_rand(1, 999999)), 0, 8);
|
||||
}
|
||||
$base = $key;
|
||||
$suffix = 1;
|
||||
while (self::exists($key)) {
|
||||
$key = $base . $suffix;
|
||||
$suffix++;
|
||||
}
|
||||
return $key;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
/**
|
||||
* 工单部门:CRUD + 受理管理员(admin_ids JSON)。
|
||||
*
|
||||
* @package AKSRT
|
||||
*/
|
||||
|
||||
namespace AKSRT\Ticket;
|
||||
|
||||
defined('ABSPATH') || exit;
|
||||
|
||||
final class TicketTypeService {
|
||||
|
||||
/** @var list<array<string,mixed>>|null */
|
||||
private static ?array $cache = null;
|
||||
|
||||
public static function flush(): void {
|
||||
self::$cache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public static function all(): array {
|
||||
if (self::$cache !== null) {
|
||||
return self::$cache;
|
||||
}
|
||||
global $wpdb;
|
||||
$table = \AKSRT\Core\DBMigrator::table('ticket_type');
|
||||
if ($table === '') {
|
||||
return [];
|
||||
}
|
||||
$rows = $wpdb->get_results("SELECT * FROM {$table} ORDER BY sort_order ASC, id ASC", ARRAY_A); // phpcs:ignore WordPress.DB.DirectDatabaseQuery
|
||||
if (!is_array($rows)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$out[] = self::normalize($row);
|
||||
}
|
||||
self::$cache = $out;
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 前台可选部门(不含 description / admin_ids 内部字段)。
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public static function public_list(): array {
|
||||
$out = [];
|
||||
foreach (self::all() as $row) {
|
||||
$out[] = [
|
||||
'id' => $row['id'],
|
||||
'name' => $row['name'],
|
||||
'description' => $row['description'],
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public static function find(int $id): ?array {
|
||||
foreach (self::all() as $row) {
|
||||
if ((int) $row['id'] === $id) {
|
||||
return $row;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function exists(int $id): bool {
|
||||
return self::find($id) !== null;
|
||||
}
|
||||
|
||||
public static function name(int $id): string {
|
||||
$row = self::find($id);
|
||||
return $row !== null ? (string) $row['name'] : '';
|
||||
}
|
||||
|
||||
/** 部门名是否已被占用(新建/改名时查重)。 */
|
||||
public static function name_taken(string $name, int $ignore_id = 0): bool {
|
||||
$name = trim($name);
|
||||
foreach (self::all() as $row) {
|
||||
if ((int) $row['id'] !== $ignore_id && (string) $row['name'] === $name) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,mixed> $admin_ids
|
||||
* @return list<int>
|
||||
*/
|
||||
public static function sanitize_admin_ids(array $admin_ids): array {
|
||||
$clean = [];
|
||||
foreach ($admin_ids as $id) {
|
||||
$id = (int) $id;
|
||||
if ($id > 0 && !in_array($id, $clean, true)) {
|
||||
$clean[] = $id;
|
||||
}
|
||||
}
|
||||
return $clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $row
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private static function normalize(array $row): array {
|
||||
$admins = json_decode((string) ($row['admin_ids'] ?? ''), true);
|
||||
return [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'name' => sanitize_text_field((string) ($row['name'] ?? '')),
|
||||
'description' => sanitize_text_field((string) ($row['description'] ?? '')),
|
||||
'admin_ids' => is_array($admins) ? self::sanitize_admin_ids($admins) : [],
|
||||
'sort_order' => (int) ($row['sort_order'] ?? 0),
|
||||
'created_at' => (string) ($row['created_at'] ?? ''),
|
||||
'updated_at' => (string) ($row['updated_at'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -197,6 +197,12 @@ class Center {
|
||||
'priority' => 48,
|
||||
'type' => 'panel',
|
||||
],
|
||||
'tickets' => [
|
||||
'label' => __('工单中心', 'aksrt'),
|
||||
'icon' => 'life-ring',
|
||||
'priority' => 49,
|
||||
'type' => 'panel',
|
||||
],
|
||||
'credits' => [
|
||||
'label' => class_exists('\AKSRT\Settings\Settings')
|
||||
? sprintf(__('我的%s', 'aksrt'), \AKSRT\Settings\Settings::get_currency_name())
|
||||
@@ -248,6 +254,7 @@ class Center {
|
||||
&& !empty(\AKSRT\Settings\Settings::get_payment_revenue_settings()['enabled']);
|
||||
$vip_on = class_exists('\AKSRT\Settings\Settings')
|
||||
&& !empty(\AKSRT\Settings\Settings::get_payment_vip_settings()['enabled']);
|
||||
$ticket_on = class_exists('\AKSRT\Ticket\TicketSettings') && \AKSRT\Ticket\TicketSettings::enabled();
|
||||
|
||||
// Follow configured order; always auto-include enabled surfaces.
|
||||
$order = $configured;
|
||||
@@ -307,6 +314,15 @@ class Center {
|
||||
}
|
||||
array_splice($order, $insert_at, 0, ['orders']);
|
||||
}
|
||||
// 工单中心:紧邻「我的订单」,开关关闭时整块移除。
|
||||
if ($ticket_on && !in_array('tickets', $order, true)) {
|
||||
$insert_at = array_search('orders', $order, true);
|
||||
array_splice($order, $insert_at === false ? count($order) : (int) $insert_at + 1, 0, ['tickets']);
|
||||
}
|
||||
if (!$ticket_on) {
|
||||
$order = array_values(array_filter($order, static fn(string $key): bool => $key !== 'tickets'));
|
||||
}
|
||||
|
||||
if (($credits_on || $balance_on) && !in_array('credits', $order, true)) {
|
||||
$order[] = 'credits';
|
||||
}
|
||||
@@ -354,6 +370,9 @@ class Center {
|
||||
if ($key === 'orders' && !$orders_on) {
|
||||
continue;
|
||||
}
|
||||
if ($key === 'tickets' && !$ticket_on) {
|
||||
continue;
|
||||
}
|
||||
if ($key === 'auth' && !$auth_on) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
* 的那份从未被调用(死代码),两页各自又内联了一份 paginate_links。
|
||||
* 3) 页码越界(手工改 URL、或筛选后总数变小)会让 offset 落到数据之外:表格空掉
|
||||
* 且分页条不输出,只能手改 URL 退出。现在每页回退到最后一页重查一次。
|
||||
* 4) 「售后服务」原本写死 limit => 100,完全没有分页。
|
||||
*
|
||||
* 本契约固化:
|
||||
* - 七个页面全部改走 AdminListPagination::render(),不再有各自的翻页实现;
|
||||
@@ -60,7 +59,7 @@ const emptyStates = {
|
||||
chargeCards: '未找到对应内容',
|
||||
};
|
||||
|
||||
test('七个页面全部改走共享分页助手', () => {
|
||||
test('六个页面全部改走共享分页助手', () => {
|
||||
for (const [key, src] of Object.entries(pages)) {
|
||||
assert.ok(
|
||||
src.includes('AdminListPagination::render('),
|
||||
@@ -68,10 +67,8 @@ test('七个页面全部改走共享分页助手', () => {
|
||||
);
|
||||
}
|
||||
|
||||
// 订单明细 + 售后服务在同一个文件里,各有一处。
|
||||
assert.equal(count(pages.orders, /AdminListPagination::render\(/g), 2, '订单明细与售后服务应各有一处');
|
||||
// 每个页面恰好一处;售后页已整体移出支付中心(工单后台在 users.php)。
|
||||
for (const key of Object.keys(pages)) {
|
||||
if (key === 'orders') continue;
|
||||
assert.equal(count(pages[key], /AdminListPagination::render\(/g), 1, `${labels[key]} 只应有一处`);
|
||||
}
|
||||
});
|
||||
@@ -123,7 +120,7 @@ test('每页都在查询前夹取页码,越界时重查一次', () => {
|
||||
);
|
||||
|
||||
const requeries = {
|
||||
orders: [/\$items\s*=\s*\$repo->get_orders_admin\(/g, /\$items\s*=\s*\$service->admin_list\(/g],
|
||||
orders: [/\$items\s*=\s*\$repo->get_orders_admin\(/g],
|
||||
products: [/\$query\s*=\s*\$this->query_paid_posts\(/g],
|
||||
coupons: [/\$svc->get_list\(/g],
|
||||
income: [/\$svc->get_list\(/g],
|
||||
@@ -138,33 +135,10 @@ test('每页都在查询前夹取页码,越界时重查一次', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('售后服务改为真正的 limit/offset 分页', () => {
|
||||
assert.doesNotMatch(pages.orders, /limit => 100/, '售后服务仍写死 limit => 100');
|
||||
assert.match(pages.orders, /\$total = \$service->count\(\$filter_args\);/, '售后服务未用 AfterSaleService::count() 取总数');
|
||||
|
||||
// count() 不像 admin_list() 那样校验状态,查询前必须先归一,否则分页条会说谎。
|
||||
assert.match(pages.orders, /if \(!in_array\(\$status, \$this->after_sale_statuses\(\), true\)\)/);
|
||||
assert.match(pages.orders, /private function after_sale_statuses\(\): array \{/);
|
||||
});
|
||||
|
||||
test('分页条晚于空态分支渲染', () => {
|
||||
for (const [key, src] of Object.entries(pages)) {
|
||||
const marker = emptyStates[key];
|
||||
assert.ok(src.includes(marker), `${labels[key]} 找不到空态标记 ${marker}`);
|
||||
|
||||
if (key === 'orders') {
|
||||
// 订单明细是第一处,售后服务是最后一处。
|
||||
assert.ok(
|
||||
src.indexOf('AdminListPagination::render(') > src.indexOf(marker),
|
||||
'订单明细的分页条必须晚于空态分支'
|
||||
);
|
||||
assert.ok(
|
||||
src.lastIndexOf('AdminListPagination::render(') > src.indexOf('暂无售后申请'),
|
||||
'售后服务的分页条必须晚于空态分支'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
src.indexOf('AdminListPagination::render(') > src.indexOf(marker),
|
||||
`${labels[key]} 的分页条必须晚于空态分支`
|
||||
|
||||
@@ -1,331 +0,0 @@
|
||||
<?php
|
||||
// Dev harness: render the REAL PayOrders pages with WP stubs, and drive the refund
|
||||
// handlers through their scenarios.
|
||||
// Usage:
|
||||
// php render-after-sales-harness.php -> after-sales page -> after-sales-rendered.html
|
||||
// php render-after-sales-harness.php orders -> orders list page -> orders-rendered.html
|
||||
// php render-after-sales-harness.php <scenario> -> run one handler scenario, print JSON log
|
||||
namespace {
|
||||
define('ABSPATH', __DIR__ . '/');
|
||||
|
||||
final class HarnessLog {
|
||||
/** @var list<array<string,mixed>> */
|
||||
public static array $events = [];
|
||||
public static function add(string $what, array $data = []): void {
|
||||
self::$events[] = ['what' => $what] + $data;
|
||||
}
|
||||
}
|
||||
|
||||
final class WP_Error {
|
||||
public function __construct(private string $code, private string $message, private array $data = []) {}
|
||||
public function get_error_code(): string { return $this->code; }
|
||||
public function get_error_message(): string { return $this->message; }
|
||||
public function get_error_data(): array { return $this->data; }
|
||||
}
|
||||
|
||||
// ---------- WP function stubs ----------
|
||||
function esc_html($s) { return htmlspecialchars((string) $s, ENT_QUOTES); }
|
||||
function esc_attr($s) { return htmlspecialchars((string) $s, ENT_QUOTES); }
|
||||
function esc_url($s) { return (string) $s; }
|
||||
function esc_url_raw($s) { return (string) $s; }
|
||||
function esc_js($s) { return addslashes((string) $s); }
|
||||
function esc_textarea($s) { return htmlspecialchars((string) $s, ENT_QUOTES); }
|
||||
function esc_html__($s, $d = null) { return esc_html($s); }
|
||||
function esc_attr__($s, $d = null) { return esc_attr($s); }
|
||||
function esc_html_e($s, $d = null) { echo esc_html($s); }
|
||||
function __($s, $d = null) { return $s; }
|
||||
function wp_unslash($v) { return $v; }
|
||||
function sanitize_key($s) { return preg_replace('/[^a-z0-9_\-]/', '', strtolower((string) $s)); }
|
||||
function sanitize_text_field($s) { return trim(strip_tags((string) $s)); }
|
||||
function sanitize_textarea_field($s) { return trim(strip_tags((string) $s)); }
|
||||
function selected($a, $b, $echo = true) { $r = ((string) $a === (string) $b) ? " selected='selected'" : ''; if ($echo) echo $r; return $r; }
|
||||
function submit_button($text = null, $type = 'primary', $name = 'submit', $wrap = true, $other = []) { echo '<button type="submit" class="' . esc_attr($type) . '">' . esc_html($text ?: 'Submit') . '</button>'; }
|
||||
function wp_nonce_field($a = -1, $n = '_wpnonce', $r = true, $e = true) { if ($e) echo '<input type="hidden" name="' . esc_attr($n) . '" value="stubnonce" />'; }
|
||||
function wp_create_nonce($a = -1) { return 'stubnonce' . md5((string) $a); }
|
||||
function admin_url($p = '') { return 'https://example.test/wp-admin/' . $p; }
|
||||
function add_query_arg($k, $v = null, $u = null) { if (is_array($k)) { $u = $v ?: ''; $q = http_build_query($k); return $u . (str_contains($u, '?') ? '&' : '?') . $q; } return ($u ?: '') . (str_contains(($u ?: ''), '?') ? '&' : '?') . $k . '=' . $v; }
|
||||
function wp_json_encode($v, $f = 0) { return json_encode($v, $f); }
|
||||
function current_user_can($c) { return true; }
|
||||
function current_time($type = 'mysql') { return '2026-10-08 12:00:00'; }
|
||||
function is_wp_error($t) { return $t instanceof WP_Error; }
|
||||
function absint($v) { return abs((int) $v); }
|
||||
function check_admin_referer($a = -1, $n = '_wpnonce') { \HarnessLog::add('check_admin_referer', ['action' => (string) $a]); }
|
||||
function wp_safe_redirect($url) { \HarnessLog::add('wp_safe_redirect', ['url' => (string) $url]); }
|
||||
function wp_die($m) { throw new Exception('wp_die: ' . $m); }
|
||||
function get_userdata($id) { return $id > 0 ? (object) ['display_name' => 'User' . $id] : null; }
|
||||
function get_the_title($id) { return 'Product' . $id; }
|
||||
function is_admin() { return true; }
|
||||
}
|
||||
|
||||
namespace AKSRT\Ledger {
|
||||
final class AssetAmount {
|
||||
public static function balance($value): self { return new self(); }
|
||||
public function decimal(): string { return '0.00'; }
|
||||
}
|
||||
}
|
||||
|
||||
namespace AKSRT\Payment {
|
||||
class Gateway {
|
||||
private static $i;
|
||||
public static function get_instance(): self { return self::$i ?: self::$i = new self(); }
|
||||
public function get_all(): array { return ['balance' => (object) [], 'points' => (object) []]; }
|
||||
}
|
||||
|
||||
class AfterSaleService {
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_PROCESSING = 'processing';
|
||||
public const STATUS_REJECTED = 'rejected';
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
public const SOURCE_ADMIN = 'admin';
|
||||
public const SOURCE_USER = 'user';
|
||||
private static $i;
|
||||
/** @var array<int,array<string,mixed>> keyed by after-sale id */
|
||||
public static array $rows = [];
|
||||
public static function get_instance(): self { return self::$i ?: self::$i = new self(); }
|
||||
public function admin_list(array $args = []): array { return array_values(self::$rows); }
|
||||
public function list_for_order(string $order_number): array {
|
||||
return array_values(array_filter(self::$rows, static fn($row): bool => (string) ($row['order_number'] ?? '') === $order_number));
|
||||
}
|
||||
public function count(array $args = []): int { return count(self::$rows); }
|
||||
public function get(int $id): ?array { return self::$rows[$id] ?? null; }
|
||||
public function can_finalize_order_refund(int $after_sale_id, string $order_number): bool {
|
||||
$row = self::$rows[$after_sale_id] ?? null;
|
||||
$ok = is_array($row)
|
||||
&& hash_equals((string) ($row['order_number'] ?? ''), $order_number)
|
||||
&& in_array((string) ($row['status'] ?? ''), [self::STATUS_PENDING, self::STATUS_PROCESSING], true);
|
||||
\HarnessLog::add('can_finalize_order_refund', ['id' => $after_sale_id, 'order' => $order_number, 'ticket_status' => is_array($row) ? (string) ($row['status'] ?? '') : null, 'ok' => $ok]);
|
||||
return $ok;
|
||||
}
|
||||
public function find_active(string $order_number): ?array {
|
||||
$found = null;
|
||||
foreach (self::$rows as $row) {
|
||||
if ((string) ($row['order_number'] ?? '') !== $order_number) continue;
|
||||
if (!in_array((string) ($row['status'] ?? ''), [self::STATUS_PENDING, self::STATUS_PROCESSING], true)) continue;
|
||||
if ($found === null || (int) ($row['id'] ?? 0) > (int) ($found['id'] ?? 0)) $found = $row;
|
||||
}
|
||||
\HarnessLog::add('find_active', ['order' => $order_number, 'ticket' => $found === null ? null : (int) ($found['id'] ?? 0)]);
|
||||
return $found;
|
||||
}
|
||||
public function set_status(int $id, string $status, string $note = '') {
|
||||
\HarnessLog::add('set_status', ['id' => $id, 'status' => $status, 'note' => $note]);
|
||||
if (!in_array($status, [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_COMPLETED, self::STATUS_REJECTED, self::STATUS_CANCELLED], true)) {
|
||||
return new \WP_Error('aksrt_after_sale_status', '售后状态无效', ['status' => 400]);
|
||||
}
|
||||
if (!isset(self::$rows[$id])) return new \WP_Error('aksrt_after_sale_state', '售后申请不存在', ['status' => 409]);
|
||||
self::$rows[$id]['status'] = $status;
|
||||
self::$rows[$id]['handling_log'][] = ['status' => $status, 'note' => $note, 'source' => 'admin', 'created_at' => current_time('mysql')];
|
||||
return self::$rows[$id];
|
||||
}
|
||||
}
|
||||
|
||||
class OrderRepository {
|
||||
private static $i;
|
||||
/** @var array<string,array<string,mixed>> keyed by order number */
|
||||
public static array $orders = [];
|
||||
public static function get_instance(): self { return self::$i ?: self::$i = new self(); }
|
||||
public function get($n): ?array { return self::$orders[(string) $n] ?? null; }
|
||||
public function get_admin_order_context(string $order_number): array { return ['customer_note' => '买家备注示例']; }
|
||||
public function get_orders_admin(array $args = [], string $search = ''): array { return array_values(self::$orders); }
|
||||
public function get_orders_count(array $args = [], string $search = ''): int { return count(self::$orders); }
|
||||
public function update_status(string $order_number, string $new_status, array $extra = []): bool {
|
||||
\HarnessLog::add('update_status', ['order' => $order_number, 'status' => $new_status, 'extra' => $extra]);
|
||||
// Mirrors OrderRepository::update_status(): refund needs allow_refund, an active
|
||||
// after-sale id, and a valid paid -> refunded transition.
|
||||
if ($new_status !== 'refunded') return true;
|
||||
if (empty($extra['allow_refund'])) return false;
|
||||
$order = self::$orders[$order_number] ?? null;
|
||||
if (!is_array($order) || (string) ($order['status'] ?? '') !== 'paid') return false;
|
||||
self::$orders[$order_number]['status'] = $new_status;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class UserOrders {
|
||||
private static $i;
|
||||
public static function get_instance(): self { return self::$i ?: self::$i = new self(); }
|
||||
public function decorate_order_view(array $o, bool $x): array { return ['product_name' => '测试商品']; }
|
||||
}
|
||||
}
|
||||
|
||||
namespace AKSRT\Core {
|
||||
class DomainModel {
|
||||
public const ORDER_STATUS_PENDING = 'pending';
|
||||
public const ORDER_STATUS_PAID = 'paid';
|
||||
public const ORDER_STATUS_REFUNDED = 'refunded';
|
||||
public const ORDER_STATUS_CLOSED = 'closed';
|
||||
public const ORDER_PRODUCT_RESOURCE = 'resource';
|
||||
public static function resolve_order_product_type(string $t, array $m): string { return $t; }
|
||||
public const ORDER_PRODUCT_READING = 'reading';
|
||||
public const ORDER_PRODUCT_DOWNLOAD = 'download';
|
||||
public const ORDER_PRODUCT_IMAGE = 'image';
|
||||
public const ORDER_PRODUCT_VIDEO = 'video';
|
||||
public const ORDER_PRODUCT_VIP = 'vip';
|
||||
public const ORDER_PRODUCT_RECHARGE = 'recharge';
|
||||
public const ORDER_PRODUCT_POINTS = 'points';
|
||||
public static function order_product_type_labels(): array {
|
||||
return [
|
||||
self::ORDER_PRODUCT_READING => '付费阅读',
|
||||
self::ORDER_PRODUCT_DOWNLOAD => '付费下载',
|
||||
self::ORDER_PRODUCT_IMAGE => '付费图片',
|
||||
self::ORDER_PRODUCT_VIDEO => '付费视频',
|
||||
self::ORDER_PRODUCT_VIP => '购买会员',
|
||||
self::ORDER_PRODUCT_RECHARGE => '余额充值',
|
||||
self::ORDER_PRODUCT_POINTS => '购买积分',
|
||||
];
|
||||
}
|
||||
public static function order_status_labels(): array {
|
||||
return [
|
||||
self::ORDER_STATUS_PENDING => '待支付',
|
||||
self::ORDER_STATUS_PAID => '已支付',
|
||||
self::ORDER_STATUS_REFUNDED => '已退款',
|
||||
self::ORDER_STATUS_CLOSED => '已关闭',
|
||||
];
|
||||
}
|
||||
public static function order_transitions(): array {
|
||||
return [
|
||||
self::ORDER_STATUS_PENDING => [self::ORDER_STATUS_PAID, self::ORDER_STATUS_CLOSED],
|
||||
self::ORDER_STATUS_PAID => [self::ORDER_STATUS_REFUNDED],
|
||||
self::ORDER_STATUS_REFUNDED => [],
|
||||
self::ORDER_STATUS_CLOSED => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace AKSRT\Settings {
|
||||
class PaymentRuntime {
|
||||
public static function get_after_sale_settings(): array {
|
||||
return ['enabled' => true, 'time_limit' => 7, 'reasons' => [['name' => '原因A']], 'materials' => ['截图']];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
require __DIR__ . '/../../src/Admin/Native/AdminListPagination.php';
|
||||
require 'C:/Users/Xing/Desktop/AKSRT/AKSRT-theme/src/Admin/Native/PayOrders.php';
|
||||
|
||||
$scenario = $argv[1] ?? '';
|
||||
|
||||
// Seed data shared by the render pass and every handler scenario.
|
||||
\AKSRT\Payment\AfterSaleService::$rows = [
|
||||
5 => ['id' => 5, 'order_number' => 'ORD100', 'user_id' => 3, 'reason' => 'legacy-reason',
|
||||
'apply_form' => ['reason_label' => '商品与描述不符', 'materials' => [['label' => '联系方式', 'value' => 'wx-abc']], 'description' => '说明文字'],
|
||||
'images' => ['https://example.test/img1.png'], 'refund_amount' => '12.50', 'status' => 'pending',
|
||||
'handling_log' => [
|
||||
['status' => 'pending', 'note' => '', 'source' => 'user', 'created_at' => '2026-10-01 10:00:00'],
|
||||
['status' => 'processing', 'note' => '已联系仓库核实库存', 'source' => 'admin', 'created_at' => '2026-10-01 14:20:00'],
|
||||
],
|
||||
'created_at' => '2026-10-01 10:00:00'],
|
||||
6 => ['id' => 6, 'order_number' => 'ORD101', 'user_id' => 4, 'reason' => 'r2',
|
||||
'apply_form' => null, 'images' => [], 'refund_amount' => '8.00', 'status' => 'processing',
|
||||
'handling_log' => [
|
||||
['status' => 'pending', 'note' => '', 'source' => 'user', 'created_at' => '2026-10-02 11:00:00'],
|
||||
['status' => 'processing', 'note' => '资料已补齐,进入处理', 'source' => 'admin', 'created_at' => '2026-10-02 15:40:00'],
|
||||
['status' => 'rejected', 'note' => '超出七天无理由范围,已驳回', 'source' => 'admin', 'created_at' => '2026-10-03 09:00:00'],
|
||||
],
|
||||
'created_at' => '2026-10-02 11:00:00'],
|
||||
];
|
||||
\AKSRT\Payment\OrderRepository::$orders = [
|
||||
'ORD100' => ['order_number' => 'ORD100', 'user_id' => 3, 'product_id' => 9, 'product_type' => 'reading', 'meta_payload' => ['product_title' => '测试商品'], 'currency' => 'CNY', 'amount' => '12.50', 'payment_method' => 'balance', 'status' => 'paid', 'created_at' => '2026-10-01 09:00:00', 'paid_at' => '2026-10-01 09:05:00'],
|
||||
'ORD101' => ['order_number' => 'ORD101', 'user_id' => 4, 'product_id' => 10, 'product_type' => 'reading', 'meta_payload' => [], 'currency' => 'CNY', 'amount' => '8.00', 'payment_method' => 'balance', 'status' => 'paid', 'created_at' => '2026-10-02 09:00:00', 'paid_at' => '2026-10-02 09:05:00'],
|
||||
'ORD102' => ['order_number' => 'ORD102', 'user_id' => 5, 'product_id' => 11, 'product_type' => 'reading', 'meta_payload' => [], 'currency' => 'CNY', 'amount' => '20.00', 'payment_method' => 'balance', 'status' => 'refunded', 'created_at' => '2026-10-03 09:00:00', 'paid_at' => '2026-10-03 09:05:00'],
|
||||
'ORD103' => ['order_number' => 'ORD103', 'user_id' => 6, 'product_id' => 12, 'product_type' => 'reading', 'meta_payload' => [], 'currency' => 'CNY', 'amount' => '5.00', 'payment_method' => 'balance', 'status' => 'pending', 'created_at' => '2026-10-04 09:00:00', 'paid_at' => ''],
|
||||
'ORD104' => ['order_number' => 'ORD104', 'user_id' => 7, 'product_id' => 13, 'product_type' => 'reading', 'meta_payload' => [], 'currency' => 'CNY', 'amount' => '7.00', 'payment_method' => 'balance', 'status' => 'closed', 'created_at' => '2026-10-05 09:00:00', 'paid_at' => '2026-10-05 09:05:00'],
|
||||
];
|
||||
|
||||
if ($scenario === 'orders') {
|
||||
$_GET = ['page' => 'aksrt_orders'];
|
||||
$pay = AKSRT\Admin\Native\PayOrders::get_instance();
|
||||
ob_start();
|
||||
$pay->render_orders_page();
|
||||
file_put_contents(__DIR__ . '/orders-rendered.html', ob_get_clean());
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// 订单明细页:售后记录单元格按工单渲染完整处理记录。
|
||||
if ($scenario === 'order-detail') {
|
||||
$_GET = ['page' => 'aksrt_orders', 'order' => 'ORD100'];
|
||||
$pay = AKSRT\Admin\Native\PayOrders::get_instance();
|
||||
ob_start();
|
||||
$pay->render_orders_page();
|
||||
file_put_contents(__DIR__ . '/order-detail-rendered.html', ob_get_clean());
|
||||
exit(0);
|
||||
}
|
||||
|
||||
if ($scenario !== '') {
|
||||
// The handler ends in exit(), so the call log is flushed on shutdown.
|
||||
register_shutdown_function(static function (): void {
|
||||
echo json_encode(\HarnessLog::$events, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
|
||||
echo 'rows=' . json_encode(array_map(static fn($r): string => (string) ($r['status'] ?? ''), \AKSRT\Payment\AfterSaleService::$rows), JSON_UNESCAPED_UNICODE) . "\n";
|
||||
echo 'orders=' . json_encode(array_map(static fn($o): string => (string) ($o['status'] ?? ''), \AKSRT\Payment\OrderRepository::$orders), JSON_UNESCAPED_UNICODE) . "\n";
|
||||
});
|
||||
|
||||
switch ($scenario) {
|
||||
case 'unchecked':
|
||||
$_POST = ['after_sale_id' => 5, 'after_sale_status' => 'completed', 'admin_note' => '已退款'];
|
||||
break;
|
||||
case 'checked_paid':
|
||||
$_POST = ['after_sale_id' => 5, 'after_sale_status' => 'completed', 'mark_order_refunded' => '1', 'admin_note' => ''];
|
||||
break;
|
||||
case 'checked_already_refunded':
|
||||
\AKSRT\Payment\OrderRepository::$orders['ORD100']['status'] = 'refunded';
|
||||
$_POST = ['after_sale_id' => 5, 'after_sale_status' => 'completed', 'mark_order_refunded' => '1', 'admin_note' => ''];
|
||||
break;
|
||||
case 'checked_unpaid_order':
|
||||
\AKSRT\Payment\OrderRepository::$orders['ORD100']['status'] = 'pending';
|
||||
$_POST = ['after_sale_id' => 5, 'after_sale_status' => 'completed', 'mark_order_refunded' => '1', 'admin_note' => ''];
|
||||
break;
|
||||
case 'checked_closed_ticket':
|
||||
\AKSRT\Payment\AfterSaleService::$rows[5]['status'] = 'completed';
|
||||
$_POST = ['after_sale_id' => 5, 'after_sale_status' => 'cancelled', 'mark_order_refunded' => '1', 'admin_note' => ''];
|
||||
break;
|
||||
case 'bad_status':
|
||||
$_POST = ['after_sale_id' => 5, 'after_sale_status' => 'bogus', 'admin_note' => ''];
|
||||
break;
|
||||
case 'refund_paid':
|
||||
$_POST = ['order_number' => 'ORD100'];
|
||||
break;
|
||||
case 'refund_no_ticket':
|
||||
unset(\AKSRT\Payment\AfterSaleService::$rows[5]);
|
||||
$_POST = ['order_number' => 'ORD100'];
|
||||
break;
|
||||
case 'refund_inactive_ticket':
|
||||
\AKSRT\Payment\AfterSaleService::$rows[5]['status'] = 'completed';
|
||||
$_POST = ['order_number' => 'ORD100'];
|
||||
break;
|
||||
case 'refund_not_paid':
|
||||
\AKSRT\Payment\OrderRepository::$orders['ORD100']['status'] = 'pending';
|
||||
$_POST = ['order_number' => 'ORD100'];
|
||||
break;
|
||||
case 'refund_already_refunded':
|
||||
\AKSRT\Payment\OrderRepository::$orders['ORD100']['status'] = 'refunded';
|
||||
$_POST = ['order_number' => 'ORD100'];
|
||||
break;
|
||||
case 'refund_missing':
|
||||
$_POST = ['order_number' => 'ORDNOPE'];
|
||||
break;
|
||||
default:
|
||||
fwrite(STDERR, "unknown scenario: {$scenario}\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
$pay = AKSRT\Admin\Native\PayOrders::get_instance();
|
||||
if (str_starts_with($scenario, 'refund')) {
|
||||
$pay->handle_refund_order();
|
||||
} else {
|
||||
$pay->handle_after_sale_status();
|
||||
}
|
||||
// Unreachable: the handler ends in exit().
|
||||
}
|
||||
|
||||
$_GET = ['page' => 'aksrt_after_sales'];
|
||||
$pay = AKSRT\Admin\Native\PayOrders::get_instance();
|
||||
|
||||
$ref = new ReflectionMethod($pay, 'render_after_sales_page');
|
||||
ob_start();
|
||||
$ref->invoke($pay);
|
||||
$html = ob_get_clean();
|
||||
file_put_contents(__DIR__ . '/after-sales-rendered.html', $html);
|
||||
echo 'bytes=' . strlen($html) . "\n";
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* After-sale handling-log harness.
|
||||
*
|
||||
* Drives the REAL AfterSaleService against an in-memory $wpdb stand-in so the
|
||||
* handling-log contract is covered end to end: every state submission appends one
|
||||
* 「status + note + source + created_at」 entry, the log stays chronological, it is
|
||||
* capped, and malformed rows normalize instead of fataling.
|
||||
*
|
||||
* Usage: php after-sale-log-harness.php -> prints a JSON result object.
|
||||
*/
|
||||
|
||||
namespace {
|
||||
define('ARRAY_A', 'ARRAY_A');
|
||||
|
||||
final class WP_Error {
|
||||
public function __construct(private string $code, private string $message, private array $data = []) {}
|
||||
public function get_error_code(): string { return $this->code; }
|
||||
public function get_error_message(): string { return $this->message; }
|
||||
public function get_error_data(): array { return $this->data; }
|
||||
}
|
||||
|
||||
define('ABSPATH', __DIR__ . '/');
|
||||
|
||||
// ---------- WP function stubs ----------
|
||||
function __($s, $d = null) { return (string) $s; }
|
||||
function esc_html($s) { return htmlspecialchars((string) $s, ENT_QUOTES); }
|
||||
function esc_url_raw($s) { return (string) $s; }
|
||||
function sanitize_key($s) { return preg_replace('/[^a-z0-9_\-]/', '', strtolower((string) $s)) ?? ''; }
|
||||
function sanitize_text_field($s) { return trim(strip_tags((string) $s)); }
|
||||
function sanitize_textarea_field($s) { return trim(strip_tags((string) $s)); }
|
||||
function wp_json_encode($v, $f = 0) { return (string) json_encode($v, $f); }
|
||||
function current_user_can($c) { return true; }
|
||||
|
||||
// Monotonic clock: every append must land on a strictly later timestamp so the
|
||||
// chronological ordering assertion is meaningful.
|
||||
$GLOBALS['aksrt_log_now'] = strtotime('2026-10-01 10:00:00');
|
||||
function current_time($type = 'mysql', $gmt = false) {
|
||||
$GLOBALS['aksrt_log_now'] += 1;
|
||||
return gmdate('Y-m-d H:i:s', $GLOBALS['aksrt_log_now']);
|
||||
}
|
||||
|
||||
// ---------- In-memory $wpdb ----------
|
||||
final class FakeWpdb {
|
||||
public string $prefix = 'wp_';
|
||||
/** @var array<int,array<string,mixed>> keyed by after-sale id */
|
||||
public array $rows = [];
|
||||
public int $insert_id = 0;
|
||||
private int $next_id = 100;
|
||||
|
||||
public function prepare(string $sql, ...$args) {
|
||||
if (count($args) === 1 && is_array($args[0])) $args = $args[0];
|
||||
$i = 0;
|
||||
return preg_replace_callback('/%[sd]/', static function ($m) use (&$i, $args): string {
|
||||
if ($i >= count($args)) return $m[0];
|
||||
$value = $args[$i++];
|
||||
return $m[0][1] === 'd' ? (string) (int) $value : "'" . str_replace("'", "''", (string) $value) . "'";
|
||||
}, $sql) ?? $sql;
|
||||
}
|
||||
|
||||
public function get_var(string $sql) {
|
||||
if (preg_match("/GET_LOCK\('/", $sql)) return 1;
|
||||
if (preg_match("/RELEASE_LOCK\('/", $sql)) return 1;
|
||||
if (str_starts_with($sql, 'SELECT COUNT(*)')) return count($this->rows);
|
||||
throw new RuntimeException('unsupported get_var: ' . $sql);
|
||||
}
|
||||
|
||||
public function get_row(string $sql, $output = null) {
|
||||
if (preg_match("/WHERE id = (\d+)$/", $sql, $m)) return $this->rows[(int) $m[1]] ?? null;
|
||||
if (preg_match("/WHERE order_number = '([^']*)'/", $sql, $m)) {
|
||||
$statuses = preg_match("/status IN \(([^)]*)\)/", $sql, $s) ? explode(',', str_replace("'", '', $s[1])) : null;
|
||||
$found = null;
|
||||
foreach ($this->rows as $row) {
|
||||
if ((string) ($row['order_number'] ?? '') !== $m[1]) continue;
|
||||
if ($statuses !== null && !in_array((string) ($row['status'] ?? ''), array_map('trim', $statuses), true)) continue;
|
||||
if ($found === null || (int) $row['id'] > (int) $found['id']) $found = $row;
|
||||
}
|
||||
return $found;
|
||||
}
|
||||
throw new RuntimeException('unsupported get_row: ' . $sql);
|
||||
}
|
||||
|
||||
public function get_results(string $sql, $output = null) {
|
||||
if (preg_match("/WHERE order_number = '([^']*)'/", $sql, $m)) {
|
||||
$rows = array_values(array_filter($this->rows, static fn($row): bool => (string) ($row['order_number'] ?? '') === $m[1]));
|
||||
usort($rows, static fn($a, $b): int => (int) $b['id'] <=> (int) $a['id']);
|
||||
return $rows;
|
||||
}
|
||||
throw new RuntimeException('unsupported get_results: ' . $sql);
|
||||
}
|
||||
|
||||
public function insert(string $table, array $data, array $formats = []) {
|
||||
$id = $this->next_id++;
|
||||
$this->rows[$id] = $data + ['id' => $id];
|
||||
$this->insert_id = $id;
|
||||
return 1;
|
||||
}
|
||||
|
||||
public function update(string $table, array $data, array $where, array $formats = [], array $where_formats = []) {
|
||||
$id = (int) ($where['id'] ?? 0);
|
||||
if (!isset($this->rows[$id])) return false;
|
||||
$this->rows[$id] = array_merge($this->rows[$id], $data);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Domain stubs the service collaborates with ----------
|
||||
require __DIR__ . '/../../src/Ledger/AssetType.php';
|
||||
require __DIR__ . '/../../src/Ledger/AssetAmount.php';
|
||||
}
|
||||
|
||||
namespace AKSRT\Core {
|
||||
final class DBMigrator {
|
||||
public static function table(string $name): string { return 'wp_aksrt_' . $name; }
|
||||
}
|
||||
}
|
||||
|
||||
namespace AKSRT\Payment {
|
||||
final class Order {
|
||||
public const STATUS_PAID = 'paid';
|
||||
}
|
||||
|
||||
final class OrderRepository {
|
||||
private static $i;
|
||||
/** @var array<string,array<string,mixed>> keyed by order number */
|
||||
public static array $orders = [];
|
||||
public static function get_instance(): self { return self::$i ?: self::$i = new self(); }
|
||||
public function get_user_order(int $user_id, string $order_number): ?array {
|
||||
return self::$orders[$order_number] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
require __DIR__ . '/../../src/Payment/AfterSaleService.php';
|
||||
}
|
||||
|
||||
namespace {
|
||||
$GLOBALS['aksrt_wpdb'] = new FakeWpdb();
|
||||
// The service reaches for the real global $wpdb, so the stand-in has to live there.
|
||||
$GLOBALS['wpdb'] = $GLOBALS['aksrt_wpdb'];
|
||||
|
||||
/** @return list<string> */
|
||||
function run(): array {
|
||||
$wpdb = $GLOBALS['aksrt_wpdb'];
|
||||
$service = \AKSRT\Payment\AfterSaleService::get_instance();
|
||||
$order = ['order_number' => '20261001100000000001', 'user_id' => 7, 'status' => 'paid', 'currency' => 'CNY', 'amount' => '12.50'];
|
||||
\AKSRT\Payment\OrderRepository::$orders = [$order['order_number'] => $order];
|
||||
|
||||
// 1) create() seeds the log with the user's own submission.
|
||||
$wpdb->rows = [];
|
||||
$created = $service->create(7, $order['order_number'], '商品与描述不符');
|
||||
if ($created instanceof \WP_Error) return ['error' => 'create failed: ' . $created->get_error_message()];
|
||||
$log = $created['handling_log'];
|
||||
$results = [];
|
||||
$results[] = ['name' => 'create_seeds_single_user_entry', 'ok' => count($log) === 1
|
||||
&& $log[0]['status'] === 'pending'
|
||||
&& $log[0]['source'] === 'user'
|
||||
&& $log[0]['note'] === ''
|
||||
&& $log[0]['created_at'] === $created['created_at'],
|
||||
'detail' => $log];
|
||||
|
||||
// 2) Every admin submission appends, even with an empty note; order is chronological.
|
||||
$service->set_status((int) $created['id'], 'processing', '已联系仓库核实');
|
||||
$service->set_status((int) $created['id'], 'completed', '已原路退款');
|
||||
$service->set_status((int) $created['id'], 'rejected', '');
|
||||
$log = $service->get((int) $created['id'])['handling_log'];
|
||||
$results[] = ['name' => 'status_changes_append_even_without_note', 'ok' => count($log) === 4
|
||||
&& $log[1] === ['status' => 'processing', 'note' => '已联系仓库核实', 'source' => 'admin', 'created_at' => $log[1]['created_at']]
|
||||
&& $log[2]['status'] === 'completed' && $log[2]['note'] === '已原路退款'
|
||||
&& $log[3]['status'] === 'rejected' && $log[3]['note'] === ''
|
||||
&& $log[0]['created_at'] < $log[1]['created_at']
|
||||
&& $log[1]['created_at'] < $log[2]['created_at']
|
||||
&& $log[2]['created_at'] < $log[3]['created_at'],
|
||||
'detail' => $log];
|
||||
|
||||
// 3) A user-side cancel is logged as a user entry.
|
||||
$wpdb->rows = [];
|
||||
$second = $service->create(7, $order['order_number'], '拍错了');
|
||||
$service->cancel_for_user(7, $order['order_number']);
|
||||
$log = $service->get((int) $second['id'])['handling_log'];
|
||||
$last = end($log);
|
||||
$results[] = ['name' => 'cancel_appends_user_entry', 'ok' => count($log) === 2
|
||||
&& $last['status'] === 'cancelled' && $last['source'] === 'user' && $last['note'] === '',
|
||||
'detail' => $log];
|
||||
|
||||
// 4) The log is capped, keeping the most recent entries.
|
||||
$wpdb->rows = [];
|
||||
$seeded = [];
|
||||
for ($i = 1; $i <= 50; $i++) {
|
||||
$seeded[] = ['status' => 'processing', 'note' => 'seed-' . $i, 'source' => 'admin', 'created_at' => sprintf('2026-10-01 10:%02d:00', $i)];
|
||||
}
|
||||
$wpdb->rows[900] = ['id' => 900, 'order_number' => $order['order_number'], 'user_id' => 7, 'status' => 'processing', 'handling_log' => wp_json_encode($seeded), 'created_at' => '2026-10-01 10:01:00'];
|
||||
$service->set_status(900, 'completed', '第五十条');
|
||||
$log = $service->get(900)['handling_log'];
|
||||
$results[] = ['name' => 'log_is_capped_at_50', 'ok' => count($log) === 50
|
||||
&& $log[0]['note'] === 'seed-2'
|
||||
&& end($log)['note'] === '第五十条',
|
||||
'detail' => ['count' => count($log), 'first' => $log[0]['note'], 'last' => end($log)['note']]];
|
||||
|
||||
// 5) Malformed rows normalize instead of fataling.
|
||||
$wpdb->rows[901] = ['id' => 901, 'order_number' => $order['order_number'], 'user_id' => 7, 'status' => 'processing',
|
||||
'handling_log' => wp_json_encode([
|
||||
['status' => 'pending', 'note' => 'ok', 'source' => 'user', 'created_at' => '2026-10-01 10:00:00'],
|
||||
['status' => '', 'note' => 'no status', 'source' => 'admin', 'created_at' => '2026-10-01 10:05:00'],
|
||||
['note' => 'not an array value', 'source' => 'admin', 'created_at' => '2026-10-01 10:06:00'],
|
||||
'garbage',
|
||||
['status' => 'completed', 'source' => 'mystery', 'created_at' => '2026-10-01 10:07:00'],
|
||||
])];
|
||||
$wpdb->rows[902] = ['id' => 902, 'order_number' => $order['order_number'], 'user_id' => 7, 'status' => 'pending', 'handling_log' => 'not-json'];
|
||||
$normalized = $service->get(901)['handling_log'];
|
||||
$broken = $service->get(902)['handling_log'];
|
||||
$results[] = ['name' => 'normalize_drops_malformed_entries', 'ok' => count($normalized) === 2
|
||||
&& $normalized[0]['status'] === 'pending'
|
||||
&& $normalized[1] === ['status' => 'completed', 'note' => '', 'source' => 'admin', 'created_at' => '2026-10-01 10:07:00']
|
||||
&& $broken === [],
|
||||
'detail' => ['normalized' => $normalized, 'broken_json' => $broken]];
|
||||
|
||||
// 6) 再次申请的闸门只看「进行中」的工单:completed / rejected / cancelled 都放行,
|
||||
// 订单有没有另标已退款不影响这条(订单已退款时 create() 的 STATUS_PAID 校验先挡)。
|
||||
$gate = static function (string $status) use ($service, $wpdb, $order) {
|
||||
$wpdb->rows = [];
|
||||
$wpdb->rows[950] = ['id' => 950, 'order_number' => $order['order_number'], 'user_id' => 7, 'status' => $status,
|
||||
'reason' => '旧申请', 'handling_log' => wp_json_encode([['status' => $status, 'note' => '', 'source' => 'admin', 'created_at' => '2026-10-01 10:00:00']]),
|
||||
'created_at' => '2026-10-01 10:00:00'];
|
||||
return $service->create(7, $order['order_number'], '新的申请原因');
|
||||
};
|
||||
$completed = $gate('completed');
|
||||
$rejected = $gate('rejected');
|
||||
$cancelled = $gate('cancelled');
|
||||
$results[] = ['name' => 'settled_ticket_still_allows_reapply', 'ok' => !$completed instanceof \WP_Error
|
||||
&& !$rejected instanceof \WP_Error
|
||||
&& !$cancelled instanceof \WP_Error,
|
||||
'detail' => [
|
||||
'completed' => $completed instanceof \WP_Error ? $completed->get_error_code() : 'created',
|
||||
'rejected' => $rejected instanceof \WP_Error ? $rejected->get_error_code() : 'created',
|
||||
'cancelled' => $cancelled instanceof \WP_Error ? $cancelled->get_error_code() : 'created',
|
||||
]];
|
||||
|
||||
// 待审核:同人同因幂等返回原工单,换原因则挡住
|
||||
$wpdb->rows = [];
|
||||
$wpdb->rows[951] = ['id' => 951, 'order_number' => $order['order_number'], 'user_id' => 7, 'status' => 'pending',
|
||||
'reason' => '同一个原因', 'handling_log' => '[]', 'created_at' => '2026-10-01 10:00:00'];
|
||||
$same = $service->create(7, $order['order_number'], '同一个原因');
|
||||
$other = $service->create(7, $order['order_number'], '换了个原因');
|
||||
$results[] = ['name' => 'open_ticket_blocks_duplicate_apply', 'ok' => is_array($same)
|
||||
&& (int) $same['id'] === 951
|
||||
&& $other instanceof \WP_Error
|
||||
&& $other->get_error_code() === 'aksrt_after_sale_exists',
|
||||
'detail' => ['same' => is_array($same) ? (int) $same['id'] : 'error', 'other' => $other instanceof \WP_Error ? $other->get_error_code() : 'created']];
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
$results = run();
|
||||
$failed = array_values(array_filter($results, static fn($r): bool => empty($r['ok'])));
|
||||
echo json_encode(['passed' => count($results) - count($failed), 'failed' => count($failed), 'results' => $results], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) . "\n";
|
||||
exit($failed ? 1 : 0);
|
||||
}
|
||||
@@ -1,574 +0,0 @@
|
||||
'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: '<div>抱歉,您的订单已超过售后申请时效</div>',
|
||||
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, '<div>已超过时效</div>')
|
||||
assert.equal(payload.overridden.desc, '<div>请保持商品完好</div>')
|
||||
|
||||
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,/)
|
||||
// can_apply 只问「有没有进行中的工单」:completed 不挡,能否再售后由订单是否已退款决定
|
||||
assert.match(userOrders, /'can_apply'\s*=> \$enabled && !\$expired && !\$open,/)
|
||||
// 可再次申请售后按工单状态推理:rejected / cancelled / completed 都允许再次申请;
|
||||
// pending / processing / approved(流转中)不允许。订单已退款时 enabled 直接为 false,
|
||||
// 上面那条自然挡死,不需要工单状态再判一次。
|
||||
assert.match(userOrders, /\$reapplyable_statuses = \[\s*AfterSaleService::STATUS_REJECTED,\s*AfterSaleService::STATUS_CANCELLED,\s*AfterSaleService::STATUS_COMPLETED,\s*\];/)
|
||||
assert.ok(!userOrders.includes('$reapplyable ='), 'can_apply must not re-check the ticket status')
|
||||
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,/)
|
||||
// 处理记录取代 reviewed_at / completed_at / admin_note 三个单点字段
|
||||
assert.match(userOrders, /'handling_log'\s*=> array_values\(\(array\) \(\$latest\['handling_log'\] \?\? \[\]\)\),/)
|
||||
|
||||
// 仅已支付订单开放;开关关闭整体不处理售后
|
||||
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, /className: 'aksrt-after-sale-withdraw-modal'/)
|
||||
assert.match(js, /modal-colorful-header colorful-bg jb-blue/)
|
||||
assert.match(js, /AksrtModalRuntime\.open\(\{/)
|
||||
|
||||
// 滚动容器与禁封申诉弹窗同口径:no-scrollbar 完全隐藏滚动条(含细滚动条),
|
||||
// 避免 modal-mini 弹窗边缘出现可见滚动栏。
|
||||
assert.match(js, /'<div class="scroll-y no-scrollbar max-vh5">'/)
|
||||
assert.match(js, /'<div class="scroll-y no-scrollbar max-vh5 aksrt-after-sale-detail__body">'/)
|
||||
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('>关闭</button>'), 'detail modal must not render a 关闭 button')
|
||||
assert.match(js, /data-aksrt-modal-close>我知道了</)
|
||||
assert.match(js, /data-aksrt-after-sale-reapply data-aksrt-after-sale-payload="/)
|
||||
// 再次申请售后:can_reapply 必然伴随 has_application=true,入口点击须清除标记,
|
||||
// 否则 openApplyFlow 的 has_application 分流会把用户弹回「售后详细」打转
|
||||
assert.match(js, /payload\.after_sale\.has_application = false[\s\S]{0,120}openApplyFlow\(payload\)/)
|
||||
|
||||
// footer 平铺按钮:相邻按钮分界线(legacy .but + .but 分隔在家族内还原,四个售后弹窗同款)
|
||||
// + 悬停反馈(参考 Element 卡片 footer:整块区域浅灰底 + 文字转主色,主按钮 hover 略加深)
|
||||
const modalCss = read('assets/css/frontend/modal-runtime.css')
|
||||
assert.match(modalCss, /-apply-modal \.but-average\.modal-buts,\s*\n\s*\.aksrt-after-sale-detail-modal \.but-average\.modal-buts,\s*\n\s*\.aksrt-after-sale-expired-modal \.but-average\.modal-buts,\s*\n\s*\.aksrt-after-sale-withdraw-modal \.but-average\.modal-buts\s*\{[^}]*padding:\s*0;/)
|
||||
// footer 不留垂直内边距:上下 12px 并入按钮(min-height 62 = 12+38+12),按钮盒=整个底部区域
|
||||
assert.match(modalCss, /-apply-modal \.but-average\.modal-buts > \.but,[\s\S]{0,260}min-height:\s*62px/)
|
||||
assert.match(modalCss, /-apply-modal \.but-average\.modal-buts > \.but \+ \.but,\s*\n\s*\.aksrt-after-sale-detail-modal \.but-average\.modal-buts > \.but \+ \.but,\s*\n\s*\.aksrt-after-sale-expired-modal \.but-average\.modal-buts > \.but \+ \.but,\s*\n\s*\.aksrt-after-sale-withdraw-modal \.but-average\.modal-buts > \.but \+ \.but\s*\{[^}]*border-left:\s*1px solid var\(--modal-line-soft/)
|
||||
assert.match(modalCss, /-apply-modal \.but-average\.modal-buts > \.but:hover,\s*\n\s*\.aksrt-after-sale-detail-modal \.but-average\.modal-buts > \.but:hover,\s*\n\s*\.aksrt-after-sale-expired-modal \.but-average\.modal-buts > \.but:hover,\s*\n\s*\.aksrt-after-sale-withdraw-modal \.but-average\.modal-buts > \.but:hover\s*\{[^}]*background:\s*color-mix\(in srgb, var\(--modal-ink/)
|
||||
assert.match(modalCss, /-apply-modal \.but-average\.modal-buts > \.but\.c-blue:hover,\s*\n\s*\.aksrt-after-sale-detail-modal \.but-average\.modal-buts > \.but\.c-blue:hover,\s*\n\s*\.aksrt-after-sale-expired-modal \.but-average\.modal-buts > \.but\.c-blue:hover,\s*\n\s*\.aksrt-after-sale-withdraw-modal \.but-average\.modal-buts > \.but\.c-blue:hover\s*\{[^}]*color:\s*color-mix/)
|
||||
|
||||
// 撤销售后:入口仅随待审核状态渲染,点击先过二次确认弹窗,确认后 DELETE 工单
|
||||
assert.match(js, /status === 'pending'[\s\S]*?data-aksrt-after-sale-withdraw="' \+ escapeHtml\(\(order && order\.order_number\) \|\| ''\)/)
|
||||
assert.match(js, /buildWithdrawHtml\(orderNumber\)/)
|
||||
assert.match(js, /className: 'aksrt-after-sale-withdraw-modal'/)
|
||||
assert.match(js, /data-aksrt-after-sale-withdraw-confirm="' \+ escapeHtml\(orderNumber\) \+ '"/)
|
||||
assert.match(js, /method: 'DELETE'/)
|
||||
assert.match(js, /AksrtModalRuntime\.close\('withdraw'\)/)
|
||||
assert.match(js, /notyf\('售后申请已撤销', 'success'\)/)
|
||||
|
||||
// 人机验证:提交与撤销前先过验证(场景未启用时挂载点缺失,run 直接放行)
|
||||
assert.match(js, /function runCaptcha\(scene\)/)
|
||||
assert.match(js, /runCaptcha\('after_sale_apply'\)/)
|
||||
assert.match(js, /runCaptcha\('after_sale_cancel'\)/)
|
||||
assert.match(js, /AKSRTCaptcha\.run\(scene\)/)
|
||||
|
||||
// 提交后关闭弹窗并刷新订单列表
|
||||
assert.match(js, /AKSRTOrderCenter\.refresh\(orderNumber\)/)
|
||||
assert.match(js, /window\.AksrtModalRuntime\.close\('submit'\)/)
|
||||
|
||||
// 入口委托:申请 / 售后详细
|
||||
assert.match(js, /data-aksrt-after-sale-apply/)
|
||||
assert.match(js, /data-aksrt-after-sale-detail/)
|
||||
assert.match(js, /auto_fun\.aksrt-after-sale/)
|
||||
})
|
||||
|
||||
test('order center drops the embedded after-sale form and exposes refresh()', () => {
|
||||
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\) \+ '"/)
|
||||
// 底部「关闭」与售后详细「我知道了」同款(but c-blue,平铺观感由 orders.css 呈现)
|
||||
assert.match(js, /class="but c-blue" data-aksrt-modal-close>关闭</)
|
||||
assert.ok(js.includes('查看详细</button>'), '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="/)
|
||||
|
||||
// 订单详情弹窗 CSS:记录卡片「查看详细」foot 右对齐;底部按钮与售后弹窗家族
|
||||
// 同款平铺观感(modal-runtime 同款规则镜像到吸底 footer),单按钮占满整行。
|
||||
const ordersCss = read('assets/css/routes/orders.css')
|
||||
assert.match(ordersCss, /\.aksrt-order-detail__after-sale-foot\s*\{[^}]*justify-content:\s*flex-end/)
|
||||
assert.match(ordersCss, /\.aksrt-order-detail-modal \.aksrt-order-detail__actions\.but-average\.modal-buts > \.but\s*\{[^}]*background:\s*transparent/)
|
||||
assert.match(ordersCss, /\.aksrt-order-detail-modal \.aksrt-order-detail__actions\.but-average\.modal-buts > \.but\.c-blue\s*\{[^}]*color:\s*var\(--modal-primary/)
|
||||
assert.match(ordersCss, /\.aksrt-order-detail-modal \.aksrt-order-detail__actions\.but-average\.modal-buts > \.but:only-child\s*\{[^}]*grid-column:\s*1 \/ -1/)
|
||||
// 分界线与悬停阴影与售后弹窗家族同款(分隔色用本弹窗 footer 同源的 --detail-line);
|
||||
// footer 零垂直内边距、按钮满高(min-height 64 = 13+38+13),按钮盒=整个底部区域,安全区留 footer 底部
|
||||
assert.match(ordersCss, /\.aksrt-order-detail-modal \.aksrt-order-detail__actions\s*\{[^}]*padding:\s*0 0 env\(safe-area-inset-bottom/)
|
||||
assert.match(ordersCss, /\.aksrt-order-detail-modal \.aksrt-order-detail__actions\.but-average\.modal-buts > \.but\s*\{[^}]*min-height:\s*64px/)
|
||||
assert.match(ordersCss, /\.aksrt-order-detail-modal \.aksrt-order-detail__actions\.but-average\.modal-buts > \.but \+ \.but\s*\{[^}]*border-left:\s*1px solid var\(--detail-line\)/)
|
||||
assert.match(ordersCss, /\.aksrt-order-detail-modal \.aksrt-order-detail__actions\.but-average\.modal-buts > \.but:hover\s*\{[^}]*background:\s*color-mix\(in srgb, var\(--modal-ink/)
|
||||
assert.match(ordersCss, /\.aksrt-order-detail-modal \.aksrt-order-detail__actions\.but-average\.modal-buts > \.but\.c-blue:hover\s*\{[^}]*color:\s*color-mix/)
|
||||
|
||||
// 售后记录区保留在订单详情弹窗:记录卡片带申请时间,「查看详细」逐条入口;
|
||||
// 取消申请按钮与处理器已移除(撤销入口移入售后详细弹窗)。
|
||||
assert.match(js, /售后记录/)
|
||||
assert.match(js, /<time>" \+ escapeHtml\(item\.created_at\)/)
|
||||
assert.ok(!js.includes('data-aksrt-after-sale-cancel'), 'cancel entry moved into the after-sale detail modal')
|
||||
assert.ok(!js.includes('取消申请'), 'order detail must not render the legacy cancel button')
|
||||
|
||||
// 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')
|
||||
|
||||
// 售后人机验证挂载点(申请 / 撤销):场景未启用时 aksrt_render_captcha 无输出
|
||||
assert.match(template, /function_exists\('aksrt_render_captcha'\)/)
|
||||
assert.match(template, /aksrt_render_captcha\('after_sale_apply'\)/)
|
||||
assert.match(template, /aksrt_render_captcha\('after_sale_cancel'\)/)
|
||||
|
||||
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, /\.aksrt-after-sale-withdraw-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/)
|
||||
|
||||
// 弹窗底部按钮:四个售后弹窗统一全宽平铺贴底,子按钮去独立胶囊观感(覆盖全局按钮网格)
|
||||
for (const modal of ['apply', 'detail', 'expired', 'withdraw']) {
|
||||
assert.match(css, new RegExp(`\\.aksrt-after-sale-${modal}-modal \\.but-average\\.modal-buts[\\s\\S]*?margin: 16px -18px -16px`))
|
||||
assert.match(css, new RegExp(`\\.aksrt-after-sale-${modal}-modal \\.but-average\\.modal-buts > \\.but[\\s\\S]*?border-radius: 0`))
|
||||
}
|
||||
assert.match(css, /\.but:only-child \{ grid-column: 1 \/ -1; \}/)
|
||||
// 撤销售后入口:待审核状态行内的文字式入口按钮
|
||||
assert.match(css, /\.aksrt-after-sale-detail__statusCell/)
|
||||
assert.match(css, /\.aksrt-after-sale-withdraw \{[^}]*background: transparent/)
|
||||
|
||||
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\)/)
|
||||
})
|
||||
|
||||
test('every after-sale submission is logged with a status, note, source and time', () => {
|
||||
const service = read('src/Payment/AfterSaleService.php')
|
||||
|
||||
// 记录落在工单行内,按时间正序追加,超过上限只保留最近的一段
|
||||
assert.match(service, /private const MAX_LOG_ENTRIES = 50;/)
|
||||
assert.match(service, /private function append_log\(int \$id, array \$current, string \$status, string \$note, string \$source\): void \{/)
|
||||
assert.match(service, /array_slice\(\$log, -self::MAX_LOG_ENTRIES\)/)
|
||||
assert.match(service, /JSON_UNESCAPED_UNICODE \| JSON_UNESCAPED_SLASHES/)
|
||||
assert.match(service, /'handling_log' => \$this->encode_log\(\[\$this->log_entry\(self::STATUS_PENDING, '', self::SOURCE_USER, \$now\)\]\),/)
|
||||
assert.match(service, /public const SOURCE_ADMIN = 'admin';/)
|
||||
assert.match(service, /public const SOURCE_USER = 'user';/)
|
||||
|
||||
// 三条写入路径都必须留痕:用户申请 / 用户撤销 / 后台改状态
|
||||
assert.match(service, /create\(int \$user_id[\s\S]*?'handling_log'/)
|
||||
assert.match(service, /cancel_for_user\([\s\S]*?append_log\(\(int\) \$row\['id'\], \$row, self::STATUS_CANCELLED, '', self::SOURCE_USER\)/)
|
||||
assert.match(service, /set_status\([\s\S]*?append_log\(\$id, \$row, \$status, \$note, self::SOURCE_ADMIN\)/)
|
||||
|
||||
// set_status 现在是「读-改-写」JSON,必须与 create/cancel 共用订单锁
|
||||
assert.match(service, /set_status\(int \$id, string \$status, string \$note = ''\)[\s\S]*?GET_LOCK\(%s, 5\)[\s\S]*?RELEASE_LOCK\(%s\)/)
|
||||
|
||||
// 单点备注 / 单点时间戳 / 从未写入的渠道字段全部移除
|
||||
for (const dead of ['admin_note', 'reviewed_at', 'completed_at', 'gateway_result', 'gateway_request_id', 'gateway_refund_id']) {
|
||||
assert.ok(!service.includes(dead), `${dead} must be removed from AfterSaleService`)
|
||||
}
|
||||
|
||||
// 规范化:畸形条目丢弃,未知 source 归 admin,非数组整体视为空
|
||||
assert.match(service, /private function normalize_log\(\$log\): array \{/)
|
||||
assert.match(service, /\$row\['handling_log'\] = \$this->normalize_log\(json_decode\(\(string\) \(\$row\['handling_log'\] \?\? ''\), true\)\);/)
|
||||
|
||||
// 退款金额工具在 create() 里被调用,不能再次缺失(88669f69 曾误删导致 fatal)
|
||||
assert.match(service, /private function refund_amount\(array \$order\): \?string \{/)
|
||||
})
|
||||
|
||||
test('handling-log behaviour is covered against the real service', () => {
|
||||
const result = spawnSync(process.env.PHP_BINARY || 'php', [path.join(__dirname, 'after-sale-log-harness.php')], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout)
|
||||
const payload = JSON.parse(result.stdout)
|
||||
assert.equal(payload.failed, 0, JSON.stringify(payload.results))
|
||||
const names = payload.results.map((entry) => entry.name)
|
||||
for (const name of [
|
||||
'create_seeds_single_user_entry',
|
||||
'status_changes_append_even_without_note',
|
||||
'cancel_appends_user_entry',
|
||||
'log_is_capped_at_50',
|
||||
'normalize_drops_malformed_entries',
|
||||
'settled_ticket_still_allows_reapply',
|
||||
'open_ticket_blocks_duplicate_apply',
|
||||
]) {
|
||||
assert.ok(names.includes(name), `missing handling-log scenario: ${name}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('frontend renders the handling log as a rope-knot timeline', () => {
|
||||
const js = read('assets/js/order-after-sale.js')
|
||||
const center = read('assets/js/order-center.js')
|
||||
|
||||
// 售后详细弹窗:处理记录时间线取代单行的「处理备注 / 完成时间」
|
||||
assert.ok(!js.includes("detailRow('处理备注'"), 'single admin-note row must be removed')
|
||||
assert.ok(!js.includes("detailRow('完成时间'"), 'single completed-at row must be removed')
|
||||
assert.match(js, /function handlingEntryTitle\(entry\)/)
|
||||
assert.match(js, /function handlingLogHtml\(log\)/)
|
||||
assert.match(js, /handlingLogHtml\(latest\.handling_log\)/)
|
||||
assert.match(js, /status === 'cancelled' \? '用户撤销售后' : '用户提交申请'/)
|
||||
assert.match(js, /'<ol class="aksrt-after-sale-timeline">'/)
|
||||
assert.match(js, /aksrt-after-sale-timeline__knot/)
|
||||
assert.match(js, /aksrt-after-sale-timeline__status/)
|
||||
assert.match(js, /aksrt-after-sale-timeline__time/)
|
||||
assert.match(js, /aksrt-after-sale-timeline__note/)
|
||||
// [节点][处理状态][时间] 同一行:节点独占一列,状态与时间包在 head 里
|
||||
assert.match(js, /'<span class="aksrt-after-sale-timeline__knot"><\/span>'/)
|
||||
assert.match(js, /'<div class="aksrt-after-sale-timeline__head">' \+/)
|
||||
assert.match(js, /'<div class="aksrt-after-sale-timeline__body">';/)
|
||||
// 退款金额不展示:并非每次售后都真的退款,工单被标完成也不代表把钱退回去了
|
||||
assert.ok(!js.includes("detailRow('退款金额'"), 'after-sale detail modal must not show 退款金额')
|
||||
assert.ok(!js.includes('aftersaleAmountText'), 'dead aftersaleAmountText must be removed')
|
||||
assert.match(js, /detailRow\('申请时间', String\(latest\.created_at \|\| ''\)\)/)
|
||||
|
||||
// 订单详情弹窗:不再渲染售后状态节点进程与退款金额,只保留状态徽章 + 原因 + 「查看详细」;
|
||||
// 完整处理记录由点开的售后详细弹窗呈现,载荷仍带上 handling_log
|
||||
assert.ok(!center.includes('gatewayResultText'), 'dead gateway-result renderer must be removed')
|
||||
assert.ok(!center.includes('<dt>渠道结果</dt>'), 'dead 渠道结果 row must be removed')
|
||||
assert.ok(!center.includes('<dt>处理备注</dt>'), 'single 处理备注 row must be removed')
|
||||
assert.ok(!center.includes('<dt>退款金额</dt>'), 'order detail card must not show 退款金额')
|
||||
assert.ok(!center.includes('afterSaleAmount'), 'dead afterSaleAmount must be removed')
|
||||
assert.ok(!center.includes('aksrt-order-detail__after-sale-meta'), 'empty meta dl must be removed')
|
||||
assert.ok(!center.includes('orderDetailHandlingLogHtml'), 'order detail modal must not render the timeline')
|
||||
assert.ok(!center.includes('AFTER_SALE_STATUS_LABELS'), 'order detail modal must not carry timeline labels')
|
||||
assert.ok(!center.includes('handlingEntryTitle'), 'order detail modal must not carry the timeline title helper')
|
||||
assert.ok(!center.includes('aksrt-after-sale-timeline'), 'order detail modal must not emit timeline markup')
|
||||
assert.match(center, /handling_log: Array\.isArray\(item\.handling_log\) \? item\.handling_log : \[\],/)
|
||||
// 「查看详细」入口保留:处理记录从这里点开
|
||||
assert.match(center, /data-aksrt-after-sale-detail="' \+ recordPayload \+ '"/)
|
||||
|
||||
// 时间线样式:竖线穿节点、末项不收口,左列固定宽度、右列自适应
|
||||
const css = read('assets/css/frontend/modal-runtime.css')
|
||||
assert.match(css, /\.aksrt-after-sale-timeline__item \{ --as-accent: var\(--modal-muted\); position: relative; display: grid; grid-template-columns: 10px minmax\(0, 1fr\); gap: 10px; padding: 0 0 14px; \}/)
|
||||
assert.match(css, /\.aksrt-after-sale-timeline__item::before \{ content: ""; position: absolute; top: 14px; bottom: -2px; left: 4px/)
|
||||
assert.match(css, /\.aksrt-after-sale-timeline__item:last-child::before \{ display: none; \}/)
|
||||
assert.match(css, /\.aksrt-after-sale-timeline__head \{ display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; \}/)
|
||||
assert.match(css, /\.aksrt-after-sale-timeline__knot/)
|
||||
assert.match(css, /\.aksrt-after-sale-timeline__note/)
|
||||
const ordersCss = read('assets/css/routes/orders.css')
|
||||
assert.ok(!ordersCss.includes('aksrt-order-detail__after-sale-meta'), 'dead after-sale-meta rules must be removed')
|
||||
// 旧的两列结构(aside / main)已移除
|
||||
assert.ok(!css.includes('aksrt-after-sale-timeline__aside'), 'left-column aside must be removed')
|
||||
assert.ok(!css.includes('aksrt-after-sale-timeline__main'), 'right-column main must be removed')
|
||||
|
||||
// 打包产物同步:public.css / public.min.css 都含时间线规则
|
||||
assert.ok(read('assets/css/build/public.css').includes('.aksrt-after-sale-timeline__knot'))
|
||||
assert.ok(read('assets/css/build/public.min.css').includes('.aksrt-after-sale-timeline__knot'))
|
||||
})
|
||||
@@ -1,117 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* After-sale policy runtime harness.
|
||||
*
|
||||
* Covers the 「支付&付费 → 售后策略」 runtime resolution: schema defaults when the
|
||||
* section was never saved, stored overrides, HTML sanitisation, preset-reason
|
||||
* slugs, and the after_sale_expired boundary matrix.
|
||||
*/
|
||||
|
||||
namespace {
|
||||
define('ABSPATH', __DIR__);
|
||||
define('DAY_IN_SECONDS', 86400);
|
||||
|
||||
$GLOBALS['aksrt_test_options'] = [];
|
||||
$GLOBALS['aksrt_test_now'] = 0;
|
||||
|
||||
function sanitize_key($value): string {
|
||||
return preg_replace('/[^a-z0-9_\-]/', '', strtolower((string) $value)) ?? '';
|
||||
}
|
||||
|
||||
function sanitize_text_field($value): string {
|
||||
return trim(preg_replace('/[\r\n\t]+/', ' ', strip_tags((string) $value)) ?? '');
|
||||
}
|
||||
|
||||
function sanitize_textarea_field($value): string {
|
||||
return trim(strip_tags((string) $value));
|
||||
}
|
||||
|
||||
function wp_kses_post($value): string {
|
||||
$value = preg_replace('#<script\b[^>]*>.*?</script>#is', '', (string) $value);
|
||||
return strip_tags($value, '<a><b><strong><em><i><span><div><p><br><ul><ol><li>');
|
||||
}
|
||||
|
||||
function esc_url_raw($value): string {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
function wp_json_encode($value): string {
|
||||
return (string) json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
function get_option(string $name, $default = false) {
|
||||
return $GLOBALS['aksrt_test_options'][$name] ?? $default;
|
||||
}
|
||||
|
||||
function update_option(string $name, $value): bool {
|
||||
$GLOBALS['aksrt_test_options'][$name] = $value;
|
||||
return true;
|
||||
}
|
||||
|
||||
function current_time(string $type, $gmt = false) {
|
||||
return $GLOBALS['aksrt_test_now'];
|
||||
}
|
||||
}
|
||||
|
||||
namespace AKSRT\Settings {
|
||||
class Settings {
|
||||
public static function get_all(): array {
|
||||
$stored = get_option('aksrt_settings', []);
|
||||
return is_array($stored) ? $stored : [];
|
||||
}
|
||||
|
||||
public static function read_domain(string $domain, string $section = ''): array {
|
||||
return \AKSRT\Admin\SchemaProvider::get_instance()->read_settings($domain, $section);
|
||||
}
|
||||
|
||||
public static function flush_settings_cache(): void {}
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
require_once dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaPayment.php';
|
||||
require_once dirname(__DIR__, 2) . '/src/Admin/SchemaProvider.php';
|
||||
require_once dirname(__DIR__, 2) . '/src/Settings/PaymentRuntime.php';
|
||||
|
||||
$GLOBALS['aksrt_test_now'] = strtotime('2026-03-01 12:00:00');
|
||||
|
||||
// 1) Section never saved: the runtime still resolves schema defaults.
|
||||
$defaults = \AKSRT\Settings\PaymentRuntime::get_after_sale_settings();
|
||||
|
||||
// 2) Stored values override defaults; HTML fields are kses'ed.
|
||||
update_option('aksrt_settings', [
|
||||
'after_sale_s' => false,
|
||||
'after_sale_time_limit' => 30,
|
||||
'after_sale_expired_text' => '<div>已超过时效</div><script>alert(1)</script>',
|
||||
'after_sale_reasons' => [['t' => '质量问题'], ['t' => '发错货']],
|
||||
'after_sale_desc' => '<div>请保持商品完好</div><script>alert(2)</script>',
|
||||
'after_sale_materials' => [['t' => '联系邮箱']],
|
||||
'after_sale_reason_placeholder' => '请描述问题',
|
||||
'after_sale_img_s' => false,
|
||||
]);
|
||||
$overridden = \AKSRT\Settings\PaymentRuntime::get_after_sale_settings();
|
||||
|
||||
// 3) Expiry boundary matrix: measured from paid_at, strictly beyond the limit.
|
||||
$matrix = [];
|
||||
$boundary_cases = [
|
||||
'within' => ['2026-02-25 00:00:00', 7, false],
|
||||
'exact_boundary' => ['2026-02-22 12:00:00', 7, false],
|
||||
'just_expired' => ['2026-02-22 11:59:59', 7, true],
|
||||
'long_expired' => ['2026-01-01 00:00:00', 7, true],
|
||||
'no_limit' => ['2020-01-01 00:00:00', 0, false],
|
||||
'missing_paid' => ['', 7, false],
|
||||
];
|
||||
$stored = get_option('aksrt_settings', []);
|
||||
foreach ($boundary_cases as $name => $case) {
|
||||
[$paid_at, $limit, $expected] = $case;
|
||||
$stored['after_sale_time_limit'] = $limit;
|
||||
update_option('aksrt_settings', $stored);
|
||||
$matrix[$name] = \AKSRT\Settings\PaymentRuntime::after_sale_expired($paid_at) === $expected;
|
||||
}
|
||||
|
||||
echo json_encode([
|
||||
'defaults' => $defaults,
|
||||
'overridden' => $overridden,
|
||||
'expired_ok' => $matrix,
|
||||
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
@@ -62,27 +62,29 @@ test('legacy public SMS-code AJAX surface is removed while SMS service remains',
|
||||
assert.match(sms, /public function send_code\(string \$phone, string \$code\): array/);
|
||||
});
|
||||
|
||||
test('after-sale submit and withdraw endpoints verify captcha once per request', () => {
|
||||
const service = read('src/Payment/AfterSaleService.php');
|
||||
test('ticket create and reply endpoints verify captcha once per request', () => {
|
||||
const service = read('src/Ticket/TicketRest.php');
|
||||
|
||||
// 两个新场景已在场景白名单与后台「启用场景」勾选列表同步注册
|
||||
const scenes = read('inc/modules/captcha.php');
|
||||
assert.match(scenes, /'after_sale_apply'\s*=> __\('申请售后', 'aksrt'\)/);
|
||||
assert.match(scenes, /'after_sale_cancel'\s*=> __\('撤销售后', 'aksrt'\)/);
|
||||
assert.match(scenes, /'ticket_create'\s*=> __\('提交工单', 'aksrt'\)/);
|
||||
assert.match(scenes, /'ticket_reply'\s*=> __\('回复工单', 'aksrt'\)/);
|
||||
const permissions = read('src/Admin/Schema/SchemaPermissions.php');
|
||||
assert.match(permissions, /'label' => '申请售后', 'value' => 'after_sale_apply'/);
|
||||
assert.match(permissions, /'label' => '撤销售后', 'value' => 'after_sale_cancel'/);
|
||||
assert.match(permissions, /'label' => '提交工单', 'value' => 'ticket_create'/);
|
||||
assert.match(permissions, /'label' => '回复工单', 'value' => 'ticket_reply'/);
|
||||
const adminSchema = read('assets/admin/src/schema/sections/permissions/index.js');
|
||||
assert.match(adminSchema, /label: '申请售后', value: 'after_sale_apply'/);
|
||||
assert.match(adminSchema, /label: '撤销售后', value: 'after_sale_cancel'/);
|
||||
assert.match(adminSchema, /label: '提交工单', value: 'ticket_create'/);
|
||||
assert.match(adminSchema, /label: '回复工单', value: 'ticket_reply'/);
|
||||
|
||||
// REST 提交 / 撤销各过一次场景校验(统一入口 helper 内恰一次 aksrt_require_captcha);
|
||||
// REST 新建 / 回复各过一次场景校验(统一入口 helper 内恰一次 aksrt_require_captcha);
|
||||
// 场景未启用时 aksrt_require_captcha 直接放行
|
||||
const submit = service.slice(service.indexOf('public function rest_submit'), service.indexOf('public function rest_list'));
|
||||
const cancel = service.slice(service.indexOf('public function rest_cancel'), service.indexOf('public function create'));
|
||||
assert.match(submit, /require_captcha\(\$request, 'after_sale_apply'\)/);
|
||||
assert.match(cancel, /require_captcha\(\$request, 'after_sale_cancel'\)/);
|
||||
assert.equal((service.match(/require_captcha\(\$request, '/g) || []).length, 2);
|
||||
const submit = service.slice(service.indexOf('public function create_ticket'), service.indexOf('public function reply'));
|
||||
const cancel = service.slice(service.indexOf('public function reply'), service.indexOf('public function urge'));
|
||||
assert.match(submit, /require_captcha\(\$request, self::CAPTCHA_SCENE_CREATE\)/);
|
||||
assert.match(cancel, /require_captcha\(\$request, self::CAPTCHA_SCENE_REPLY\)/);
|
||||
assert.match(service, /const CAPTCHA_SCENE_CREATE = 'ticket_create'/);
|
||||
assert.match(service, /const CAPTCHA_SCENE_REPLY = 'ticket_reply'/);
|
||||
assert.equal((service.match(/require_captcha\(\$request, self::CAPTCHA_SCENE_/g) || []).length, 2);
|
||||
assert.equal((service.match(/aksrt_require_captcha\(/g) || []).length, 1);
|
||||
assert.match(service, /function_exists\('aksrt_require_captcha'\)/);
|
||||
});
|
||||
@@ -124,99 +124,58 @@ test('flow retention and archive were deliberately removed', () => {
|
||||
assert.doesNotMatch(read('functions.php'), /FinancialFlowRetention/)
|
||||
assert.equal(fs.existsSync(path.join(root, 'src/Finance/FinancialFlowRetention.php')), false)
|
||||
})
|
||||
|
||||
test('order detail page drops tabs; after-sales becomes a standalone submenu', () => {
|
||||
test('order detail page drops tabs; ticket center replaces the after-sale submenu', () => {
|
||||
const page = read('src/Admin/Native/PayOrders.php')
|
||||
const menus = read('src/Admin/Native/PayMenus.php')
|
||||
const repository = read('src/Payment/OrderRepositoryCore.php')
|
||||
// 订单明细页不再有 Tab 分页:统计 Tab 与页面整体删除,售后 Tab 移出
|
||||
// 订单明细页不再有 Tab 分页:统计 Tab 与页面整体删除
|
||||
assert.ok(!page.includes('render_workbench_tabs'), 'workbench tabs must be removed')
|
||||
assert.ok(!page.includes("$_GET['tab']"), 'tab query param must not be read')
|
||||
assert.ok(!page.includes('render_statistics_page'), 'statistics page must be removed')
|
||||
assert.ok(!page.includes('stat_card'), 'stat cards must be removed')
|
||||
assert.ok(!repository.includes('get_admin_order_stats'), 'dead stats query must be removed')
|
||||
// 售后独立为子菜单「售后服务」(aksrt_after_sales),无兼容跳转
|
||||
assert.ok(menus.includes("'slug' => 'aksrt_after_sales'"), 'after-sales submenu slug')
|
||||
assert.ok(page.includes('render_after_sales_admin_page'), 'after-sales admin page entry')
|
||||
assert.ok(page.includes("PAGE_AFTER_SALES_SLUG = 'aksrt_after_sales'"), 'after-sales slug constant')
|
||||
assert.ok(!page.includes('after-sales'), 'old tab slug must not remain')
|
||||
// 售后表格列对齐售后策略的申请表单配置项:原因 / 申请资料 / 申请说明
|
||||
assert.ok(page.includes('<th>申请资料</th>'), 'materials column')
|
||||
assert.ok(page.includes('<th>申请说明</th>'), 'description column')
|
||||
assert.ok(!page.includes('<th>举证图片</th>'), 'evidence images must not be a standalone column')
|
||||
assert.ok(page.includes('aksrt-as-desc-thumbs'), 'evidence thumbnails render inside the description cell')
|
||||
// 操作列:点击「操作」以手风琴展开整条记录详情(含完整字段与状态面板),经 JS 以 POST 提交
|
||||
assert.ok(page.includes('aksrt-toggle-row-actions'), 'toggle to expand record accordion')
|
||||
assert.ok(page.includes('aksrt-as-detail-row'), 'accordion detail row per after-sale record')
|
||||
assert.ok(page.includes('aksrt-as-detail-grid'), 'accordion panel renders full record fields')
|
||||
assert.ok(page.includes('aksrt-review-note'), 'review note textarea inside expanded panel')
|
||||
assert.ok(page.includes('aksrt-as-review-actions'), 'review actions row inside expanded panel')
|
||||
assert.ok(page.includes('aksrt-status-select'), 'status select inside expanded panel')
|
||||
assert.ok(page.includes('aksrt-after-sale-confirm'), 'confirm button posts the selected status')
|
||||
assert.ok(page.includes('aksrt-after-sale-cancel'), 'cancel button only collapses the panel')
|
||||
assert.ok(page.includes('aksrt_after_sale_update'), 'status update handler registered')
|
||||
assert.ok(page.includes('aksrt_after_sale_status_'), 'per-row nonce covers the status update')
|
||||
assert.ok(page.includes('aksrt-as-field--wide'), 'description renders as a full-width row')
|
||||
assert.ok(page.includes('justify-content:flex-end'), 'confirm/cancel buttons right-aligned')
|
||||
// 「标记订单已退款」:勾选后确认时把关联订单推到已退款
|
||||
assert.ok(page.includes('aksrt-mark-order-refunded'), 'refund checkbox inside expanded panel')
|
||||
assert.ok(page.includes('标记订单已退款'), 'refund checkbox label')
|
||||
assert.ok(page.includes('mark_order_refunded:markRefund?"1":""'), 'refund flag posted only when checked')
|
||||
assert.ok(page.includes('var markRefund=!!(refundEl&&refundEl.checked&&!refundEl.disabled);'),
|
||||
'a disabled (already refunded) checkbox submits nothing')
|
||||
assert.ok(page.includes('aksrt-as-refund-flag'), 'refund checkbox keeps its own row in the panel')
|
||||
// 订单退款必须早于售后状态更新:工单一离开 pending/processing,OrderRepository 的退款闸门就不再放行
|
||||
assert.ok(page.indexOf('mark_related_order_refunded($id)') < page.indexOf('set_status($id'),
|
||||
'order refund runs before the after-sale status update')
|
||||
assert.ok(page.includes('DomainModel::ORDER_STATUS_REFUNDED'), 'refund marking targets the refunded order status')
|
||||
assert.ok(page.includes("'allow_refund' => true"), 'refund opts into the guarded transition')
|
||||
assert.ok(page.includes("'after_sale_id' => $after_sale_id"), 'refund is bound to this after-sale application')
|
||||
assert.ok(page.includes('after_sale_order_refund_failed'), 'refund failure is reported to the admin')
|
||||
assert.ok(page.includes('after_sale_updated_order_refunded'), 'refund success is reported to the admin')
|
||||
const service = read('src/Payment/AfterSaleService.php')
|
||||
assert.match(service, /public function get\(int \$id\)/, 'after-sale lookup is exposed for the admin handler')
|
||||
assert.ok(page.includes('.aksrt-as-detail-row[hidden]'), 'hidden attribute guard keeps accordion closed')
|
||||
assert.ok(page.includes('document.addEventListener("click"'), 'delegated click handler survives row re-render')
|
||||
assert.ok(!page.includes('class="row-actions"'), 'no hover-only row-actions')
|
||||
assert.ok(!page.includes('aksrt_after_sale_bulk'), 'bulk review handler removed')
|
||||
assert.ok(!page.includes('批量操作'), 'bulk actions dropdown removed')
|
||||
assert.ok(!page.includes('after_sale_ids[]'), 'row checkboxes removed')
|
||||
assert.ok(page.includes('$materials_cell'), 'materials cell rendered from apply_form')
|
||||
assert.ok(page.includes('$desc_cell'), 'description cell rendered from apply_form')
|
||||
// 页头展示售后策略摘要(功能开关 / 时效 / 预置原因 / 申请材料)
|
||||
assert.ok(page.includes('get_after_sale_settings()'), 'policy summary reads settings')
|
||||
assert.ok(page.includes('预置原因'), 'policy summary shows reasons')
|
||||
assert.ok(page.includes('申请所需材料'), 'policy summary shows materials')
|
||||
// 售后整体移除:工单改走 users.php 的「工单管理」子菜单,支付中心不再承载售后
|
||||
assert.ok(!menus.includes('aksrt_after_sales'), 'after-sales submenu slug must be gone')
|
||||
assert.ok(!page.includes('render_after_sales_admin_page'), 'after-sales admin page entry must be gone')
|
||||
assert.ok(!page.includes('PAGE_AFTER_SALES_SLUG'), 'after-sales slug constant must be gone')
|
||||
assert.ok(!page.includes('after_sale'), 'no after-sale handler may remain on the orders page')
|
||||
const usersMenus = read('src/Admin/Native/UsersMenus.php')
|
||||
assert.ok(usersMenus.includes("'aksrt_tickets'"), 'ticket submenu slug registered under users.php')
|
||||
assert.ok(usersMenus.includes('render_tickets_page'), 'ticket admin page entry registered')
|
||||
const ticketTrait = read('src/Admin/Native/UsersMenusTicketsCenter.php')
|
||||
assert.ok(ticketTrait.includes('use UsersMenusTicketsList;'), 'ticket list renderer trait composed')
|
||||
assert.ok(ticketTrait.includes('use UsersMenusTicketsConfig;'), 'ticket config renderer trait composed')
|
||||
const domain = read('src/Core/DomainModel.php')
|
||||
for (const status of ['pending', 'paid', 'refunded', 'closed']) assert.ok(domain.includes(`ORDER_STATUS_${status.toUpperCase()}`))
|
||||
assert.match(page, /STATUS_PENDING[\s\S]*?STATUS_PROCESSING[\s\S]*?STATUS_COMPLETED[\s\S]*?STATUS_REJECTED[\s\S]*?STATUS_CANCELLED/)
|
||||
assert.ok(!page.includes('STATUS_APPROVED'), 'approved status removed')
|
||||
assert.match(page, /ORDER_PRODUCT_READING[\s\S]*?付费阅读[\s\S]*?ORDER_PRODUCT_DOWNLOAD[\s\S]*?付费下载[\s\S]*?ORDER_PRODUCT_IMAGE[\s\S]*?付费图片[\s\S]*?ORDER_PRODUCT_VIDEO[\s\S]*?付费视频[\s\S]*?ORDER_PRODUCT_VIP[\s\S]*?购买会员[\s\S]*?ORDER_PRODUCT_RECHARGE[\s\S]*?余额充值[\s\S]*?ORDER_PRODUCT_POINTS[\s\S]*?购买积分/)
|
||||
})
|
||||
|
||||
test('paid orders expose a refund button next to the detail link', () => {
|
||||
const page = read('src/Admin/Native/PayOrders.php')
|
||||
const service = read('src/Payment/AfterSaleService.php')
|
||||
// 「退款」紧跟在「详情」右侧,仅已支付订单可见——已退款是已支付的唯一后继状态
|
||||
assert.match(page, /详情', 'aksrt'\) \. '<\/a>';\s*\n\s*\/\/ 已支付是已退款的唯一前置状态[\s\S]*?aksrt_pay_refund_order/)
|
||||
assert.ok(page.includes("if (\$status_key === \\AKSRT\\Core\\DomainModel::ORDER_STATUS_PAID) {"),
|
||||
assert.ok(page.includes("if ($status_key === \\AKSRT\\Core\\DomainModel::ORDER_STATUS_PAID) {"),
|
||||
'refund button only renders for paid orders')
|
||||
assert.ok(page.includes('aksrt_pay_refund_order_'), 'per-order nonce covers the refund')
|
||||
assert.ok(page.includes("add_action('admin_post_aksrt_pay_refund_order', [\$this, 'handle_refund_order'])"),
|
||||
assert.ok(page.includes("add_action('admin_post_aksrt_pay_refund_order', [$this, 'handle_refund_order'])"),
|
||||
'refund action registered')
|
||||
assert.ok(page.includes('handle_refund_order'), 'refund handler exists')
|
||||
// 工单号由服务端按订单号反查,客户端只能传订单号
|
||||
assert.ok(page.includes("AfterSaleService::get_instance()->find_active(\$order_number)"),
|
||||
'active after-sale application is resolved server-side')
|
||||
assert.ok(!page.includes('after_sale_id\' => absint'), 'after-sale id is never taken from the request')
|
||||
assert.ok(page.includes("'allow_refund' => true"), 'refund opts into the guarded transition')
|
||||
// 退款闸门只剩「订单为已支付」:工单系统与订单退款已解耦,不再要求先有进行中的工单
|
||||
assert.ok(!page.includes('AfterSaleService'), 'no after-sale lookup in the refund path')
|
||||
assert.ok(!page.includes('after_sale_id'), 'refund is no longer bound to an after-sale application')
|
||||
assert.ok(page.includes("'allow_refund' => true"), 'refund opts into the guarded transition')
|
||||
const repository = read('src/Payment/OrderRepositoryCore.php')
|
||||
assert.ok(!repository.includes('can_finalize_order_refund'),
|
||||
'OrderRepository no longer gates refunds on an after-sale application')
|
||||
// 各种失败都要给出可操作的提示,而不是静默失败
|
||||
for (const code of ['order_refunded', 'order_refund_failed', 'order_refund_missing', 'order_refund_not_paid', 'order_refund_no_ticket']) {
|
||||
for (const code of ['order_refunded', 'order_refund_failed', 'order_refund_missing', 'order_refund_not_paid']) {
|
||||
assert.ok(page.includes(`'${code}'`), `flash code ${code} is mapped`)
|
||||
}
|
||||
assert.match(service, /public function find_active\(string \$number\)/, 'active application lookup is exposed')
|
||||
assert.ok(!page.includes('order_refund_no_ticket'), 'the no-ticket refund failure is gone')
|
||||
})
|
||||
|
||||
|
||||
|
||||
test('order creation retries only on order-number unique key collisions', () => {
|
||||
const repository = read('src/Payment/OrderRepositoryCore.php')
|
||||
assert.match(repository, /private const ORDER_NUMBER_MAX_ATTEMPTS = \d+;/)
|
||||
|
||||
@@ -89,6 +89,7 @@ namespace {
|
||||
require dirname(__DIR__, 2) . '/src/Auth/OAuthProxyProtocol.php';
|
||||
require dirname(__DIR__, 2) . '/src/Auth/OAuth.php';
|
||||
require dirname(__DIR__, 2) . '/src/Admin/Schema/SchemaUsers.php';
|
||||
require dirname(__DIR__, 2) . '/src/Admin/Schema/Users/SchemaUsersTicket.php';
|
||||
require dirname(__DIR__, 2) . '/src/Admin/SchemaProvider.php';
|
||||
|
||||
function check(bool $condition, string $message): void {
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const root = path.resolve(__dirname, '../..')
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8')
|
||||
|
||||
test('orders expose four states, seven product categories and retain 22-digit IDs', () => {
|
||||
const domain = read('src/Core/DomainModel.php')
|
||||
const repository = read('src/Payment/OrderRepositoryCore.php')
|
||||
const order = read('src/Payment/Order.php')
|
||||
|
||||
for (const status of ['pending', 'paid', 'refunded', 'closed']) {
|
||||
assert.match(domain, new RegExp(`ORDER_STATUS_${status.toUpperCase()}\\s*=\\s*'${status}'`))
|
||||
}
|
||||
assert.ok(repository.includes("ORDER_NUMBER_PATTERN = '/^\\d{20}$|^\\d{22}$/';"))
|
||||
for (const label of ['付费阅读', '付费下载', '付费图片', '付费视频', '购买会员', '余额充值', '购买积分']) {
|
||||
assert.ok(domain.includes(label), `missing product category ${label}`)
|
||||
}
|
||||
assert.match(repository, /\$row\['status'\] === 'refunding'[\s\S]*?Order::STATUS_PAID/)
|
||||
assert.doesNotMatch(order, /STATUS_REFUNDING\s*=/)
|
||||
})
|
||||
|
||||
test('balance and points mutations use user meta, guard debits in SQL and cap history at 50', () => {
|
||||
const assets = read('src/Credits/MetaAssetService.php')
|
||||
const ledger = read('src/Ledger/LedgerService.php')
|
||||
|
||||
assert.match(assets, /MAX_RECORDS\s*=\s*50/)
|
||||
assert.match(assets, /SELECT GET_LOCK\(%s, 5\)/)
|
||||
assert.match(assets, /FOR UPDATE/)
|
||||
assert.match(assets, /CAST\(meta_value AS SIGNED\) >= %d/)
|
||||
assert.match(assets, /CAST\(meta_value AS DECIMAL\(20,2\)\) >= CAST\(%s AS DECIMAL\(20,2\)\)/)
|
||||
assert.match(assets, /array_slice\(\$history, 0, self::MAX_RECORDS - 1\)/)
|
||||
assert.match(assets, /'balance_record'/)
|
||||
assert.match(assets, /'points_record'/)
|
||||
assert.match(ledger, /post_meta_command\(\$command, \$guard, \$business_mutation\)/)
|
||||
assert.match(ledger, /AssetType::BALANCE, AssetType::POINTS/)
|
||||
})
|
||||
|
||||
test('free points keep a bounded daily summary alongside the recent points history', () => {
|
||||
const freePoints = read('src/Credits/PointsFree.php')
|
||||
const assets = read('src/Credits/MetaAssetService.php')
|
||||
|
||||
assert.match(freePoints, /META_DETAIL\s*=\s*'free_points_detail'/)
|
||||
assert.match(freePoints, /remaining_today\(\$user_id\) < \$points/)
|
||||
assert.match(freePoints, /array_slice\(\$detail, 0, 50, true\)/)
|
||||
assert.match(freePoints, /LedgerService::get_instance\(\)->credit\([\s\S]*?self::bump_detail\(\$user_id, \$points\)/)
|
||||
assert.match(assets, /'points_record'/)
|
||||
})
|
||||
|
||||
test('after-sale submissions are order-locked and admins set status directly', () => {
|
||||
const afterSale = read('src/Payment/AfterSaleService.php')
|
||||
const schema = read('src/Core/DBMigratorPart2.php')
|
||||
const repository = read('src/Payment/OrderRepositoryCore.php')
|
||||
|
||||
// 处理记录落在工单行内:每次状态提交一条「状态 + 备注 + 来源 + 时间」。
|
||||
assert.match(schema, /CREATE TABLE \{\$prefix\}aksrt_order_aftersale[\s\S]*?handling_log LONGTEXT DEFAULT NULL[\s\S]*?refund_amount/)
|
||||
for (const dead of ['admin_note', 'gateway_request_id', 'gateway_refund_id', 'gateway_result', 'reviewed_at', 'completed_at']) {
|
||||
// 只截到本表 CREATE 语句结束,避免误判其它表里同名的 completed_at
|
||||
const block = schema.slice(schema.indexOf('CREATE TABLE {$prefix}aksrt_order_aftersale'))
|
||||
const column = block.slice(0, block.indexOf('ENGINE=InnoDB'))
|
||||
assert.ok(!column.includes(dead), `${dead} column must be removed from aksrt_order_aftersale`)
|
||||
}
|
||||
// create() 的重复申请闸门看「最新一条工单」而不是只看进行中的:已完结同样要挡
|
||||
// 只挡仍在流转的工单:已完结(completed)不挡,能否再售后由订单是否已退款决定
|
||||
assert.match(afterSale, /GET_LOCK\(%s, 5\)[\s\S]*?get_user_order\(\$user_id, \$order_number\)[\s\S]*?find_active\(\$order_number\)/)
|
||||
// 待审核 / 处理中:同人同因幂等返回原工单
|
||||
assert.match(afterSale, /hash_equals\(\(string\) \(\$active\['reason'\] \?\? ''\), \$reason\)/)
|
||||
// 订单已退款走 STATUS_PAID 校验挡掉,工单 completed 不参与这道闸门
|
||||
assert.ok(!afterSale.includes('该订单的售后申请已完结,不能再次申请'), 'completed-block copy must be removed')
|
||||
assert.match(afterSale, /Order::STATUS_PAID/)
|
||||
assert.match(afterSale, /public function set_status\(int \$id, string \$status, string \$note = ''\)/)
|
||||
assert.match(afterSale, /in_array\(\$status, \[self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_COMPLETED, self::STATUS_REJECTED, self::STATUS_CANCELLED\], true\)/)
|
||||
assert.ok(!afterSale.includes('STATUS_APPROVED'), 'approved status removed')
|
||||
assert.ok(!afterSale.includes('function review('), 'decision-based review removed')
|
||||
assert.ok(!afterSale.includes('approve('), 'refund pipeline removed')
|
||||
assert.match(repository, /can_finalize_order_refund\(\(int\) \(\$extra\['after_sale_id'\] \?\? 0\), \$order_number\)/)
|
||||
})
|
||||
|
||||
test('withdrawal holds and returns use the selected user-meta or commission pool', () => {
|
||||
const withdrawal = read('src/Payment/Withdrawal.php')
|
||||
const assets = read('src/Credits/MetaAssetService.php')
|
||||
|
||||
assert.match(withdrawal, /source_balance_decimal\(\$user_id, \$source\)/)
|
||||
assert.match(withdrawal, /private function source_balance_decimal\(int \$user_id, string \$source\)/)
|
||||
assert.match(withdrawal, /\$this->source_asset\(\$source\)/)
|
||||
assert.match(withdrawal, /\$this->source_balance_decimal\(\$user_id, \$refund_source\)/)
|
||||
assert.match(assets, /AssetType::BALANCE->value, AssetType::POINTS->value/)
|
||||
assert.match(assets, /function write_balance\([\s\S]*?\$wpdb->usermeta/)
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 工单人机验证契约:ticket_create / ticket_reply 两个场景必须在三处对齐
|
||||
* (PHP 场景白名单 / 后台「启用场景」勾选列表的 PHP schema / SPA schema),
|
||||
* 且旧的 after_sale_* 场景已彻底移除。
|
||||
*/
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
|
||||
const SCENES = [
|
||||
{ key: 'ticket_create', label: '提交工单' },
|
||||
{ key: 'ticket_reply', label: '回复工单' },
|
||||
];
|
||||
|
||||
test('ticket captcha scenes are registered in all three places', () => {
|
||||
const captcha = read('inc/modules/captcha.php');
|
||||
const permissions = read('src/Admin/Schema/SchemaPermissions.php');
|
||||
const adminSchema = read('assets/admin/src/schema/sections/permissions/index.js');
|
||||
|
||||
for (const scene of SCENES) {
|
||||
assert.match(captcha, new RegExp(`'${scene.key}'\\s*=> __\\('${scene.label}', 'aksrt'\\)`),
|
||||
`captcha scene ${scene.key} missing`);
|
||||
assert.ok(permissions.includes(`'label' => '${scene.label}', 'value' => '${scene.key}'`),
|
||||
`PHP permissions schema missing ${scene.key}`);
|
||||
assert.ok(adminSchema.includes(`{ label: '${scene.label}', value: '${scene.key}' }`),
|
||||
`SPA permissions schema missing ${scene.key}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the after-sale captcha scenes are gone', () => {
|
||||
for (const file of [
|
||||
'inc/modules/captcha.php',
|
||||
'src/Admin/Schema/SchemaPermissions.php',
|
||||
'assets/admin/src/schema/sections/permissions/index.js',
|
||||
]) {
|
||||
const src = read(file);
|
||||
assert.ok(!src.includes('after_sale_apply'), `${file} still declares after_sale_apply`);
|
||||
assert.ok(!src.includes('after_sale_cancel'), `${file} still declares after_sale_cancel`);
|
||||
assert.ok(!src.includes('申请售后'), `${file} still carries the 申请售后 label`);
|
||||
assert.ok(!src.includes('撤销售后'), `${file} still carries the 撤销售后 label`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the ticket REST layer consumes both scenes exactly once', () => {
|
||||
const rest = read('src/Ticket/TicketRest.php');
|
||||
assert.match(rest, /const CAPTCHA_SCENE_CREATE = 'ticket_create';/);
|
||||
assert.match(rest, /const CAPTCHA_SCENE_REPLY = 'ticket_reply';/);
|
||||
assert.equal((rest.match(/require_captcha\(\$request, self::CAPTCHA_SCENE_/g) || []).length, 2,
|
||||
'create and reply must each verify captcha exactly once');
|
||||
assert.equal((rest.match(/aksrt_require_captcha\(/g) || []).length, 1,
|
||||
'captcha verification must funnel through one helper');
|
||||
});
|
||||
|
||||
test('the ticket panel mounts both captcha scenes', () => {
|
||||
const renderer = read('inc/helpers/user-center-renderers/profile-tickets.php');
|
||||
assert.ok(renderer.includes("aksrt_render_captcha('ticket_create')"), 'create mount missing');
|
||||
assert.ok(renderer.includes("aksrt_render_captcha('ticket_reply')"), 'reply mount missing');
|
||||
assert.ok(!renderer.includes('after_sale'), 'renderer must not mount after-sale scenes');
|
||||
|
||||
const js = read('assets/js/ticket-center.js');
|
||||
assert.match(js, /runCaptcha\('ticket_create'\)/);
|
||||
assert.match(js, /runCaptcha\('ticket_reply'\)/);
|
||||
// 取消验证时中止提交,不静默放行。
|
||||
assert.match(js, /return payload === null \? Promise\.reject\(\{ cancelled: true \}\)/);
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 工单状态机契约:
|
||||
* 1) 迁移规则集中在 TicketStatusService::can_transition(),上层不各自判合法性;
|
||||
* 2) 回复会把工单推到「已回复 / 待回复」,管理员「接收」只能从待接单出发;
|
||||
* 3) 已完结状态不可再流转,也不可从未接单一步跳到关闭;
|
||||
* 4) 催单冷却用 transient,关闭走通知;
|
||||
* 5) 默认状态在后台被拒绝改删,非默认状态带工单时也拒删。
|
||||
*/
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
|
||||
test('transition rules live in one place', () => {
|
||||
const service = read('src/Ticket/TicketStatusService.php');
|
||||
const body = service.slice(service.indexOf('public static function can_transition'));
|
||||
|
||||
// 目标必须存在;同状态幂等;已完结不可流转;待接单不可直接关闭。
|
||||
assert.match(body, /if \(\$to === '' \|\| !self::exists\(\$to\)\) \{\s*\n\s*return false;/);
|
||||
assert.match(body, /if \(\$from === \$to\) \{\s*\n\s*return true;/);
|
||||
assert.match(body, /if \(self::is_closed\(\$from\)\) \{\s*\n\s*return false;/);
|
||||
assert.match(body, /if \(\$from === self::KEY_PENDING && \$to === self::KEY_CLOSED\) \{\s*\n\s*return false;/);
|
||||
|
||||
// 上层只调用 can_transition,不复制合法性判断。
|
||||
for (const file of ['src/Ticket/TicketRepository-traits/TicketStatus.php', 'src/Ticket/TicketRestAdmin.php', 'src/Admin/Native/UsersMenusTicketsCenter.php']) {
|
||||
const src = read(file);
|
||||
assert.ok(src.includes('can_transition'), `${file} must delegate to can_transition`);
|
||||
}
|
||||
});
|
||||
|
||||
test('reply and receive drive the documented state changes', () => {
|
||||
const status = read('src/Ticket/TicketRepository-traits/TicketStatus.php');
|
||||
|
||||
// 管理员回复 → 已回复;用户回复 → 待回复。
|
||||
assert.match(status, /\$target = \$author_type === 'admin'\s*\n\s*\? TicketStatusService::KEY_ADMIN_REPLIED\s*\n\s*: TicketStatusService::KEY_USER_REPLIED;/);
|
||||
// 同状态不重复迁移;已完结工单上的回复不改变状态。
|
||||
assert.match(status, /if \(\$from === \$target\) \{\s*\n\s*return true;/);
|
||||
assert.match(status, /if \(TicketStatusService::is_closed\(\$from\)\) \{\s*\n\s*return false;/);
|
||||
|
||||
// 「接收」只允许从未接单转处理中。
|
||||
assert.match(status, /if \(\(string\) \(\$ticket\['status'\] \?\? ''\) !== TicketStatusService::KEY_PENDING\) \{\s*\n\s*return false;/);
|
||||
assert.match(status, /return \$this->transition_ticket\(\$ticket_id, \$ticket, TicketStatusService::KEY_HANDLING, 'admin', get_current_user_id\(\)\);/);
|
||||
|
||||
// 「已解决」落到已关闭。
|
||||
assert.match(status, /private function resolve_ticket\(int \$ticket_id, array \$ticket\): bool \{\s*\n\s*return \$this->transition_ticket\(\$ticket_id, \$ticket, TicketStatusService::KEY_CLOSED, 'admin', get_current_user_id\(\)\);/);
|
||||
});
|
||||
|
||||
test('closing notifies the counterparty exactly once', () => {
|
||||
const status = read('src/Ticket/TicketRepository-traits/TicketStatus.php');
|
||||
assert.match(status, /if \(\$to === TicketStatusService::KEY_CLOSED\) \{\s*\n\s*TicketNotifier::closed\(\$ticket, \$actor\);/);
|
||||
|
||||
const notifier = read('src/Ticket/TicketNotifier.php');
|
||||
// 关单双分支:管理员关 → 通知用户;用户关 → 通知跟进人。
|
||||
assert.match(notifier, /if \(\$closer === 'admin'\) \{/);
|
||||
assert.match(notifier, /self::send\(\$user_id, 'ticket_closed'/);
|
||||
assert.match(notifier, /self::send\(\$admin_id, 'ticket_closed'/);
|
||||
});
|
||||
|
||||
test('default status cannot be edited or deleted in the admin surface', () => {
|
||||
// REST 侧的状态 CRUD 在 TicketConfigRest trait 里,由 TicketRestAdmin 组合。
|
||||
const config = read('src/Ticket/TicketConfigRest.php');
|
||||
assert.equal((config.match(/!empty\(\$row\['is_default'\]\)/g) || []).length, 2,
|
||||
'both update and delete must honour is_default');
|
||||
assert.match(config, /'aksrt_ticket_status_default'/);
|
||||
// 原生后台页同样要挡。
|
||||
const native = read('src/Admin/Native/UsersMenusTicketsCenter.php');
|
||||
assert.equal((native.match(/!empty\(\$row\['is_default'\]\)/g) || []).length, 2,
|
||||
'native admin must honour is_default in both paths');
|
||||
|
||||
// 删除前还要确认没有工单挂在该状态/部门下(守卫在 TicketConfigRest trait 中)。
|
||||
assert.match(config, /count_by_status\(\(string\) \$row\['status_key'\]\) > 0/);
|
||||
assert.match(config, /count_by_type\(\$id\) > 0/);
|
||||
assert.match(config, /'aksrt_ticket_status_in_use'/);
|
||||
assert.match(config, /'aksrt_ticket_type_in_use'/);
|
||||
|
||||
const repo = read('src/Ticket/TicketRepository.php');
|
||||
assert.match(repo, /public function count_by_status\(string \$status_key\): int/);
|
||||
assert.match(repo, /public function count_by_type\(int \$type_id\): int/);
|
||||
});
|
||||
|
||||
test('prereply reorder requires the full id set', () => {
|
||||
const service = read('src/Ticket/TicketPrereplyService.php');
|
||||
const body = service.slice(service.indexOf('public static function reorder'));
|
||||
|
||||
// 数量不符或含未知 ID 一律拒绝,避免漏排导致顺序错乱。
|
||||
assert.match(body, /count\(\$ids\) !== count\(\$existing\) \|\| array_diff\(\$ids, \$existing\) !== \[\]/);
|
||||
assert.match(body, /return false;/);
|
||||
// 新建时追加到末尾。
|
||||
assert.match(service, /return \(int\) \$max \+ 1;/);
|
||||
});
|
||||
|
||||
test('ticket numbers are unique and settings-driven', () => {
|
||||
const number = read('src/Ticket/TicketNumber.php');
|
||||
assert.match(number, /TicketSettings::number_prefix\(\)/);
|
||||
assert.match(number, /current_time\('Ymd'\)/);
|
||||
assert.match(number, /wp_rand\(100000, 999999\)/);
|
||||
assert.match(number, /if \(!self::exists\(\$candidate\)\) \{\s*\n\s*return \$candidate;/);
|
||||
// 冲突重试有上限,极端情况追加随机后缀兜底。
|
||||
assert.match(number, /private const MAX_ATTEMPTS = 12;/);
|
||||
|
||||
const settings = read('src/Ticket/TicketSettings.php');
|
||||
assert.match(settings, /strtoupper\(\$raw\)/);
|
||||
assert.match(settings, /sanitize_key/);
|
||||
});
|
||||
|
||||
test('notifications go through the message inbox with deep links', () => {
|
||||
const notifier = read('src/Ticket/TicketNotifier.php');
|
||||
// 所有通知都走 Message::send(),持久化到 aksrt_notification。
|
||||
assert.match(notifier, /\\AKSRT\\Messages\\Message::get_instance\(\)->send\(/);
|
||||
assert.match(notifier, /class_exists\('\\AKSRT\\Messages\\Message'\)/);
|
||||
// 新建工单通知全体管理员,催单只通知跟进人。
|
||||
assert.match(notifier, /get_users\(\['role' => 'administrator', 'fields' => 'ID', 'number' => 20\]\)/);
|
||||
assert.match(notifier, /\$admin_id = \(int\) \(\$ticket\['admin_id'\] \?\? 0\);/);
|
||||
// 深链分别指向后台工单页与用户中心工单位。
|
||||
assert.match(notifier, /admin_url\('users\.php\?page=aksrt_tickets'\)/);
|
||||
assert.match(notifier, /aksrt_get_user_center_url\('tickets'\)/);
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 工单后台(users.php → aksrt_tickets)契约:
|
||||
* 1) 子菜单已注册,trait 已组合,写操作走 admin_post 且每个都带 nonce;
|
||||
* 2) 分页用共享 AdminListPagination,不写死 limit;
|
||||
* 3) 默认状态与「仍有工单」两类删除被拒;
|
||||
* 4) SPA 深链 redirect 指向 users.php?page=aksrt_tickets;
|
||||
* 5) 前端 JS 与 PHP 的 data-* 挂载点对齐。
|
||||
*/
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
|
||||
test('ticket submenu is registered under users.php', () => {
|
||||
const menus = read('src/Admin/Native/UsersMenus.php');
|
||||
assert.ok(menus.includes("use UsersMenusTicketsCenter;"), 'trait not composed');
|
||||
assert.ok(menus.includes("'aksrt_tickets'"), 'submenu slug missing');
|
||||
assert.ok(menus.includes("'users.php'"), 'submenu must hang off users.php');
|
||||
assert.ok(menus.includes('[$this, \'render_tickets_page\']'), 'render callback missing');
|
||||
assert.ok(menus.includes("add_action('admin_post_aksrt_ticket_update', [\$this, 'handle_ticket_update'])"),
|
||||
'write handler not registered');
|
||||
// 后台待接单提示。
|
||||
assert.match(menus, /count_pending_tickets\(\)/);
|
||||
assert.match(menus, /TicketSettings::enabled\(\)/);
|
||||
});
|
||||
|
||||
test('write actions are nonce-protected and dispatched by action name', () => {
|
||||
const center = read('src/Admin/Native/UsersMenusTicketsCenter.php');
|
||||
|
||||
assert.match(center, /check_admin_referer\('aksrt_ticket_' \. \$action \. '_' \. \$id\);/);
|
||||
assert.ok(center.includes('current_user_can(self::CAPABILITY)'), 'capability check missing');
|
||||
|
||||
// 每个动作分支都要有对应处理器。
|
||||
for (const action of ['status', 'reply', 'type', 'status_def', 'prereply', 'prereply_delete', 'type_delete', 'status_delete', 'note', 'create']) {
|
||||
assert.ok(center.includes(`case '${action}':`), `dispatch case ${action} missing`);
|
||||
}
|
||||
|
||||
// 表单里 nonce 以 action + id 为单位,删除类操作独立小表单 + 二次确认。
|
||||
assert.match(center, /wp_nonce_field\('aksrt_ticket_' \. \$action \. '_' \. \$id, '_wpnonce', true, false\)/);
|
||||
assert.match(center, /onsubmit="return confirm\(/);
|
||||
});
|
||||
|
||||
test('the ticket list paginates through the shared helper', () => {
|
||||
const list = read('src/Admin/Native/UsersMenusTicketsList.php');
|
||||
assert.ok(list.includes('AdminListPagination::render('), 'shared pagination helper not used');
|
||||
assert.ok(!/limit => 100/.test(list), 'hard-coded limit must not come back');
|
||||
// 分页条必须晚于空态分支。
|
||||
assert.ok(
|
||||
list.indexOf('AdminListPagination::render(') > list.indexOf('暂无工单'),
|
||||
'pagination must render after the empty-state branch'
|
||||
);
|
||||
// 每页条数来自常量(常量定义在父 trait UsersMenusTicketsCenter 中)。
|
||||
const center = read('src/Admin/Native/UsersMenusTicketsCenter.php');
|
||||
assert.match(center, /private const TICKET_PER_PAGE = 20;/);
|
||||
assert.match(list, /'limit' => self::TICKET_PER_PAGE,/);
|
||||
});
|
||||
|
||||
test('config tab edits departments, statuses and prereplies', () => {
|
||||
const config = read('src/Admin/Native/UsersMenusTicketsConfig.php');
|
||||
|
||||
assert.match(config, /render_ticket_types_panel/);
|
||||
assert.match(config, /render_ticket_statuses_panel/);
|
||||
assert.match(config, /render_ticket_prereplies_panel/);
|
||||
|
||||
// 默认状态行不给编辑/删除入口。
|
||||
assert.match(config, /empty\(\$row\['is_default'\]\)/);
|
||||
// 删除按钮带 confirm,且走独立小表单。
|
||||
assert.match(config, /ticket_delete_button\('status_delete', 'status_id'/);
|
||||
assert.match(config, /ticket_delete_button\('type_delete', 'type_id'/);
|
||||
assert.match(config, /ticket_delete_button\('prereply_delete', 'prereply_id'/);
|
||||
|
||||
// 状态徽章颜色来自状态表,不写死。
|
||||
assert.match(config, /--aksrt-ticket-color:%s/);
|
||||
});
|
||||
|
||||
test('detail panel renders replies, quote blocks and internal notes', () => {
|
||||
const list = read('src/Admin/Native/UsersMenusTicketsList.php');
|
||||
const body = list.slice(list.indexOf('private function render_ticket_detail_panel'));
|
||||
|
||||
assert.match(body, /TicketNoteService::for_ticket/);
|
||||
assert.match(body, /repo->replies\(\$id\)/);
|
||||
// 引用块:原回复已删除时显示占位。
|
||||
assert.match(body, /!empty\(\$quote\['is_deleted'\]\) \? __\('原回复已删除', 'aksrt'\)/);
|
||||
// 回复与备注各自独立表单 + nonce。
|
||||
assert.match(body, /wp_nonce_field\('aksrt_ticket_reply_' \. \$id, '_wpnonce', true, false\)/);
|
||||
assert.match(body, /wp_nonce_field\('aksrt_ticket_note_' \. \$id, '_wpnonce', true, false\)/);
|
||||
// 已关闭工单仍可查看,但不再展示迁移表单之外的写入口由前端按钮控制。
|
||||
assert.match(body, /aksrt-ticket-detail__closed/);
|
||||
});
|
||||
|
||||
test('SPA deep-links the native ticket page instead of hosting it', () => {
|
||||
const router = read('assets/admin/src/router/index.js');
|
||||
assert.ok(router.includes("{ path: '/users/tickets', title: '工单管理', nativeAdminPath: 'users.php?page=aksrt_tickets' }"),
|
||||
'NATIVE_OPS_REDIRECTS must point at the native ticket page');
|
||||
});
|
||||
|
||||
test('frontend hooks match the renderer and the JS module', () => {
|
||||
const renderer = read('inc/helpers/user-center-renderers/profile-tickets.php');
|
||||
const js = read('assets/js/ticket-center.js');
|
||||
|
||||
for (const hook of [
|
||||
'data-aksrt-ticket-center',
|
||||
'data-aksrt-ticket-create',
|
||||
'data-aksrt-ticket-stats',
|
||||
'data-aksrt-ticket-stat=',
|
||||
'data-aksrt-ticket-list',
|
||||
'data-aksrt-ticket-pagination',
|
||||
'data-aksrt-tickets-search',
|
||||
'data-aksrt-ticket-filter=',
|
||||
'data-aksrt-ticket-config',
|
||||
]) {
|
||||
assert.ok(renderer.includes(hook), `renderer missing ${hook}`);
|
||||
}
|
||||
|
||||
// JS 侧的挂载点与 REST 路径必须和 PHP 对齐。
|
||||
for (const hook of [
|
||||
'data-aksrt-ticket-center',
|
||||
'data-aksrt-ticket-create',
|
||||
'data-aksrt-ticket-stat="',
|
||||
'data-aksrt-ticket-list',
|
||||
'data-aksrt-ticket-pagination',
|
||||
'data-aksrt-tickets-search',
|
||||
'[data-aksrt-ticket-filter]',
|
||||
'data-aksrt-ticket-view=',
|
||||
'data-aksrt-ticket-page=',
|
||||
'data-aksrt-ticket-quote=',
|
||||
'data-aksrt-ticket-images',
|
||||
]) {
|
||||
assert.ok(js.includes(hook), `ticket-center.js missing ${hook}`);
|
||||
}
|
||||
|
||||
assert.ok(js.includes("'/tickets'"), 'list/create path missing');
|
||||
assert.ok(js.includes("'/tickets/statistic'"), 'statistic path missing');
|
||||
assert.ok(js.includes("'/tickets/meta'"), 'meta path missing');
|
||||
assert.ok(js.includes("'/tickets/' + ticketId"), 'detail path missing');
|
||||
assert.ok(js.includes("'/tickets/' + ticket.id + '/reply'"), 'reply path missing');
|
||||
assert.ok(js.includes("'/tickets/' + ticket.id + '/urge'"), 'urge path missing');
|
||||
assert.ok(js.includes("'/tickets/' + ticket.id + '/close'"), 'close path missing');
|
||||
assert.ok(js.includes("'/ticket/upload-attachment'"), 'upload path missing');
|
||||
|
||||
// 人机验证在提交与回复前各过一次。
|
||||
assert.match(js, /runCaptcha\('ticket_create'\)/);
|
||||
assert.match(js, /runCaptcha\('ticket_reply'\)/);
|
||||
});
|
||||
|
||||
test('ticket CSS ships as its own route bundle', () => {
|
||||
const css = read('assets/css/routes/tickets.css');
|
||||
assert.ok(css.includes('.aksrt-tickets-page'), 'page root rule missing');
|
||||
assert.ok(css.includes('.aksrt-ticket-badge'), 'status badge rule missing');
|
||||
assert.ok(css.includes('.aksrt-ticket-detail'), 'detail rule missing');
|
||||
// 徽章颜色由状态行内变量驱动,不写死。
|
||||
assert.ok(css.includes('--aksrt-ticket-color'), 'badge color variable missing');
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 工单 REST 权限契约:
|
||||
* 1) 每条路由都带 permission_callback,没有裸路由;
|
||||
* 2) 用户侧只能读写自己的工单(find_for_user 命中 user_id);
|
||||
* 3) 关联订单必须属于本人且为已支付;
|
||||
* 4) 管理侧全部要求 manage_options;
|
||||
* 5) 附件上传复用评论图片上传,但权限回调是工单专属的。
|
||||
*/
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
|
||||
/** 从 register_routes() 源码里抽出所有 register_rest_route 调用的路由路径。 */
|
||||
function routes(source) {
|
||||
const out = [];
|
||||
const re = /register_rest_route\(\s*\$namespace\s*,\s*([^,]+),\s*\[/g;
|
||||
let m;
|
||||
while ((m = re.exec(source)) !== null) out.push(m[1].trim());
|
||||
return out;
|
||||
}
|
||||
|
||||
test('every user-side ticket route declares a permission callback', () => {
|
||||
const rest = read('src/Ticket/TicketRest.php');
|
||||
const body = rest.slice(rest.indexOf('public function register_routes()'), rest.indexOf('private function list_args()'));
|
||||
|
||||
const paths = routes(body);
|
||||
assert.ok(paths.length >= 9, `expected at least 9 routes, found ${paths.length}`);
|
||||
|
||||
// 每个 register_rest_route 调用体内都必须出现 permission_callback。
|
||||
const calls = body.match(/register_rest_route\([\s\S]*?\n\s*\]\);?/g) || [];
|
||||
assert.equal(calls.length, paths.length, 'route call count mismatch');
|
||||
for (const call of calls) {
|
||||
assert.ok(call.includes('permission_callback'), `route without permission_callback:\n${call.slice(0, 160)}`);
|
||||
}
|
||||
|
||||
// 用户侧统一走 require_user(登录 + nonce)。
|
||||
assert.match(body, /'permission_callback' => \[\$this, 'require_user'\]/);
|
||||
assert.match(rest, /is_user_logged_in\(\)/);
|
||||
assert.match(rest, /wp_verify_nonce\(\$nonce, 'wp_rest'\)/);
|
||||
// 附件上传用的是工单专属回调,不是 require_user。
|
||||
assert.match(rest, /'permission_callback' => \[\$this, 'require_attachment_upload'\]/);
|
||||
});
|
||||
|
||||
test('attachment upload permission is gated on the ticket attachment switch', () => {
|
||||
const rest = read('src/Ticket/TicketRest.php');
|
||||
const body = rest.slice(rest.indexOf('public function require_attachment_upload'));
|
||||
|
||||
assert.match(body, /TicketSettings::enabled\(\)/);
|
||||
assert.match(body, /TicketSettings::attachment_enabled\(\)/);
|
||||
// 复用评论图片上传 handler,不自建上传链路。
|
||||
assert.match(rest, /callback' => \[\\AKSRT\\Admin\\UserController::class, 'rest_upload_comment_image'\]/);
|
||||
// 上传路由挂在 /ticket/upload-attachment(与 /report/upload-image 同构)。
|
||||
assert.match(rest, /'\/ticket\/upload-attachment'/);
|
||||
});
|
||||
|
||||
test('user reads and writes are scoped to the owner', () => {
|
||||
const rest = read('src/Ticket/TicketRest.php');
|
||||
const repo = read('src/Ticket/TicketRepository.php');
|
||||
|
||||
// 详情 / 回复 / 催单 / 关闭全部经过 find_for_user。
|
||||
assert.match(repo, /public function find_for_user\(int \$id, int \$user_id\): \?array/);
|
||||
assert.match(repo, /if \(\$row === null \|\| \(int\) \$row\['user_id'\] !== \$user_id\) \{\s*\n\s*return null;/);
|
||||
|
||||
for (const method of ['get_ticket', 'reply', 'urge', 'close']) {
|
||||
const body = rest.slice(rest.indexOf(`public function ${method}(`));
|
||||
const next = body.indexOf('public function', 10);
|
||||
const slice = next === -1 ? body : body.slice(0, next);
|
||||
assert.match(slice, /find_for_user\(/, `${method} must scope by owner`);
|
||||
}
|
||||
|
||||
// 列表与统计也带 user_id。
|
||||
assert.match(rest, /'user_id' => get_current_user_id\(\),/);
|
||||
});
|
||||
|
||||
test('linked orders must belong to the caller and be paid', () => {
|
||||
const rest = read('src/Ticket/TicketRest.php');
|
||||
const body = rest.slice(rest.indexOf('private function owns_paid_order'));
|
||||
|
||||
assert.match(body, /get_user_order\(get_current_user_id\(\), \$order_number\)/);
|
||||
assert.match(body, /ORDER_STATUS_PAID/);
|
||||
// 新建时关联订单非法必须拒掉。
|
||||
assert.match(rest, /if \(\$order_number !== '' && !\$this->owns_paid_order\(\$order_number\)\) \{\s*\n\s*return new \\WP_Error\('aksrt_ticket_order'/);
|
||||
});
|
||||
|
||||
test('admin routes all require manage_options', () => {
|
||||
const admin = read('src/Ticket/TicketRestAdmin.php');
|
||||
const body = admin.slice(admin.indexOf('public function register_routes()'));
|
||||
|
||||
const calls = body.match(/register_rest_route\([\s\S]*?\n\s*\]\);?/g) || [];
|
||||
assert.ok(calls.length >= 20, `expected a full admin surface, found ${calls.length}`);
|
||||
for (const call of calls) {
|
||||
assert.ok(call.includes("'permission_callback' => [\$this, 'require_admin']"),
|
||||
`admin route without require_admin:\n${call.slice(0, 160)}`);
|
||||
}
|
||||
|
||||
assert.match(admin, /current_user_can\('manage_options'\)/);
|
||||
// 管理侧命名空间与用户侧隔离。
|
||||
assert.match(body, /\$namespace = 'aksrt\/v1\/admin';/);
|
||||
});
|
||||
|
||||
test('closed tickets refuse replies and cannot be re-closed', () => {
|
||||
const rest = read('src/Ticket/TicketRest.php');
|
||||
const admin = read('src/Ticket/TicketRestAdmin.php');
|
||||
|
||||
// 用户侧
|
||||
assert.match(rest, /if \(!empty\(\$ticket\['is_closed'\]\)\) \{\s*\n\s*return new \\WP_Error\('aksrt_ticket_closed', __\('工单已关闭,无法回复'/);
|
||||
assert.match(rest, /if \(!empty\(\$ticket\['is_closed'\]\)\) \{\s*\n\s*return new \\WP_Error\('aksrt_ticket_closed', __\('工单已关闭,无法催单'/);
|
||||
// 管理侧
|
||||
assert.match(admin, /if \(!empty\(\$ticket\['is_closed'\]\)\) \{\s*\n\s*return new \\WP_Error\('aksrt_ticket_closed'/);
|
||||
|
||||
// close_ticket 自身也挡已完结(双保险,避免前端绕过 UI 直接 POST)。
|
||||
const status = read('src/Ticket/TicketRepository-traits/TicketStatus.php');
|
||||
assert.match(status, /if \(TicketStatusService::is_closed\(\(string\) \(\$ticket\['status'\] \?\? ''\)\)\) \{\s*\n\s*return false;/);
|
||||
});
|
||||
|
||||
test('urge has a cooldown driven by settings', () => {
|
||||
const rest = read('src/Ticket/TicketRest.php');
|
||||
const body = rest.slice(rest.indexOf('public function urge('));
|
||||
|
||||
assert.match(body, /get_transient\('aksrt_ticket_urge_' \. \$id\)/);
|
||||
assert.match(body, /set_transient\('aksrt_ticket_urge_' \. \$id, time\(\)/);
|
||||
assert.match(body, /TicketSettings::urge_interval\(\) \* MINUTE_IN_SECONDS/);
|
||||
});
|
||||
|
||||
test('quoted replies must belong to the same ticket', () => {
|
||||
for (const file of ['src/Ticket/TicketRest.php', 'src/Ticket/TicketRestAdmin.php']) {
|
||||
const src = read(file);
|
||||
assert.match(src, /if \(\$quoted === null \|\| \(int\) \$quoted\['ticket_id'\] !== \$id\) \{\s*\n\s*\$quote_id = 0;/,
|
||||
`${file} must drop cross-ticket quotes`);
|
||||
}
|
||||
|
||||
const repo = read('src/Ticket/TicketRepository.php');
|
||||
// 自引用同样要挡掉,否则时间线会出现循环引用。
|
||||
assert.match(repo, /if \(\$quote_id === \$self_id\) \{\s*\n\s*return null;/);
|
||||
// 被引用的回复已删除时返回 is_deleted 标记,界面显示占位而不是空白。
|
||||
assert.match(repo, /'is_deleted' => true,/);
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 「工单中心」配置契约:
|
||||
* 1) PHP SchemaUsersTicket 分区与 SPA JS schema 的键、顺序、类型、默认值 1:1 对齐;
|
||||
* 2) SPA 页面已注册(/users/ticket 可达),且挂的是 schemaPage 而非自定义页;
|
||||
* 3) 运行时 TicketSettings 缺键回退 schema 默认,数值边界被夹取;
|
||||
* 4) 前台入口三处均已注册:Center.php 面板定义 + sidebar-nav.php 侧栏项 +
|
||||
* aksrt-user-center.php 的 built_in_tabs 与 view map。
|
||||
*/
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
|
||||
const FIELD_ORDER = [
|
||||
'ticket_s',
|
||||
'ticket_num_prefix',
|
||||
'ticket_notice_open',
|
||||
'ticket_notice_text',
|
||||
'ticket_attachment_s',
|
||||
'ticket_attachment_max',
|
||||
'ticket_order_link_s',
|
||||
'ticket_urge_interval',
|
||||
];
|
||||
|
||||
const GROUPS = [
|
||||
{ key: 'main', title: '工单功能' },
|
||||
{ key: 'form', title: '提交设置' },
|
||||
];
|
||||
|
||||
test('ticket schema keys match between PHP and SPA JS', () => {
|
||||
const php = read('src/Admin/Schema/Users/SchemaUsersTicket.php');
|
||||
const js = read('assets/admin/src/schema/sections/users/ticket.js');
|
||||
|
||||
for (const key of FIELD_ORDER) {
|
||||
assert.ok(php.includes(`'key' => '${key}'`), `PHP schema missing ${key}`);
|
||||
assert.ok(js.includes(`key: '${key}'`), `JS schema missing ${key}`);
|
||||
}
|
||||
|
||||
// 顺序:两组各自的字段顺序必须一致(前端分组渲染依赖它)。
|
||||
for (const group of GROUPS) {
|
||||
const phpGroup = php.slice(php.indexOf(`'key' => '${group.key}'`));
|
||||
const jsGroup = js.slice(js.indexOf(`key: '${group.key}'`));
|
||||
const phpKeys = [...phpGroup.matchAll(/'key' => '([a-z_]+)'/g)].map((m) => m[1]);
|
||||
const jsKeys = [...jsGroup.matchAll(/key: '([a-z_]+)'/g)].map((m) => m[1]);
|
||||
assert.deepEqual(jsKeys, phpKeys, `group ${group.key} field order drifted`);
|
||||
}
|
||||
|
||||
// 类型逐字段对齐。PHP 里 textarea 带 sanitize_type,取值要跳过它,
|
||||
// 因此显式匹配 'type' => '...'(前后都是引号边界,不会撞上 sanitize_type)。
|
||||
for (const key of FIELD_ORDER) {
|
||||
const phpType = new RegExp(`'key' => '${key}'[^\\]]*'type' => '([a-z]+)'`).exec(php);
|
||||
const jsType = new RegExp(`key: '${key}'[^}]*?\\btype: '([a-z]+)'`).exec(js);
|
||||
assert.ok(phpType && jsType, `type not found for ${key}`);
|
||||
assert.equal(jsType[1], phpType[1], `${key} type drifted`);
|
||||
}
|
||||
|
||||
// sanitize_type 也必须一致:公告内容允许 HTML,两侧都要声明。
|
||||
assert.ok(php.includes("'key' => 'ticket_notice_text', 'label' => '公告内容', 'type' => 'textarea', 'sanitize_type' => 'html'"),
|
||||
'PHP notice text must declare sanitize_type html');
|
||||
assert.ok(js.includes("key: 'ticket_notice_text', label: '公告内容', type: 'textarea', sanitize_type: 'html'"),
|
||||
'JS notice text must declare sanitize_type html');
|
||||
});
|
||||
|
||||
test('ticket schema is registered as a schema page under users', () => {
|
||||
const js = read('assets/admin/src/schema/sections/users/ticket.js');
|
||||
assert.equal(js.includes("key: 'users.ticket'"), true, 'schema key must be users.ticket');
|
||||
|
||||
const index = read('assets/admin/src/schema/sections/users/index.js');
|
||||
assert.ok(index.includes("import { ticketSchema } from './ticket.js'"), 'schema not imported');
|
||||
assert.ok(/ticket:\s*ticketSchema/.test(index), 'schema not exported in usersSchemas');
|
||||
|
||||
const constants = read('assets/admin/src/constants/sections/users.js');
|
||||
assert.ok(constants.includes("key: 'users-ticket'"), 'menu page key missing');
|
||||
assert.ok(constants.includes("route: '/users/ticket'"), 'menu route missing');
|
||||
assert.ok(constants.includes("section: 'ticket'"), 'menu section missing');
|
||||
assert.ok(constants.includes("canonicalDomain: 'users'"), 'menu canonical domain missing');
|
||||
|
||||
// PHP 侧:分区由 SchemaUsers 组装,SchemaUsersTicket 提供内容。
|
||||
const users = read('src/Admin/Schema/SchemaUsers.php');
|
||||
assert.ok(users.includes("'ticket' => Users\\SchemaUsersTicket::build()"), 'PHP section not composed');
|
||||
const ticket = read('src/Admin/Schema/Users/SchemaUsersTicket.php');
|
||||
assert.ok(ticket.includes("'key' => 'ticket'"), 'PHP section key missing');
|
||||
assert.ok(ticket.includes("'option_key' => 'aksrt_settings'"), 'PHP option key missing');
|
||||
});
|
||||
|
||||
test('TicketSettings falls back to schema defaults and clamps numerics', () => {
|
||||
const settings = read('src/Ticket/TicketSettings.php');
|
||||
|
||||
// 只取方法体,避免 docblock 与注释里的同形文字参与断言。
|
||||
const body = settings.slice(settings.indexOf('public static function all()'));
|
||||
|
||||
// 缺键回退:enabled 只在显式关闭时为 false(与 schema default true 对齐)。
|
||||
assert.match(body, /!isset\(\$s\['ticket_s'\]\) \|\| !empty\(\$s\['ticket_s'\]\)/);
|
||||
assert.match(body, /self::all\(\)\['ticket_num_prefix'\] \?\? 'T'/);
|
||||
assert.match(body, /self::all\(\)\['ticket_notice_text'\] \?\? ''\)/);
|
||||
assert.match(body, /self::all\(\)\['ticket_attachment_max'\] \?\? 5\)/);
|
||||
assert.match(body, /self::all\(\)\['ticket_urge_interval'\] \?\? 15\)/);
|
||||
|
||||
// 数值夹取:附件上限 1..9,催单冷却 1..1440。
|
||||
assert.match(body, /max\(1, min\(9, \(int\)/);
|
||||
assert.match(body, /max\(1, min\(1440, \(int\)/);
|
||||
|
||||
// 公告文案走 kses(后台允许 HTML)。
|
||||
assert.match(body, /wp_kses_post/);
|
||||
// 分区名与 schema key 一致(常量在 all() 之前声明,故对整份文件断言)。
|
||||
assert.match(settings, /const SECTION = 'ticket'/);
|
||||
assert.match(body, /read_domain\('users', self::SECTION\)/);
|
||||
});
|
||||
|
||||
test('ticket panel is registered across the user-center wiring', () => {
|
||||
const center = read('src/User/Center.php');
|
||||
assert.ok(center.includes("'tickets' => ["), 'panel definition missing');
|
||||
assert.ok(center.includes("'label' => __('工单中心', 'aksrt')"), 'panel label missing');
|
||||
assert.match(center, /'tickets'\s*=>\s*\[[\s\S]*?'priority' => 49/, 'panel priority should sit next to orders (48)');
|
||||
// 开关关闭时整块移除。
|
||||
assert.match(center, /\$ticket_on = class_exists\('\\AKSRT\\Ticket\\TicketSettings'\)/);
|
||||
assert.match(center, /if \(!\$ticket_on\) \{\s*\n\s*\$order = array_values\(array_filter\(\$order, static fn\(string \$key\): bool => \$key !== 'tickets'\)\);/);
|
||||
assert.match(center, /if \(\$key === 'tickets' && !\$ticket_on\) \{\s*\n\s*continue;/);
|
||||
|
||||
const sidebar = read('inc/helpers/user-center-renderers/sidebar-nav.php');
|
||||
assert.ok(sidebar.includes("'label' => __('工单中心', 'aksrt')"), 'sidebar label missing');
|
||||
assert.ok(sidebar.includes("'icon' => 'manual-service'"), 'sidebar icon should reuse the existing headset sprite');
|
||||
assert.ok(sidebar.includes("'tab' => 'tickets'"), 'sidebar tab key missing');
|
||||
|
||||
const helper = read('inc/helpers/aksrt-user-center.php');
|
||||
assert.ok(helper.includes("'tickets'"), 'built_in_tabs missing tickets');
|
||||
assert.match(helper, /'tickets'\s*=> 'tickets',/, 'view map missing tickets => tickets');
|
||||
|
||||
const renderer = read('inc/helpers/user-center-renderers/profile-tickets.php');
|
||||
assert.ok(renderer.includes('data-aksrt-ticket-center'), 'panel root hook missing');
|
||||
assert.ok(renderer.includes('data-aksrt-ticket-create'), 'create button hook missing');
|
||||
assert.ok(renderer.includes("aksrt_render_captcha('ticket_create')"), 'create captcha mount missing');
|
||||
assert.ok(renderer.includes("aksrt_render_captcha('ticket_reply')"), 'reply captcha mount missing');
|
||||
});
|
||||
|
||||
test('ticket frontend assets are enqueued only when the panel exists', () => {
|
||||
const scripts = read('src/Core/ScriptsPart1b.php');
|
||||
assert.match(scripts, /\$has_tickets_panel = false;/, 'panel flag not initialised');
|
||||
assert.match(scripts, /\$has_tickets_panel = isset\(\$dashboard_tabs\['tickets'\]\);/, 'panel flag not derived');
|
||||
assert.ok(scripts.includes("'aksrt-tickets'"), 'ticket CSS handle missing');
|
||||
assert.ok(scripts.includes("'aksrt-ticket-center'"), 'ticket JS handle missing');
|
||||
assert.ok(scripts.includes('/assets/js/ticket-center.js'), 'ticket JS path missing');
|
||||
assert.ok(scripts.includes('/assets/css/routes/tickets.css'), 'ticket CSS path missing');
|
||||
// 两个入队都必须在 $has_tickets_panel 条件内。
|
||||
const cssIndex = scripts.indexOf("'aksrt-tickets'");
|
||||
const jsIndex = scripts.indexOf("'aksrt-ticket-center'");
|
||||
assert.ok(scripts.lastIndexOf('if ($has_tickets_panel) {', cssIndex) > scripts.indexOf("'aksrt-orders'"),
|
||||
'ticket CSS must be gated on its own panel flag');
|
||||
assert.ok(jsIndex > cssIndex, 'ticket JS should be enqueued alongside its CSS');
|
||||
|
||||
const localize = read('src/Core/ScriptsPart1c.php');
|
||||
assert.ok(localize.includes("'ticket' => (function () {"), 'localize key missing');
|
||||
assert.ok(!localize.includes("'afterSale'"), 'stale afterSale localize must be gone');
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 工单表结构契约:
|
||||
* 1) 6 张 aksrt_ticket* 表全部由 DBMigratorPart2 建出,且被 migrate_ticket_tables() 调用;
|
||||
* 2) SCHEMA_VERSION 已升到 3.0.0(dbDelta 只在版本变化时重跑);
|
||||
* 3) aksrt_order_aftersale 已从 schema 与表名映射中彻底移除;
|
||||
* 4) 状态与部门 seeds 幂等,默认状态被标记为不可改删。
|
||||
*/
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
|
||||
|
||||
const TABLES = [
|
||||
'aksrt_ticket',
|
||||
'aksrt_ticket_reply',
|
||||
'aksrt_ticket_status',
|
||||
'aksrt_ticket_type',
|
||||
'aksrt_ticket_prereply',
|
||||
'aksrt_ticket_note',
|
||||
];
|
||||
|
||||
test('schema version is bumped so dbDelta re-runs', () => {
|
||||
const migrator = read('src/Core/DBMigrator.php');
|
||||
assert.match(migrator, /const SCHEMA_VERSION = '3\.0\.0';/, 'SCHEMA_VERSION must be 3.0.0');
|
||||
assert.ok(migrator.includes('use DBMigratorTicket;'), 'DBMigratorTicket trait not composed');
|
||||
// 版本注释要说明 3.0.0 做了什么,否则后人看不懂这次升版的原因。
|
||||
assert.match(migrator, /3\.0\.0: 售后(order_aftersale)整体替换为工单系统/);
|
||||
});
|
||||
|
||||
test('order_aftersale is gone from schema and the table map', () => {
|
||||
const part2 = read('src/Core/DBMigratorPart2.php');
|
||||
const part3 = read('src/Core/DBMigratorPart3.php');
|
||||
|
||||
assert.ok(!part2.includes('aksrt_order_aftersale'), 'order_aftersale CREATE must be removed');
|
||||
assert.ok(!part3.includes("'order_aftersale'"), 'order_aftersale table map entry must be removed');
|
||||
assert.ok(!part3.includes('aksrt_order_aftersale'), 'order_aftersale table name must be removed');
|
||||
});
|
||||
|
||||
test('six ticket tables are declared and the migrator is invoked', () => {
|
||||
const part2 = read('src/Core/DBMigratorPart2.php');
|
||||
// Part2 只负责调用;DDL 集中在 DBMigratorTicket,避免 run() 继续膨胀。
|
||||
assert.ok(part2.includes('self::migrate_ticket_tables($prefix, $charset_collate);'),
|
||||
'migrate_ticket_tables() must be called from run()');
|
||||
assert.ok(!part2.includes('aksrt_order_aftersale'), 'Part2 must not carry the old table');
|
||||
|
||||
const ticket = read('src/Core/DBMigratorTicket.php');
|
||||
for (const table of TABLES) {
|
||||
assert.ok(ticket.includes(`CREATE TABLE {$prefix}${table} (`), `${table} DDL missing in trait`);
|
||||
}
|
||||
assert.ok(ticket.includes('self::seed_ticket_statuses($prefix);'), 'status seeds not invoked');
|
||||
assert.ok(ticket.includes('self::seed_ticket_types($prefix);'), 'type seeds not invoked');
|
||||
});
|
||||
|
||||
test('ticket table map exposes all six names', () => {
|
||||
const part3 = read('src/Core/DBMigratorPart3.php');
|
||||
for (const name of ['ticket', 'ticket_reply', 'ticket_status', 'ticket_type', 'ticket_prereply', 'ticket_note']) {
|
||||
assert.ok(part3.includes(`'${name}'`), `table map missing ${name}`);
|
||||
assert.ok(part3.includes(`'aksrt_${name}'`), `table map missing aksrt_${name}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('ticket columns cover the state machine and optional order link', () => {
|
||||
const ticket = read('src/Core/DBMigratorTicket.php');
|
||||
|
||||
// 工单号对外唯一;状态是可自定义的 key;订单关联可空(默认空串)。
|
||||
assert.match(ticket, /UNIQUE KEY uniq_ticket_num \(ticket_num\)/);
|
||||
assert.match(ticket, /status VARCHAR\(24\) NOT NULL DEFAULT 'pending'/);
|
||||
assert.match(ticket, /order_number VARCHAR\(32\) NOT NULL DEFAULT ''/);
|
||||
assert.match(ticket, /KEY idx_order \(order_number\)/);
|
||||
|
||||
// 沟通记录独立成行,支持引用回复。
|
||||
assert.match(ticket, /author_type VARCHAR\(16\) NOT NULL DEFAULT 'user'/);
|
||||
assert.match(ticket, /quote_reply_id BIGINT UNSIGNED NOT NULL DEFAULT 0/);
|
||||
|
||||
// 状态表带完结/默认两个开关,默认状态不可改删由服务层兜住。
|
||||
assert.match(ticket, /status_key VARCHAR\(24\) NOT NULL/);
|
||||
assert.match(ticket, /UNIQUE KEY uniq_status_key \(status_key\)/);
|
||||
assert.match(ticket, /is_closed TINYINT\(1\) NOT NULL DEFAULT 0/);
|
||||
assert.match(ticket, /is_default TINYINT\(1\) NOT NULL DEFAULT 0/);
|
||||
});
|
||||
|
||||
test('status and department seeds are idempotent', () => {
|
||||
const ticket = read('src/Core/DBMigratorTicket.php');
|
||||
|
||||
// 按 key / name 查重后跳过,重复执行不会写重复行。
|
||||
assert.match(ticket, /\$exists = \$wpdb->get_col\("SELECT status_key FROM \{\$table\}"\)/);
|
||||
assert.match(ticket, /if \(in_array\(\$key, \$exists, true\)\) \{\s*\n\s*continue;/);
|
||||
assert.match(ticket, /\$exists = \$wpdb->get_col\("SELECT name FROM \{\$table\}"\)/);
|
||||
assert.match(ticket, /if \(in_array\(\$label, \$exists, true\)\) \{\s*\n\s*continue;/);
|
||||
|
||||
// 五个内置状态与五个部门。
|
||||
for (const key of ['pending', 'handling', 'user_replied', 'admin_replied', 'closed']) {
|
||||
assert.ok(ticket.includes(`['${key}',`), `status seed ${key} missing`);
|
||||
}
|
||||
for (const label of ['技术支持', '账号问题', '财务问题', '产品建议', '其他']) {
|
||||
assert.ok(ticket.includes(`'${label}'`), `department seed ${label} missing`);
|
||||
}
|
||||
// 默认状态是「待接单」,且是完结态的补集。
|
||||
assert.match(ticket, /\['pending', '待接单', '#f59e0b', 0, 1\]/);
|
||||
assert.match(ticket, /\['closed', '已关闭', '#6b7280', 1, 0\]/);
|
||||
});
|
||||
|
||||
test('TicketStatusService seeds mirror the migrator and guard the default row', () => {
|
||||
const service = read('src/Ticket/TicketStatusService.php');
|
||||
for (const key of ['pending', 'handling', 'user_replied', 'admin_replied', 'closed']) {
|
||||
assert.ok(service.includes(`KEY_${key.toUpperCase()}`), `KEY_${key.toUpperCase()} missing`);
|
||||
}
|
||||
// 状态机只挡确定非法的组合,其余交给「目标状态必须存在」。
|
||||
assert.match(service, /if \(\$from === self::KEY_PENDING && \$to === self::KEY_CLOSED\) \{\s*\n\s*return false;/);
|
||||
assert.match(service, /if \(self::is_closed\(\$from\)\) \{\s*\n\s*return false;/);
|
||||
// 颜色只接受 #rgb / #rrggbb。
|
||||
assert.match(service, /'#6b7280'/);
|
||||
assert.match(service, /preg_match\('\/\^#\(\?:\[0-9a-f\]\{3\}\|\[0-9a-f\]\{6\}\)\$\/'/);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"generated_at": "2026-10-21T13:04:45+00:00",
|
||||
"generated_at": "2026-10-25T15:29:20+00:00",
|
||||
"errors": [],
|
||||
"warnings": [
|
||||
"customPage tools-update section 'tools/update' not in PHP schema (ok if pure operational page)",
|
||||
@@ -62,7 +62,7 @@
|
||||
"enum incomplete parse for 'weixingzh_gzh_type' (php subset of js): php=[service] js=[service,subscribe]"
|
||||
],
|
||||
"info": [
|
||||
"PHP schema domains: global, page_display, post_list, permissions, users, payment, extensions, tools, ExtensionsAudit, ExtensionsIP, ExtensionsPush, ExtensionsSecurity, ExtensionsSystem, ToolsBackup, ToolsHealth, ToolsImportExport",
|
||||
"PHP schema domains: global, page_display, post_list, permissions, users, payment, extensions, tools, ExtensionsAudit, ExtensionsIP, ExtensionsPush, ExtensionsSecurity, ExtensionsSystem, ToolsBackup, ToolsHealth, ToolsImportExport, PostListList",
|
||||
"Menu pages discovered: 61",
|
||||
"admin build ok; chunks=30"
|
||||
],
|
||||
@@ -112,8 +112,7 @@
|
||||
"thumb"
|
||||
],
|
||||
"files": [
|
||||
"src/Admin/Schema/SchemaPostList.php",
|
||||
"src/Admin/Schema/PostList/SchemaPostListList.php"
|
||||
"src/Admin/Schema/SchemaPostList.php"
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
@@ -138,16 +137,17 @@
|
||||
"oauth",
|
||||
"report",
|
||||
"sms",
|
||||
"ticket",
|
||||
"verify"
|
||||
],
|
||||
"files": [
|
||||
"src/Admin/Schema/SchemaUsers.php"
|
||||
"src/Admin/Schema/SchemaUsers.php",
|
||||
"src/Admin/Schema/Users/SchemaUsersTicket.php"
|
||||
]
|
||||
},
|
||||
"payment": {
|
||||
"sections": [
|
||||
"affiliate",
|
||||
"after_sale",
|
||||
"gateway",
|
||||
"knowledge",
|
||||
"recharge",
|
||||
@@ -244,6 +244,14 @@
|
||||
"files": [
|
||||
"src/Admin/Schema/SchemaToolsImportExport.php"
|
||||
]
|
||||
},
|
||||
"PostListList": {
|
||||
"sections": [
|
||||
"list"
|
||||
],
|
||||
"files": [
|
||||
"src/Admin/Schema/PostList/SchemaPostListList.php"
|
||||
]
|
||||
}
|
||||
},
|
||||
"page_count": 61,
|
||||
@@ -252,7 +260,7 @@
|
||||
"entry": true,
|
||||
"missing_chunks": [],
|
||||
"chunks": [
|
||||
"SchemaPage.CD_W16f3.js",
|
||||
"SchemaPage.DCJ4QdH8.js",
|
||||
"vendor-vue.BqIbVHit.js",
|
||||
"FaIcon.BMA0TNWc.js",
|
||||
"AksMediaField.C9RRDn2V.js",
|
||||
@@ -268,8 +276,8 @@
|
||||
"FloatingButtonsSettings.DM1NXEcr.js",
|
||||
"PageHeader.UhySXCpq.js",
|
||||
"FooterTabbarSettings.8D3P8zNv.js",
|
||||
"PostPageSettings.BjFJtDVl.js",
|
||||
"CommentSettings.DxYhkUD2.js",
|
||||
"PostPageSettings.DfkMmr7s.js",
|
||||
"CommentSettings.ke9PbifA.js",
|
||||
"PermissionsCapabilities.CnQfjtxC.js",
|
||||
"CmsCredits.Dh2b9uHb.js",
|
||||
"ThemeInfoPage.zYktV7qz.js",
|
||||
|
||||
@@ -19,7 +19,7 @@ Domain folders were frozen after the root→domain move. Theme types then migrat
|
||||
| User center/home/badges/level/metrics/notifications/follow | `User` | Account surface |
|
||||
| Credits, balance wallet, signin, invite, points-free | `Credits` | Points + cash wallet |
|
||||
| Download tokens/state, content pay, VIP, visibility | `Content` | Post-centric paid content |
|
||||
| Orders, gateways, coupon, charge-card, revenue, creator-revenue, withdrawal, rebate, discount, after-sale, shipping, wechat-notify | `Payment` | Commerce + settlement |
|
||||
| Orders, gateways, coupon, charge-card, revenue, creator-revenue, withdrawal, rebate, discount, shipping, wechat-notify | `Payment` | Commerce + settlement |
|
||||
| Shop services/meta/reviews | `Shop` | Product catalog / cart |
|
||||
| SMS stack | `Sms` | Messaging channel |
|
||||
| AI runtime (admin AI controller stays in `Admin`) | `Ai` | Generation helpers |
|
||||
|
||||
@@ -197,13 +197,21 @@ function collect_php_schema_sections(string $themeRoot): array {
|
||||
}
|
||||
}
|
||||
if ($domain === null) {
|
||||
// Fallback: first token / PSR-4 Schema* short names.
|
||||
if (str_contains($base, 'PostList')) {
|
||||
$domain = 'post_list';
|
||||
} else {
|
||||
$domain = str_replace('-', '_', $parts[0] ?? '');
|
||||
// Fallback: 叶子文件放在 Schema/{Domain}/ 子目录时,用目录名推断域,
|
||||
// 覆盖 SchemaUsersTicket / SchemaPostListList 这类「域+分区」短类名。
|
||||
$dir = basename(dirname($file));
|
||||
if ($dir !== '' && strcasecmp($dir, 'Schema') !== 0) {
|
||||
foreach (array_keys($primary) as $candidate) {
|
||||
if (strcasecmp(str_replace('_', '-', $candidate), $dir) === 0) {
|
||||
$domain = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($domain === null) {
|
||||
$domain = str_replace('-', '_', $parts[0] ?? '');
|
||||
}
|
||||
// Leaf under Schema/PostList/ with key list → post_list/list
|
||||
if ($domain === 'post_list' && $section === 'list') {
|
||||
$register($domain, $section, $file);
|
||||
|
||||
Reference in new issue
Block a user