diff --git a/README.md b/README.md
index 047132fc..5e209590 100644
--- a/README.md
+++ b/README.md
@@ -38,7 +38,7 @@ AKSRT-theme/
│ ├── Extensions/ # MeiliSearch / 推送 / IP / 内容审核
│ ├── Frontend/ # 前端渲染辅助
│ ├── Messages/ # 站内信 / 公告 / 举报
-│ ├── Payment/ # 订单 / 网关 / 售后 / 物流 / 分成
+│ ├── Payment/ # 订单 / 网关 / 物流 / 分成
│ │ └── Gateways/ # 支付宝 / 微信 / PayPal / Stripe
│ ├── Settings/ # 设置门面 + 运行时 delegates
│ ├── Shop/ # 商城服务 + meta box
diff --git a/assets/admin/src/constants/sections/payment.js b/assets/admin/src/constants/sections/payment.js
index 80e34b74..e48f90b8 100644
--- a/assets/admin/src/constants/sections/payment.js
+++ b/assets/admin/src/constants/sections/payment.js
@@ -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',
- }),
],
}
diff --git a/assets/admin/src/constants/sections/users.js b/assets/admin/src/constants/sections/users.js
index 01eaa2d4..1c74de48 100644
--- a/assets/admin/src/constants/sections/users.js
+++ b/assets/admin/src/constants/sections/users.js
@@ -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',
+ }),
],
}
diff --git a/assets/admin/src/router/index.js b/assets/admin/src/router/index.js
index e4e24dcc..db3966b4 100644
--- a/assets/admin/src/router/index.js
+++ b/assets/admin/src/router/index.js
@@ -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 = [
diff --git a/assets/admin/src/schema/sections/payment/afterSale.js b/assets/admin/src/schema/sections/payment/afterSale.js
deleted file mode 100644
index 660a9fd6..00000000
--- a/assets/admin/src/schema/sections/payment/afterSale.js
+++ /dev/null
@@ -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: '
抱歉,您的订单已超过售后申请时效
',
- 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: '抱歉,您的订单已超过售后申请时效
', 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张)' },
- ],
- },
- ],
-}
diff --git a/assets/admin/src/schema/sections/payment/index.js b/assets/admin/src/schema/sections/payment/index.js
index ab4aa853..18a612a4 100644
--- a/assets/admin/src/schema/sections/payment/index.js
+++ b/assets/admin/src/schema/sections/payment/index.js
@@ -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,
}
diff --git a/assets/admin/src/schema/sections/permissions/index.js b/assets/admin/src/schema/sections/permissions/index.js
index d0bfb970..ca3626e2 100644
--- a/assets/admin/src/schema/sections/permissions/index.js
+++ b/assets/admin/src/schema/sections/permissions/index.js
@@ -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' },
],
},
],
diff --git a/assets/admin/src/schema/sections/users/index.js b/assets/admin/src/schema/sections/users/index.js
index c5957f64..a97c4a2b 100644
--- a/assets/admin/src/schema/sections/users/index.js
+++ b/assets/admin/src/schema/sections/users/index.js
@@ -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,
}
diff --git a/assets/admin/src/schema/sections/users/ticket.js b/assets/admin/src/schema/sections/users/ticket.js
new file mode 100644
index 00000000..31ca3ed4
--- /dev/null
+++ b/assets/admin/src/schema/sections/users/ticket.js
@@ -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: '提交工单后请耐心等待处理
',
+ 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: '提交工单后请耐心等待处理
', 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: '同一工单两次催单之间的最小间隔' },
+ ],
+ },
+ ],
+}
diff --git a/assets/css/build/public.css b/assets/css/build/public.css
index e26dcc35..4fd5ef24 100644
--- a/assets/css/build/public.css
+++ b/assets/css/build/public.css
@@ -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.
diff --git a/assets/css/build/public.min.css b/assets/css/build/public.min.css
index 59015d23..65796173 100644
--- a/assets/css/build/public.min.css
+++ b/assets/css/build/public.min.css
@@ -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-grad-ocean-1: #4facfe;--aksrt-grad-ocean-2: #00f2fe;--aksrt-grad-forest-1: #43e97b;--aksrt-grad-forest-2: #38f9d7;--aksrt-grad-night-1: #a18cd1;--aksrt-grad-night-2: #fbc2eb;--aksrt-grad-fire-1: #f83600;--aksrt-grad-fire-2: #f9d423;--aksrt-toggle-w: 44px;--aksrt-toggle-h: 24px;--aksrt-stepper-h: 36px;--aksrt-badge-h: 22px;--text-1: var(--color-ink);--text-2: var(--color-ink-2);--text-3: var(--color-ink-3);--border: var(--color-line);--color-text-1: var(--color-ink);--color-text-2: var(--color-ink-2);--color-text-3: var(--color-ink-3);--color-primary-bg: var(--color-primary-softer);--color-danger: var(--color-error)}@supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))){:root{--blur-bg: var(--surface-1);--header-bg: var(--surface-1)}}@supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))){html.dark,body.dark-theme{--blur-bg: #37383a;--header-bg: #37383a}}body.modal-open,body.dialog-open{--blur-bg: var(--surface-1);--header-bg: var(--surface-1)}body.modal-open .site-nav,body.modal-open .footer-tabbar,body.modal-open .float-right .float-btn,body.dialog-open .site-nav,body.dialog-open .footer-tabbar,body.dialog-open .float-right .float-btn{backdrop-filter:none!important;-webkit-backdrop-filter:none!important}html.dark body.modal-open,html.dark body.dialog-open,body.dark-theme.modal-open,body.dark-theme.dialog-open{--blur-bg: #37383a;--header-bg: #37383a}@media(min-width:992px){:root{--container: 960px}}@media(min-width:1200px){:root{--container: 1140px}}@media(min-width:1400px){:root{--container: 1200px}}html.dark,body.dark-theme{--color-ink: #f8fafc;--color-ink-2: #e5eef7;--color-ink-3: #b4b6bb;--color-ink-4: #888a8f;--surface-page: #292a2d;--surface-1: #323335;--surface-2: #2d2e31;--surface-3: #3a3c42;--surface-4: #26282d;--color-line: rgba(114, 114, 114, .1);--color-line-soft: rgba(184, 184, 184, .02);--color-line-stronger: rgba(255, 255, 255, .12);--color-primary-soft: rgba(240, 68, 148, .16);--color-primary-softer: rgba(240, 68, 148, .08);--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);--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);--shadow-xl: 0 0 10px rgba(24, 24, 24, .18);--shadow-xs: 0 1px 2px rgba(0, 0, 0, .18);--shadow-sm: 0 0 10px rgba(24, 24, 24, .1);--shadow: 0 0 10px rgba(24, 24, 24, .14);--shadow-lg: 0 14px 36px rgba(0, 0, 0, .32);--shadow-drawer: 0 20px 60px rgba(0, 0, 0, .6);--blur-bg: rgba(50, 51, 53, .8);--float-btn-bg: rgba(70, 70, 70, .4);--header-bg: var(--blur-bg);--footer-bg: var(--surface-1);--footer-color: var(--color-ink-4);--shadow-md: var(--shadow);--aksrt-rose-soft: rgba(244, 63, 94, .18);--aksrt-blue-soft: rgba(59, 130, 246, .18);--aksrt-green-soft: rgba(16, 185, 129, .18);--aksrt-orange-soft:rgba(249, 115, 22, .18);--aksrt-purple-soft:rgba(139, 92, 246, .18);--aksrt-cyan-soft: rgba(6, 182, 212, .18);--aksrt-yellow-soft:rgba(234, 179, 8, .18);--aksrt-red-soft: rgba(239, 68, 68, .18);--aksrt-teal-soft: rgba(20, 184, 166, .18);--aksrt-indigo-soft:rgba(99, 102, 241, .18);--aksrt-pink-soft: rgba(236, 72, 153, .18)}html.dark .site-nav,body.dark-theme .site-nav{border-bottom-color:var(--color-line);box-shadow:none}html.dark .footer-tabbar,body.dark-theme .footer-tabbar{background:#323335cc;box-shadow:0 -1px 3px #0003}html.dark .n-drawer,html.dark .n-modal,html.dark .n-dialog,body.dark-theme .n-drawer,body.dark-theme .n-modal,body.dark-theme .n-dialog{background:var(--surface-1)}html.dark .post-card,html.dark .widget,html.dark .aksrt-theme-box,html.dark .theme-box,body.dark-theme .post-card,body.dark-theme .widget,body.dark-theme .aksrt-theme-box,body.dark-theme .theme-box{border-color:transparent}*,*:before,*:after{box-sizing:border-box}html{font-size:16px;scroll-behavior:smooth;-webkit-text-size-adjust:100%;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;overflow-x:hidden;text-rendering:optimizeLegibility}body{margin:0;font-family:var(--font-body);font-size:var(--text-base);line-height:var(--leading-normal);background-color:var(--surface-page);color:var(--color-ink-2);transition:background-color .25s ease,color .25s ease;overflow-x:hidden}img,picture,video,canvas{display:block;max-width:100%;height:auto}input,button,textarea,select{font:inherit;color:inherit}button{background:none;border:0;padding:0;cursor:pointer}a{color:inherit;text-decoration:none;transition:color var(--dur-fast) var(--ease)}a:hover{color:var(--color-primary)}:focus{outline:none}::selection{background:var(--color-primary-soft);color:var(--color-ink)}.screen-reader-text,.sr-only{position:absolute!important;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.screen-reader-text:focus,.sr-only:focus{position:fixed!important;top:16px;left:16px;width:auto;height:auto;padding:8px 16px;background:var(--surface-1);color:var(--color-ink);z-index:100000;clip:auto;box-shadow:var(--shadow-lg);border-radius:var(--radius)}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important;scroll-behavior:auto!important}}h1,h2,h3,h4,h5,h6{font-weight:700;margin:20px 0 10px;color:var(--key-color, #333)}h1{font-size:22px}h2{font-size:20px}h3{font-size:18px}h4{font-size:16px}h5{font-size:15px}h6{font-size:14px}.wp-posts-content,.entry-content,.prose{font-size:16px;line-height:2;color:var(--main-color, #4e5358);min-height:90px}.wp-posts-content h1,.wp-posts-content h2,.wp-posts-content h3,.wp-posts-content h4,.wp-posts-content h5,.entry-content h1,.entry-content h2,.entry-content h3,.entry-content h4,.entry-content h5,.prose h1,.prose h2,.prose h3,.prose h4,.prose h5{font-weight:700;margin:20px 0 10px;color:var(--key-color, #333)}.wp-posts-content h1,.entry-content h1,.prose h1{font-size:22px}.wp-posts-content h2,.entry-content h2,.prose h2{font-size:20px}.wp-posts-content h3,.entry-content h3,.prose h3{font-size:18px}.wp-posts-content h4,.entry-content h4,.prose h4{font-size:16px}.wp-posts-content p,.entry-content p,.prose p{margin:0 0 20px;word-wrap:break-word}.wp-posts-content a,.entry-content a,.prose a{color:var(--focus-color, #f04494);text-decoration:none}.wp-posts-content a:hover,.entry-content a:hover,.prose a:hover{color:var(--focus-color, #f04494)}.wp-posts-content img,.entry-content img,.prose img{max-width:100%;height:auto;display:block;margin:auto}.wp-posts-content code:not(pre code),.entry-content code:not(pre code),.prose code:not(pre code){color:#ff3c98;background:#fd999933;font-family:var(--font-mono, monospace);font-size:.9em}.wp-posts-content pre,.entry-content pre,.prose pre{position:relative;background:#282c34;color:#abb2bf;padding:15px;border-radius:var(--main-radius, 8px);overflow-x:auto;font-size:14px;line-height:1.5;margin:0 0 20px}.wp-posts-content pre code,.entry-content pre code,.prose pre code{color:inherit;background:none;padding:0;font-size:inherit}.wp-posts-content table,.entry-content table,.prose table{font-size:14px;width:100%;border-collapse:collapse;margin:0 0 20px}.wp-posts-content th,.wp-posts-content td,.entry-content th,.entry-content td,.prose th,.prose td{border:1px solid var(--main-border-color, rgba(50, 50, 50, .06));padding:8px}.wp-posts-content th,.entry-content th,.prose th{background:var(--muted-border-color, rgba(0, 0, 0, .03));font-weight:700}.wp-posts-content tr:hover,.entry-content tr:hover,.prose tr:hover{background-color:#f044940d}.wp-posts-content ul,.wp-posts-content ol,.entry-content ul,.entry-content ol,.prose ul,.prose ol{margin:10px 0 20px 15px;padding-left:15px}.wp-posts-content li,.entry-content li,.prose li{margin-bottom:10px}.wp-posts-content blockquote,.entry-content blockquote,.prose blockquote{padding:15px 20px;margin:0 0 20px;border-left:4px solid var(--main-border-color, rgba(50, 50, 50, .06));background:var(--muted-border-color, rgba(0, 0, 0, .03));font-size:16px;color:var(--muted-color, #777);border-radius:0}.wp-posts-content blockquote p:last-child,.entry-content blockquote p:last-child,.prose blockquote p:last-child{margin-bottom:0}hr{border-color:var(--main-border-color, rgba(50, 50, 50, .06))}strong,b{font-weight:700}.btn,.btn-primary,.btn-outline,.btn-ghost,.btn-danger{--btn-h: 34px;--btn-px: 14px;display:inline-flex;align-items:center;justify-content:center;gap:6px;height:var(--btn-h);padding:0 var(--btn-px);font-size:var(--text-sm);font-weight:400;line-height:1.44;border:1px solid transparent;border-radius:var(--radius-btn);cursor:pointer;white-space:nowrap;user-select:none;text-decoration:none;box-shadow:none;transition:opacity .15s ease,background .15s ease,border-color .15s ease,color .15s ease,box-shadow .15s ease}.btn-sm{--btn-h: 28px;--btn-px: 8px;font-size:var(--text-xs)}.btn-lg{--btn-h: 44px;--btn-px: 18px;font-size:var(--text-md)}.btn{background:#8888881a;color:#888;border-color:transparent}.btn:hover{opacity:.8;color:#888}.btn-primary{background:var(--color-primary);color:var(--color-on-primary);border-color:var(--color-primary);box-shadow:none}.btn-primary:hover{opacity:.8;color:var(--color-on-primary);background:var(--color-primary);border-color:var(--color-primary);transform:none;box-shadow:none}.btn-primary:active{opacity:1;box-shadow:inset 0 2px 3px #00000014}.btn-outline{background:transparent;color:var(--color-ink-2);border-color:var(--color-ink-2)}.btn-outline:hover{opacity:.8;background:transparent;border-color:var(--color-ink-2);color:var(--color-ink-2)}.btn-ghost{background:transparent;color:var(--color-ink-2);border-color:transparent}.btn-ghost:hover{background:#8888881a;opacity:.8}.btn-danger{background:var(--color-error);color:#fff;border-color:var(--color-error)}.btn-danger:hover{opacity:.8;color:#fff}.btn:disabled,.btn-primary:disabled,.btn-outline:disabled,.btn-ghost:disabled,.btn-danger:disabled{opacity:.5;cursor:not-allowed;pointer-events:none}.btn-icon{width:var(--btn-h);padding:0;justify-content:center}.btn-icon.btn-sm{width:28px}.btn-icon.btn-lg{width:44px}.btn-block{display:block;width:100%}.but,.badg{display:inline-block;vertical-align:middle;padding:.3em .6em;text-align:center;font-weight:400;line-height:1.44;border-radius:4px;border:1px solid var(--this-border);box-shadow:var(--this-shadow);background:var(--this-bg);color:var(--this-color);--main-color: var(--this-color);--this-bg: rgba(136, 136, 136, .1);--this-border: transparent;--this-shadow: none;--this-color: #888;text-shadow:0 0 0;transition:.15s;cursor:pointer}.but a{color:inherit}.but .icon,.but>.fa:not(.fa-angle-right),.but>i[class*=fa-]:not(.fa-angle-right){margin-right:.25em}.but.block{display:block}.but:focus,.but:hover{opacity:.8;color:var(--this-color)}.but:active{--this-shadow: inset 0 2px 3px rgba(0, 0, 0, .08)}.but.radius,.badg.radius,.radius>.but{border-radius:50px;padding:.3em 1em}.but.main-radius,.badg.main-radius{border-radius:var(--main-radius)}.but.mini-radius,.badg.mini-radius{border-radius:var(--mini-radius)}.but.hollow,.badg.hollow{background:transparent;--this-border: var(--this-color)}.but.hollow:hover{background:var(--this-bg)}.badg.hollow[class*=b-],.but.hollow[class*=b-]{background:0 0;--this-border: var(--this-bg);--this-color: var(--this-bg)}.but.hollow[class*=b-]:hover{color:#fff}.but.hollow.c-white:hover{background:0 0;--this-color: var(--focus-color);color:var(--focus-color)}.circular,.badg.cir,.but.cir{padding:0;border-radius:5em;--this-size: 1.8em;display:inline-flex;justify-content:center;align-items:center;height:var(--this-size);min-width:var(--this-size)}.circular.radius4,.badg.cir.radius4,.but.cir.radius4{border-radius:4px}.but.cir svg.icon,.but.cir>.fa,.but.cir>.fa:not(.fa-angle-right),.but.cir>i[class*=fa-]:not(.fa-angle-right){margin:0}.but-group{display:inline-flex}.but-group>.but{border-radius:0;margin:0}.but-group>.but:first-of-type{border-radius:4px 0 0 4px}.but-group>.but:last-of-type{border-radius:0 4px 4px 0}.but-average{display:flex;border-radius:4px;overflow:hidden}.but-average.inline{display:inline-flex}.but-average.radius{border-radius:100px}.but-average.main-radius{border-radius:var(--main-radius)}.but-average.mini-radius{border-radius:var(--mini-radius)}.but-average>.but{margin:0!important;border-radius:0;flex:auto}.but-average>.but.active{--this-bg: var(--focus-color-opacity1);--this-color: var(--focus-color)}.but.but-sm{font-size:.9em;padding:.1em .4em;vertical-align:.05em}.but.badg-sm,.badg.badg-sm{font-size:.8em;padding:0 .2em;vertical-align:.1em;min-width:1.6em}.badg.badg-sm.cir{--this-size: 1.6em}.padding-lg,.badg.badg-lg,.but.padding-lg{padding:.5em 2em}.active.badg,.active>.badg:not(.vip-tag){--this-bg: var(--focus-color);--this-color: #fff}.active.badg.hollow,.active>.hollow:not(.vip-tag){--this-color: var(--focus-color)}.toggle-radius{background:var(--this-bg);width:29px;height:29px;display:inline-block;text-align:center;border-radius:100%;position:relative;vertical-align:text-top;--this-bg: var(--main-border-color)}.toggle-radius .fa,.toggle-radius .icon,.toggle-radius>i{position:absolute;top:50%;right:0!important;left:0!important;margin:auto;transform:translateY(-50%);line-height:1;width:1em;height:1em;display:inline-flex;align-items:center;justify-content:center;text-align:center}.b-gray,.b-theme,.b-red,.b-yellow,.b-blue,.b-blue-2,.b-black,.b-cyan,.b-green,.b-purple,.jb-red,.jb-pink,.jb-yellow,.jb-blue,.jb-green,.jb-purple,.jb-vip1,.jb-vip2{color:var(--this-color);background:var(--this-bg);--this-color: #fff}.jb-red,.jb-pink,.jb-yellow,.jb-blue,.jb-green,.jb-purple,.jb-vip1,.jb-vip2{border:none}.b-theme{--this-bg: var(--focus-color)}.b-gray{--this-bg: #818b95}.b-red{--this-bg: #ff5473}.b-yellow{--this-bg: #ff6f06}.b-cyan{--this-bg: #08c4c1}.b-blue{--this-bg: #2997f7}.b-blue-2{--this-bg: #5c7cff}.b-green{--this-bg: #12b928}.b-purple{--this-bg: #d448f5}.b-black{--this-bg: #121517}.jb-red{--this-bg: linear-gradient(135deg, #fd7a64 10%, #fb2d2d 100%)}.jb-yellow{--this-bg: linear-gradient(135deg, #f59f54 10%, #ff6922 100%)}.jb-blue{--this-bg: linear-gradient(135deg, #59c3fb 10%, #268df7 100%)}.jb-green{--this-bg: linear-gradient(135deg, #60e464 10%, #5cb85b 100%)}.jb-purple{--this-bg: linear-gradient(135deg, #f98dfb 10%, #ea00f9 100%)}.jb-pink{--this-bg: linear-gradient(135deg, #ff5e7f 30%, #ff967e 100%)}.jb-vip1{--this-bg: linear-gradient(25deg, #eabe7b 10%, #f5e3c7 70%, #edc788 100%);--this-color: #866127}.jb-vip2{--this-bg: linear-gradient(317deg, #4d4c4c 30%, #7b7b7b 70%, #5f5c5c 100%);--this-color: #ddd}.c-theme,.c-gray,.c-white,.c-red,.c-red-2,.c-yellow,.c-blue,.c-blue-2,.c-green,.c-cyan,.c-purple{color:var(--this-color)}.c-theme{--this-color: var(--focus-color);--this-bg: var(--focus-color-opacity1)}.c-gray{--this-color: #888;--this-bg: rgba(136, 136, 136, .1)}.c-white{--this-color: #fff;--this-bg: rgba(255, 255, 255, .1)}.c-red{--this-color: #ff5473;--this-bg: rgba(255, 84, 115, .1)}.c-red-2{--this-color: #d6064c;--this-bg: rgba(194, 41, 46, .1)}.c-yellow{--this-color: #ff6f06;--this-bg: rgba(255, 111, 6, .1)}.c-blue{--this-color: #2997f7;--this-bg: rgba(41, 151, 247, .1)}.field{display:flex;flex-direction:column;gap:6px;margin-bottom:var(--space-4)}.field-label{font-size:var(--text-sm);font-weight:600;color:var(--color-ink)}.field-help{font-size:var(--text-xs);color:var(--color-ink-3)}.field-error{font-size:var(--text-xs);color:var(--color-error)}.input,.textarea,.select,input[type=text].form-input,input[type=email].form-input,input[type=password].form-input,input[type=search].form-input,input[type=url].form-input,input[type=tel].form-input,textarea.form-textarea,select.form-select{width:100%;height:34px;padding:6px 12px;font-size:14px;color:var(--color-ink);background:var(--surface-page);border:1px solid transparent;border-radius:var(--radius);transition:border-color var(--dur-fast) var(--ease),box-shadow var(--dur-fast) var(--ease)}.textarea,textarea.form-textarea{height:auto;min-height:120px;padding:12px;line-height:var(--leading-snug);resize:vertical}.input:hover,.textarea:hover,.select:hover{border-color:var(--color-primary)}.input:focus,.textarea:focus,.select:focus{outline:none;border-color:var(--color-primary);box-shadow:0 0 2px 3px #fd53a166}.input::placeholder,.textarea::placeholder{color:var(--color-ink-4)}.select,select.form-select{appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23777' d='M6 8L1 3h10z'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center;padding-right:32px}.checkbox,.radio{display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-size:var(--text-sm);color:var(--color-ink-2)}.checkbox input,.radio input{width:16px;height:16px;accent-color:var(--color-primary);cursor:pointer}.input-group{display:flex;align-items:stretch}.input-group .input{border-radius:var(--radius) 0 0 var(--radius)}.input-group .btn{border-radius:0 var(--radius) var(--radius) 0}.form-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:var(--space-4)}.form-actions{display:flex;gap:var(--space-3);margin-top:var(--space-5)}.alert{display:flex;align-items:flex-start;gap:10px;padding:12px 14px;margin-bottom:var(--space-4);font-size:var(--text-sm);line-height:var(--leading-snug);border-radius:var(--radius);border:1px solid transparent}.alert>svg{flex-shrink:0;width:18px;height:18px;margin-top:1px}.alert--error{color:#b91c1c;background:#ef444414;border-color:#ef44444d}.alert--warning{color:#b45309;background:#f59e0b14;border-color:#f59e0b4d}.alert--success{color:#047857;background:#10b98114;border-color:#10b9814d}.alert--info{color:#1d4ed8;background:#3b82f614;border-color:#3b82f64d}html.dark .alert--error{color:#fca5a5}html.dark .alert--warning{color:#fcd34d}html.dark .alert--success{color:#6ee7b7}html.dark .alert--info{color:#93c5fd}@keyframes aksrtFadeInUp{0%{opacity:0;transform:translateY(20px)}to{opacity:1;transform:none}}@keyframes aksrtFadeInDown{0%{opacity:0;transform:translateY(-20px)}to{opacity:1;transform:none}}@keyframes aksrtFadeInLeft{0%{opacity:0;transform:translate(-20px)}to{opacity:1;transform:none}}@keyframes aksrtFadeInRight{0%{opacity:0;transform:translate(20px)}to{opacity:1;transform:none}}@keyframes aksrtZoomIn{0%{opacity:0;transform:scale(.85)}to{opacity:1;transform:none}}@keyframes aksrtZoomInUp{0%{opacity:0;transform:scale(.85) translateY(20px)}to{opacity:1;transform:none}}@keyframes aksrtBounceIn{0%{opacity:0;transform:scale(.3)}50%{opacity:1;transform:scale(1.08)}70%{transform:scale(.95)}to{opacity:1;transform:none}}@keyframes aksrtBounce{0%,20%,50%,80%,to{transform:translateY(0)}40%{transform:translateY(-12px)}60%{transform:translateY(-6px)}}@keyframes aksrtPulse{0%{transform:scale(1)}50%{transform:scale(1.06)}to{transform:scale(1)}}@keyframes aksrtFlash{0%,50%,to{opacity:1}25%,75%{opacity:0}}@keyframes aksrtRubberBand{0%{transform:scale(1)}30%{transform:scaleX(1.25) scaleY(.75)}40%{transform:scaleX(.75) scaleY(1.25)}50%{transform:scaleX(1.15) scaleY(.85)}65%{transform:scaleX(.95) scaleY(1.05)}75%{transform:scaleX(1.05) scaleY(.95)}to{transform:scale(1)}}@keyframes aksrtShake{0%,to{transform:translate(0)}10%,30%,50%,70%,90%{transform:translate(-4px)}20%,40%,60%,80%{transform:translate(4px)}}@keyframes aksrtSwing{20%{transform:rotate(15deg)}40%{transform:rotate(-10deg)}60%{transform:rotate(5deg)}80%{transform:rotate(-5deg)}to{transform:none}}@keyframes aksrtTada{0%{transform:scale(1)}10%,20%{transform:scale(.9) rotate(-3deg)}30%,50%,70%,90%{transform:scale(1.1) rotate(3deg)}40%,60%,80%{transform:scale(1.1) rotate(-3deg)}to{transform:scale(1) rotate(0)}}@keyframes aksrtWobble{0%{transform:none}15%{transform:translate(-25%) rotate(-5deg)}30%{transform:translate(20%) rotate(3deg)}45%{transform:translate(-15%) rotate(-3deg)}60%{transform:translate(10%) rotate(2deg)}75%{transform:translate(-5%) rotate(-1deg)}to{transform:none}}@keyframes aksrtSlideInUp{0%{opacity:0;transform:translateY(100%)}to{opacity:1;transform:none}}@keyframes aksrtSlideInDown{0%{opacity:0;transform:translateY(-100%)}to{opacity:1;transform:none}}@keyframes aksrtSlideInLeft{0%{opacity:0;transform:translate(-100%)}to{opacity:1;transform:none}}@keyframes aksrtSlideInRight{0%{opacity:0;transform:translate(100%)}to{opacity:1;transform:none}}@keyframes aksrtHeartBeat{0%{transform:scale(1)}14%{transform:scale(1.3)}28%{transform:scale(1)}42%{transform:scale(1.3)}70%{transform:scale(1)}to{transform:scale(1)}}@keyframes aksrtJello{0%,to{transform:none}11%{transform:skew(-12.5deg) skewY(-12.5deg)}22%{transform:skew(6.25deg) skewY(6.25deg)}33%{transform:skew(-6.25deg) skewY(-6.25deg)}44%{transform:skew(3.125deg) skewY(3.125deg)}55%{transform:skew(-3.125deg) skewY(-3.125deg)}66%{transform:skew(1.5625deg) skewY(1.5625deg)}77%{transform:skew(-1.5625deg) skewY(-1.5625deg)}88%{transform:skew(.78125deg) skewY(.78125deg)}}@keyframes aksrtHeadShake{0%{transform:translate(0)}6.5%{transform:translate(-6px) rotateY(-9deg)}18.5%{transform:translate(5px) rotateY(7deg)}31.5%{transform:translate(-3px) rotateY(-5deg)}43.5%{transform:translate(2px) rotateY(3deg)}50%{transform:translate(0)}to{transform:translate(0)}}@keyframes aksrtBounceOut{0%{transform:scale(1)}25%{transform:scale(.95)}50%{transform:scale(1.1)}75%{transform:scale(.97)}to{transform:scale(1);opacity:0}}@keyframes aksrtZoomOut{0%{opacity:1;transform:none}to{opacity:0;transform:scale(.85)}}@keyframes aksrtBackInUp{0%{opacity:0;transform:translateY(30px) scale(.7)}80%{opacity:.8;transform:translateY(-5px) scale(1.02)}to{opacity:1;transform:none}}@keyframes aksrtBackInDown{0%{opacity:0;transform:translateY(-30px) scale(.7)}80%{opacity:.8;transform:translateY(5px) scale(1.02)}to{opacity:1;transform:none}}@keyframes aksrtFlip{0%{transform:perspective(400px) rotateY(90deg);opacity:0}40%{transform:perspective(400px) rotateY(-10deg)}70%{transform:perspective(400px) rotateY(10deg)}to{transform:perspective(400px) rotateY(0);opacity:1}}@keyframes aksrtFlipInX{0%{transform:perspective(400px) rotateX(90deg);opacity:0}40%{transform:perspective(400px) rotateX(-10deg)}70%{transform:perspective(400px) rotateX(10deg)}to{transform:perspective(400px) rotateX(0);opacity:1}}.aksrt-anim-fade-in{animation:aksrtFadeIn var(--dur-base, .4s) var(--ease-out, ease) both}.aksrt-anim-fade-up{animation:aksrtFadeInUp var(--dur-base, .4s) var(--ease-out, ease) both}.aksrt-anim-fade-down{animation:aksrtFadeInDown var(--dur-base, .4s) var(--ease-out, ease) both}.aksrt-anim-fade-left{animation:aksrtFadeInLeft var(--dur-base, .4s) var(--ease-out, ease) both}.aksrt-anim-fade-right{animation:aksrtFadeInRight var(--dur-base, .4s) var(--ease-out, ease) both}.aksrt-anim-zoom-in{animation:aksrtZoomIn var(--dur-base, .4s) var(--ease-out, ease) both}.aksrt-anim-zoom-in-up{animation:aksrtZoomInUp var(--dur-base, .4s) var(--ease-out, ease) both}.aksrt-anim-bounce-in{animation:aksrtBounceIn .6s var(--ease-out, ease) both}.aksrt-anim-bounce{animation:aksrtBounce 1s ease both}.aksrt-anim-pulse{animation:aksrtPulse 1s ease infinite}.aksrt-anim-flash{animation:aksrtFlash 1s ease both}.aksrt-anim-rubber-band{animation:aksrtRubberBand .8s ease both}.aksrt-anim-shake{animation:aksrtShake .5s ease both}.aksrt-anim-swing{animation:aksrtSwing .6s var(--ease-out, ease) both}.aksrt-anim-tada{animation:aksrtTada 1s ease both}.aksrt-anim-wobble{animation:aksrtWobble .8s ease both}.aksrt-anim-heart-beat{animation:aksrtHeartBeat 1.3s ease both}.aksrt-anim-jello{animation:aksrtJello .8s ease both}.aksrt-anim-head-shake{animation:aksrtHeadShake .8s ease both}.aksrt-anim-bounce-out{animation:aksrtBounceOut .5s ease both}.aksrt-anim-zoom-out{animation:aksrtZoomOut .3s ease both}.aksrt-anim-back-in-up{animation:aksrtBackInUp .5s var(--ease-out, ease) both}.aksrt-anim-back-in-down{animation:aksrtBackInDown .5s var(--ease-out, ease) both}.aksrt-anim-slide-up{animation:aksrtSlideInUp .5s var(--ease-out, ease) both}.aksrt-anim-slide-down{animation:aksrtSlideInDown .5s var(--ease-out, ease) both}.aksrt-anim-slide-left{animation:aksrtSlideInLeft .5s var(--ease-out, ease) both}.aksrt-anim-slide-right{animation:aksrtSlideInRight .5s var(--ease-out, ease) both}.aksrt-anim-flip{animation:aksrtFlip .6s var(--ease-out, ease) both}.aksrt-anim-flip-x{animation:aksrtFlipInX .6s var(--ease-out, ease) both}.aksrt-anim-fast{--dur-base: .2s}.aksrt-anim-slow{--dur-base: .6s}.aksrt-anim-delay-1{animation-delay:.1s}.aksrt-anim-delay-2{animation-delay:.2s}.aksrt-anim-delay-3{animation-delay:.3s}.aksrt-anim-delay-4{animation-delay:.4s}.aksrt-anim-delay-5{animation-delay:.5s}.aksrt-anim-delay-6{animation-delay:.6s}.aksrt-anim-delay-7{animation-delay:.7s}.aksrt-anim-delay-8{animation-delay:.8s}.aksrt-anim-infinite{animation-iteration-count:infinite}@media(prefers-reduced-motion:reduce){.aksrt-anim-fade-in,.aksrt-anim-fade-up,.aksrt-anim-fade-down,.aksrt-anim-fade-left,.aksrt-anim-fade-right,.aksrt-anim-zoom-in,.aksrt-anim-zoom-in-up,.aksrt-anim-bounce-in,.aksrt-anim-bounce,.aksrt-anim-pulse,.aksrt-anim-flash,.aksrt-anim-rubber-band,.aksrt-anim-shake,.aksrt-anim-swing,.aksrt-anim-tada,.aksrt-anim-wobble,.aksrt-anim-heart-beat,.aksrt-anim-jello,.aksrt-anim-head-shake,.aksrt-anim-bounce-out,.aksrt-anim-zoom-out,.aksrt-anim-back-in-up,.aksrt-anim-back-in-down,.aksrt-anim-slide-up,.aksrt-anim-slide-down,.aksrt-anim-slide-left,.aksrt-anim-slide-right,.aksrt-anim-flip,.aksrt-anim-flip-x{animation:none!important}}.aksrt-timeline{position:relative;padding-left:28px;margin:0;list-style:none}.aksrt-timeline:before{content:"";position:absolute;left:8px;top:4px;bottom:4px;width:2px;background:var(--color-line, rgba(50, 50, 50, .1));border-radius:1px}.aksrt-timeline__item{position:relative;padding-bottom:24px}.aksrt-timeline__item:last-child{padding-bottom:0}.aksrt-timeline__dot{position:absolute;left:-24px;top:4px;width:12px;height:12px;border-radius:50%;background:var(--color-primary);border:2px solid var(--surface-1, #fff);box-shadow:0 0 0 2px var(--color-primary-soft, rgba(240, 68, 148, .12));z-index:1}.aksrt-timeline__dot--outline{background:var(--surface-1, #fff);border:2px solid var(--color-primary);box-shadow:none}.aksrt-timeline__dot--muted{background:var(--color-ink-4, #999);box-shadow:0 0 0 2px var(--surface-2, #eee)}.aksrt-timeline__time{display:block;font-size:12px;color:var(--color-ink-4, #999);margin-bottom:4px}.aksrt-timeline__title{font-weight:600;color:var(--color-ink, #333);margin-bottom:4px}.aksrt-timeline__content{font-size:14px;color:var(--color-ink-3, #777);line-height:1.6}.aksrt-steps{display:flex;align-items:flex-start;gap:0;margin:0;padding:0;list-style:none;counter-reset:aksrt-step}.aksrt-steps__item{flex:1;position:relative;text-align:center;counter-increment:aksrt-step}.aksrt-steps__item:not(:last-child):after{content:"";position:absolute;top:16px;left:calc(50% + 20px);right:calc(-50% + 20px);height:2px;background:var(--color-line, rgba(50, 50, 50, .1))}.aksrt-steps__icon{display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:50%;background:var(--surface-2, #eee);color:var(--color-ink-3, #777);font-size:14px;font-weight:700;margin:0 auto 8px;position:relative;z-index:1}.aksrt-steps__item--active .aksrt-steps__icon{background:var(--color-primary);color:var(--color-on-primary, #fff)}.aksrt-steps__item--done .aksrt-steps__icon{background:var(--color-success);color:#fff}.aksrt-steps__item--done:not(:last-child):after{background:var(--color-success)}.aksrt-steps__label{font-size:13px;font-weight:600;color:var(--color-ink-3, #777)}.aksrt-steps__item--active .aksrt-steps__label{color:var(--color-primary)}.aksrt-steps__item--done .aksrt-steps__label{color:var(--color-success)}.aksrt-steps--vertical{flex-direction:column;gap:0;padding-left:20px}.aksrt-steps--vertical .aksrt-steps__item{text-align:left;padding-left:28px;padding-bottom:20px}.aksrt-steps--vertical .aksrt-steps__item:not(:last-child):after{top:34px;left:36px;right:auto;width:2px;height:calc(100% - 34px)}.aksrt-steps--vertical .aksrt-steps__icon{position:absolute;left:0;margin:0}.aksrt-img-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:4px;border-radius:var(--radius, 8px);overflow:hidden}.aksrt-img-grid img{width:100%;aspect-ratio:1;object-fit:cover;display:block;cursor:pointer;transition:transform .2s ease}.aksrt-img-grid img:hover{transform:scale(1.03)}.aksrt-img-grid--2{grid-template-columns:repeat(2,1fr)}.aksrt-img-grid--4{grid-template-columns:repeat(4,1fr)}@media(max-width:480px){.aksrt-img-grid--4{grid-template-columns:repeat(3,1fr)}}.aksrt-toggle{position:relative;display:inline-flex;align-items:center;cursor:pointer;user-select:none;gap:8px}.aksrt-toggle__input{position:absolute;opacity:0;width:0;height:0;pointer-events:none}.aksrt-toggle__track{position:relative;width:var(--aksrt-toggle-w, 44px);height:var(--aksrt-toggle-h, 24px);background:var(--surface-3, #e8e8e8);border-radius:var(--aksrt-toggle-h, 24px);transition:background .2s ease;flex-shrink:0}.aksrt-toggle__track:after{content:"";position:absolute;top:2px;left:2px;width:calc(var(--aksrt-toggle-h, 24px) - 4px);height:calc(var(--aksrt-toggle-h, 24px) - 4px);background:#fff;border-radius:50%;box-shadow:0 1px 3px #00000026;transition:transform .2s ease}.aksrt-toggle__input:checked+.aksrt-toggle__track{background:var(--color-primary)}.aksrt-toggle__input:checked+.aksrt-toggle__track:after{transform:translate(calc(var(--aksrt-toggle-w, 44px) - var(--aksrt-toggle-h, 24px)))}.aksrt-toggle__input:focus-visible+.aksrt-toggle__track{box-shadow:var(--focus-ring, 0 0 2px 3px rgba(253, 83, 161, .4))}.aksrt-toggle__input:disabled+.aksrt-toggle__track{opacity:.5;cursor:not-allowed}.aksrt-toggle__label{font-size:14px;color:var(--color-ink-2, #4e5358)}.aksrt-checkbox{display:inline-flex;align-items:center;gap:8px;cursor:pointer;user-select:none}.aksrt-checkbox__input{position:absolute;opacity:0;width:0;height:0;pointer-events:none}.aksrt-checkbox__box{position:relative;width:18px;height:18px;border:2px solid var(--color-line, rgba(50, 50, 50, .15));border-radius:4px;background:var(--surface-1, #fff);transition:background .15s ease,border-color .15s ease;flex-shrink:0}.aksrt-checkbox__box:after{content:"";position:absolute;top:2px;left:5px;width:5px;height:9px;border:2px solid #fff;border-top:none;border-left:none;transform:rotate(45deg) scale(0);transition:transform .15s ease}.aksrt-checkbox__input:checked+.aksrt-checkbox__box{background:var(--color-primary);border-color:var(--color-primary)}.aksrt-checkbox__input:checked+.aksrt-checkbox__box:after{transform:rotate(45deg) scale(1)}.aksrt-checkbox__input:focus-visible+.aksrt-checkbox__box{box-shadow:var(--focus-ring, 0 0 2px 3px rgba(253, 83, 161, .4))}.aksrt-checkbox__input:disabled+.aksrt-checkbox__box{opacity:.5;cursor:not-allowed}.aksrt-checkbox__label{font-size:14px;color:var(--color-ink-2, #4e5358)}.aksrt-stepper{display:inline-flex;align-items:center;border:1px solid var(--color-line, rgba(50, 50, 50, .1));border-radius:var(--radius, 8px);overflow:hidden;height:var(--aksrt-stepper-h, 36px)}.aksrt-stepper__btn{display:flex;align-items:center;justify-content:center;width:var(--aksrt-stepper-h, 36px);height:100%;background:var(--surface-2, #eee);color:var(--color-ink-2, #4e5358);border:none;cursor:pointer;font-size:16px;font-weight:600;transition:background .15s ease,color .15s ease;user-select:none}.aksrt-stepper__btn:hover{background:var(--surface-3, #e8e8e8);color:var(--color-ink, #333)}.aksrt-stepper__btn:disabled{opacity:.4;cursor:not-allowed}.aksrt-stepper__input{width:48px;height:100%;border:none;text-align:center;font-size:14px;font-weight:600;color:var(--color-ink, #333);background:var(--surface-1, #fff);-moz-appearance:textfield}.aksrt-stepper__input::-webkit-outer-spin-button,.aksrt-stepper__input::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}.aksrt-stepper__input:focus{outline:none;background:var(--color-primary-softer, rgba(240, 68, 148, .05))}.aksrt-avatar--xs{width:var(--aksrt-avatar-size-xs);height:var(--aksrt-avatar-size-xs)}.aksrt-avatar--sm{width:var(--aksrt-avatar-size-sm);height:var(--aksrt-avatar-size-sm)}.aksrt-avatar{width:var(--aksrt-avatar-size);height:var(--aksrt-avatar-size)}.aksrt-avatar--lg{width:var(--aksrt-avatar-size-lg);height:var(--aksrt-avatar-size-lg)}.aksrt-avatar--xl{width:var(--aksrt-avatar-size-xl);height:var(--aksrt-avatar-size-xl)}img.avatar,img.avatar-mini,img.avatar-md,img.avatar-lg,.avatar-img img{border-radius:var(--radius-avatar, 50%);object-fit:cover}.aksrt-ripple{position:relative;overflow:hidden}.aksrt-ripple__wave{position:absolute;border-radius:50%;background:#ffffff59;transform:scale(0);animation:aksrtRippleWave .6s ease-out forwards;pointer-events:none}@keyframes aksrtRippleWave{to{transform:scale(4);opacity:0}}.aksrt-wave{position:absolute!important;border-radius:50%;background:#ffffff59;pointer-events:none!important;z-index:1;transform:scale(0);animation:aksrtWaveRipple .6s ease-out forwards}@keyframes aksrtWaveRipple{to{transform:scale(1);opacity:0}}.modal-stacked .modal-dialog{transition:transform .25s var(--ease, ease),filter .25s ease}.modal-stacked .modal-dialog.modal-behind{transform:scale(.92);filter:brightness(.85);pointer-events:none}.aksrt-btn--rose{background:var(--aksrt-rose);color:#fff}.aksrt-btn--rose:hover,.aksrt-btn--rose:focus-visible{background:#e11d48}.aksrt-btn--blue{background:var(--aksrt-blue);color:#fff}.aksrt-btn--blue:hover,.aksrt-btn--blue:focus-visible{background:#2563eb}.aksrt-btn--green{background:var(--aksrt-green);color:#fff}.aksrt-btn--green:hover,.aksrt-btn--green:focus-visible{background:#059669}.aksrt-btn--orange{background:var(--aksrt-orange);color:#fff}.aksrt-btn--orange:hover,.aksrt-btn--orange:focus-visible{background:#ea580c}.aksrt-btn--purple{background:var(--aksrt-purple);color:#fff}.aksrt-btn--purple:hover,.aksrt-btn--purple:focus-visible{background:#7c3aed}.aksrt-btn--cyan{background:var(--aksrt-cyan);color:#fff}.aksrt-btn--cyan:hover,.aksrt-btn--cyan:focus-visible{background:#0891b2}.aksrt-btn--yellow{background:var(--aksrt-yellow);color:#422006}.aksrt-btn--yellow:hover,.aksrt-btn--yellow:focus-visible{background:#ca8a04;color:#fff}.aksrt-btn--red{background:var(--aksrt-red);color:#fff}.aksrt-btn--red:hover,.aksrt-btn--red:focus-visible{background:#dc2626}.aksrt-btn--teal{background:var(--aksrt-teal);color:#fff}.aksrt-btn--teal:hover,.aksrt-btn--teal:focus-visible{background:#0d9488}.aksrt-btn--indigo{background:var(--aksrt-indigo);color:#fff}.aksrt-btn--indigo:hover,.aksrt-btn--indigo:focus-visible{background:#4f46e5}.aksrt-btn--pink{background:var(--aksrt-pink);color:#fff}.aksrt-btn--pink:hover,.aksrt-btn--pink:focus-visible{background:#db2777}.aksrt-btn--rose-soft{background:var(--aksrt-rose-soft);color:var(--aksrt-rose)}.aksrt-btn--blue-soft{background:var(--aksrt-blue-soft);color:var(--aksrt-blue)}.aksrt-btn--green-soft{background:var(--aksrt-green-soft);color:var(--aksrt-green)}.aksrt-btn--orange-soft{background:var(--aksrt-orange-soft);color:var(--aksrt-orange)}.aksrt-btn--purple-soft{background:var(--aksrt-purple-soft);color:var(--aksrt-purple)}.aksrt-btn--cyan-soft{background:var(--aksrt-cyan-soft);color:var(--aksrt-cyan)}.aksrt-btn--yellow-soft{background:var(--aksrt-yellow-soft);color:#92400e}.aksrt-btn--red-soft{background:var(--aksrt-red-soft);color:var(--aksrt-red)}.aksrt-btn--teal-soft{background:var(--aksrt-teal-soft);color:var(--aksrt-teal)}.aksrt-btn--indigo-soft{background:var(--aksrt-indigo-soft);color:var(--aksrt-indigo)}.aksrt-btn--pink-soft{background:var(--aksrt-pink-soft);color:var(--aksrt-pink)}.aksrt-btn--grad-vip{background:linear-gradient(135deg,var(--aksrt-vip-gold-1),var(--aksrt-vip-gold-2));color:#5a3d00;border:none}.aksrt-btn--grad-sunset{background:linear-gradient(135deg,var(--aksrt-grad-sunset-1),var(--aksrt-grad-sunset-2));color:#fff;border:none}.aksrt-btn--grad-ocean{background:linear-gradient(135deg,var(--aksrt-grad-ocean-1),var(--aksrt-grad-ocean-2));color:#fff;border:none}.aksrt-btn--grad-forest{background:linear-gradient(135deg,var(--aksrt-grad-forest-1),var(--aksrt-grad-forest-2));color:#064e3b;border:none}.aksrt-btn--grad-night{background:linear-gradient(135deg,var(--aksrt-grad-night-1),var(--aksrt-grad-night-2));color:#4c1d95;border:none}.aksrt-btn--grad-fire{background:linear-gradient(135deg,var(--aksrt-grad-fire-1),var(--aksrt-grad-fire-2));color:#fff;border:none}.aksrt-btn--grad-primary{background:linear-gradient(135deg,var(--color-primary),var(--color-primary-active));color:var(--color-on-primary);border:none}.aksrt-btn--grad-vip:hover,.aksrt-btn--grad-sunset:hover,.aksrt-btn--grad-ocean:hover,.aksrt-btn--grad-forest:hover,.aksrt-btn--grad-night:hover,.aksrt-btn--grad-fire:hover,.aksrt-btn--grad-primary:hover{filter:brightness(1.08);box-shadow:0 4px 16px #00000026}.aksrt-text-grad-primary{background:linear-gradient(135deg,var(--color-primary),var(--color-primary-active));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}.aksrt-text-grad-vip{background:linear-gradient(135deg,var(--aksrt-vip-gold-1),var(--aksrt-vip-gold-2));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}.aksrt-text-grad-sunset{background:linear-gradient(135deg,var(--aksrt-grad-sunset-1),var(--aksrt-grad-sunset-2));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}.aksrt-text-grad-ocean{background:linear-gradient(135deg,var(--aksrt-grad-ocean-1),var(--aksrt-grad-ocean-2));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}.aksrt-text-grad-forest{background:linear-gradient(135deg,var(--aksrt-grad-forest-1),var(--aksrt-grad-forest-2));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}.aksrt-text-grad-night{background:linear-gradient(135deg,var(--aksrt-grad-night-1),var(--aksrt-grad-night-2));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}.aksrt-text-grad-fire{background:linear-gradient(135deg,var(--aksrt-grad-fire-1),var(--aksrt-grad-fire-2));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}.aksrt-badge{display:inline-flex;align-items:center;justify-content:center;height:var(--aksrt-badge-h, 22px);padding:0 8px;border-radius:var(--radius-pill, 100px);font-size:12px;font-weight:600;line-height:1;white-space:nowrap;vertical-align:middle}.aksrt-badge--primary{background:var(--color-primary-soft);color:var(--color-primary)}.aksrt-badge--success{background:#10b9811f;color:var(--color-success)}.aksrt-badge--warning{background:#f59e0b1f;color:var(--color-warning)}.aksrt-badge--error{background:#ef44441f;color:var(--color-error)}.aksrt-badge--info{background:#3b82f61f;color:var(--color-info)}.aksrt-badge--rose{background:var(--aksrt-rose-soft);color:var(--aksrt-rose)}.aksrt-badge--blue{background:var(--aksrt-blue-soft);color:var(--aksrt-blue)}.aksrt-badge--green{background:var(--aksrt-green-soft);color:var(--aksrt-green)}.aksrt-badge--orange{background:var(--aksrt-orange-soft);color:var(--aksrt-orange)}.aksrt-badge--purple{background:var(--aksrt-purple-soft);color:var(--aksrt-purple)}.aksrt-badge--cyan{background:var(--aksrt-cyan-soft);color:var(--aksrt-cyan)}.aksrt-badge--yellow{background:var(--aksrt-yellow-soft);color:#92400e}.aksrt-badge--red{background:var(--aksrt-red-soft);color:var(--aksrt-red)}.aksrt-badge--teal{background:var(--aksrt-teal-soft);color:var(--aksrt-teal)}.aksrt-badge--indigo{background:var(--aksrt-indigo-soft);color:var(--aksrt-indigo)}.aksrt-badge--pink{background:var(--aksrt-pink-soft);color:var(--aksrt-pink)}.aksrt-badge--grad-vip{background:linear-gradient(135deg,var(--aksrt-vip-gold-1),var(--aksrt-vip-gold-2));color:#5a3d00}.aksrt-badge--grad-primary{background:linear-gradient(135deg,var(--color-primary),var(--color-primary-active));color:var(--color-on-primary)}.aksrt-badge--solid-primary{background:var(--color-primary);color:#fff}.aksrt-badge--solid-success{background:var(--color-success);color:#fff}.aksrt-badge--solid-warning{background:var(--color-warning);color:#fff}.aksrt-badge--solid-error{background:var(--color-error);color:#fff}.aksrt-badge--solid-info{background:var(--color-info);color:#fff}.aksrt-badge--solid-rose{background:var(--aksrt-rose);color:#fff}.aksrt-badge--solid-blue{background:var(--aksrt-blue);color:#fff}.aksrt-badge--solid-green{background:var(--aksrt-green);color:#fff}.aksrt-badge--solid-orange{background:var(--aksrt-orange);color:#fff}.aksrt-badge--solid-purple{background:var(--aksrt-purple);color:#fff}.aksrt-badge--solid-cyan{background:var(--aksrt-cyan);color:#fff}.aksrt-badge--solid-yellow{background:var(--aksrt-yellow);color:#422006}.aksrt-badge--solid-red{background:var(--aksrt-red);color:#fff}.aksrt-badge--solid-teal{background:var(--aksrt-teal);color:#fff}.aksrt-badge--solid-indigo{background:var(--aksrt-indigo);color:#fff}.aksrt-badge--solid-pink{background:var(--aksrt-pink);color:#fff}.aksrt-badge--sm{height:18px;padding:0 6px;font-size:11px}.aksrt-badge--lg{height:28px;padding:0 12px;font-size:13px}.aksrt-bg-rose{background-color:var(--aksrt-rose-soft)}.aksrt-bg-blue{background-color:var(--aksrt-blue-soft)}.aksrt-bg-green{background-color:var(--aksrt-green-soft)}.aksrt-bg-orange{background-color:var(--aksrt-orange-soft)}.aksrt-bg-purple{background-color:var(--aksrt-purple-soft)}.aksrt-bg-cyan{background-color:var(--aksrt-cyan-soft)}.aksrt-bg-yellow{background-color:var(--aksrt-yellow-soft)}.aksrt-bg-red{background-color:var(--aksrt-red-soft)}.aksrt-bg-teal{background-color:var(--aksrt-teal-soft)}.aksrt-bg-indigo{background-color:var(--aksrt-indigo-soft)}.aksrt-bg-pink{background-color:var(--aksrt-pink-soft)}.aksrt-text-rose{color:var(--aksrt-rose)}.aksrt-text-blue{color:var(--aksrt-blue)}.aksrt-text-green{color:var(--aksrt-green)}.aksrt-text-orange{color:var(--aksrt-orange)}.aksrt-text-purple{color:var(--aksrt-purple)}.aksrt-text-cyan{color:var(--aksrt-cyan)}.aksrt-text-yellow{color:var(--aksrt-yellow)}.aksrt-text-red{color:var(--aksrt-red)}.aksrt-text-teal{color:var(--aksrt-teal)}.aksrt-text-indigo{color:var(--aksrt-indigo)}.aksrt-text-pink{color:var(--aksrt-pink)}html,body{overflow-x:clip}@supports not (overflow-x: clip){html,body{overflow-x:hidden}}.reading-progress-bar{position:fixed;top:0;left:0;height:3px;width:0%;z-index:9999;pointer-events:none;background:linear-gradient(90deg,var(--color-primary),var(--color-primary-light));transition:width .1s ease-out}body{font-family:var(--font-body);font-size:var(--text-base);line-height:var(--leading-normal);color:var(--color-ink-2);background-color:var(--surface-page)}a[href]{transition:color .2s,background .3s,opacity .3s,box-shadow .3s,transform .3s}.fa.fab{font-family:"Font Awesome 5 Brands";font-weight:400}.skip-link{position:absolute;left:16px;top:12px;z-index:1100;padding:10px 14px;border-radius:999px;background:var(--color-primary);color:#fff;transform:translateY(-140%);transition:transform .2s ease}.skip-link:focus{transform:translateY(0)}.site-wrapper{min-height:100vh;display:flex;flex-direction:column;font-size:var(--text-base);line-height:var(--leading-normal)}.site-main{flex:1}.container,main.container,.site-main.container{width:100%;max-width:var(--container, var(--mian-max-width, 1200px));margin-right:auto;margin-left:auto;padding-right:0;padding-left:0;--bs-gutter-x: 0;--bs-gutter-y: 0}.header-slider-card .icon-cover-card{font-size:12px}.header-slider-search .line-form-input:focus,.header-slider-search .line-form-input:focus-visible{border:0;outline:0;box-shadow:none}.header-slider-search .line-form .abs-right{position:absolute;right:.7em;top:50%;transform:translateY(-50%);margin-top:0}@media(min-width:576px){.container,main.container{max-width:var(--container, var(--mian-max-width, 1200px))}}@media(min-width:768px){.container,main.container{max-width:var(--container, var(--mian-max-width, 1200px))}}@media(min-width:992px){.container,main.container{max-width:var(--container, var(--mian-max-width, 1200px))}}@media(min-width:1200px){.container,main.container{max-width:var(--container, var(--mian-max-width, 1200px))}}@media(min-width:1240px){.container,main.container,.fluid-widget-wrap .widget-container{width:auto;max-width:var(--container, var(--mian-max-width, 1200px))}}@media(min-width:1400px){.container,main.container{max-width:var(--container, var(--mian-max-width, 1200px))}}@media(max-width:991.98px){.container,main.container{padding-right:15px;padding-left:15px}}@media(max-width:767px){.container,main.container{width:100%;max-width:100%;padding-right:15px;padding-left:15px}}.aksrt-user-level-badge,.aksrt-user-level-icon,.aksrt-identity-medal,.aksrt-identity-certification-icon,.aksrt-header-user-medal{display:inline-flex;align-items:center;justify-content:center;width:auto;height:1.1em;max-width:6em;object-fit:contain;vertical-align:-.15em;flex:0 0 auto;box-sizing:border-box}.aksrt-user-level-badge{padding:0 .35em;font-size:.78em;line-height:1.1;white-space:nowrap}.aksrt-identity-medal--comment,.aksrt-user-level-icon--comment,.aksrt-header-user-medal--comment{height:1em;vertical-align:-.12em}.article-avatar name,.article-avatar .name,.user-name,.aksrt-author-hero__name,.uc-header__name,.aksrt-header-badges{flex-wrap:nowrap;white-space:nowrap}.user-card.author .user-name>name{overflow:visible}.user-card.author .user-name>name>.display-name,.user-card.widget .user-name>name>.display-name{min-width:0;overflow:hidden;text-overflow:ellipsis}.user-card.author .user-name>name>.follow{flex-shrink:0}.aksrt-author-hero__name,.uc-header__name{display:inline-flex;align-items:center;gap:4px}.aksrt-author-hero__name-inner{display:inline-flex;align-items:center;gap:4px;min-width:0}.aksrt-ip-geo-badge{display:inline-flex;align-items:center;min-width:0;margin:2px 0;padding:.3em .6em;border:1px solid var(--color-line-soft, var(--main-border-color, rgba(136, 136, 136, .18)));border-radius:var(--mini-radius, 4px);background:var(--surface-2, rgba(136, 136, 136, .1));box-shadow:var(--shadow-xs, 0 1px 3px rgba(0, 0, 0, .08));color:var(--color-ink-3, #666);font-size:.92em;line-height:1.44;vertical-align:middle;box-sizing:border-box}.no-results{padding:48px 20px;text-align:center;color:var(--color-ink-3)}.navbar-top .aksrt-navbar-account{display:inline-flex;align-items:center;gap:4px;margin:0}.navbar-top .aksrt-navbar-account>li{position:relative;display:inline-flex;align-items:center;flex:0 0 auto;padding:0;overflow:visible}.navbar-top .aksrt-navbar-account>li>.sub-menu{top:calc(100% + 5px);right:0;left:auto;z-index:1002;margin:0}.navbar-top .aksrt-nav-icon,.navbar-top .aksrt-nav-avatar{display:inline-flex!important;align-items:center;justify-content:center;width:34px;height:34px;padding:0!important;overflow:visible!important;border:0!important;border-radius:0;background:transparent!important;box-shadow:none!important}.navbar-top .aksrt-nav-icon .icon{width:1.2em;height:1.2em}.navbar-top .aksrt-nav-avatar{margin-top:0}.navbar-top .aksrt-nav-avatar .avatar{display:block;width:29px;height:29px;border-radius:50%;object-fit:cover}.navbar-top .navbar-form .toggle-radius:not(.social-login-item):not(.xh-social-item){flex:0 0 29px;margin:0;vertical-align:middle}.navbar-top .navbar-form.flex.ac{min-height:34px}.navbar-top .navbar-form .aksrt-navbar-language{margin:0}.navbar-top .navbar-but>.hover-show{display:inline-block}.navbar-search{z-index:1001;opacity:0;transform:translate3d(0,-24px,0);transition:transform .24s ease,opacity .18s ease,visibility 0s linear .24s;will-change:transform,opacity}.navbar-search.show{opacity:1;transform:translateZ(0);transition-delay:0s}.navbar-search .line-form-input:focus,.navbar-search .line-form-input:focus-visible{outline:0;box-shadow:none}.navbar-search .search-close-box{display:flex;justify-content:flex-end;max-width:660px;margin:16px auto 0}.navbar-search .search-close-box>.but{float:none}.aksrt-header-userbox{color:var(--color-ink-2);line-height:1.42857143}.aksrt-header-userbox .muted-box.padding-6{background-color:var(--this-bg, var(--color-line-soft));border-radius:4px;padding:6px}.payvip-icon{background:linear-gradient(300deg,#4c4d51,#2a2a31 15%,#85858a 40%,#393a3c,#393838,#5e5f62);color:#ece0e0;--this-color: #ece0e0;border:none;text-shadow:none}.aksrt-header-userbox>.mt6.em09>.but.block{display:block;box-sizing:border-box;width:100%}.aksrt-nav-notification .aksrt-notification-badge{position:absolute;top:2px;right:2px;display:inline-flex;align-items:center;justify-content:center;min-width:16px;height:16px;padding:0 4px;border:2px solid var(--surface-1);border-radius:999px;background:#e74c3c;color:#fff;font-size:9px;font-weight:700;line-height:1}.navbar-top .aksrt-header-userbox .header-user-href>a,.aksrt-header-userbox .header-user-href>a{max-height:none}.mobile-navbar .aksrt-header-userbox{width:auto;max-width:400px;margin:auto}.aksrt-theme-box{border:none;border-radius:var(--radius, 8px);background:var(--surface-1);box-shadow:var(--shadow-sm);transition:box-shadow .2s ease}.aksrt-theme-box:hover{box-shadow:var(--shadow)}.aksrt-box-bg{background-color:var(--surface-1);box-shadow:var(--shadow-sm)}.aksrt-box-body,.aksrt-box-header{padding:var(--card-padding)}.aksrt-box-header{border-bottom:1px solid var(--color-line-soft)}.aksrt-box-header+.aksrt-box-body{padding-top:0}.aksrt-box-body.notop,.aksrt-box-header.notop{padding-top:0}.aksrt-box-body.nobottom,.aksrt-box-header.nobottom{padding-bottom:0}.aksrt-muted-box{background-color:var(--surface-2);border-radius:var(--radius);padding:var(--card-padding)}.aksrt-noshadow{box-shadow:none!important}.theme-box,.mb20{margin-bottom:20px}.box-bg{background-color:var(--main-bg-color, var(--surface-1));box-shadow:0 0 10px var(--main-shadow, rgba(116, 116, 116, .08))}.box-body{padding:var(--card-padding)}.box-header{padding:var(--card-padding);border-bottom:1px solid var(--color-line-soft)}.box-header+.box-body{padding-top:0}.radius8{border-radius:var(--radius)}.main-shadow{box-shadow:0 0 10px var(--main-shadow, rgba(116, 116, 116, .08))}.main-bg{background:var(--main-bg-color, var(--surface-1))}.muted-box{background-color:var(--surface-2);border-radius:var(--radius);padding:var(--card-padding)}html.dark .aksrt-theme-box,body.dark-theme .aksrt-theme-box,html.dark .box-bg,body.dark-theme .box-bg{border-color:transparent;box-shadow:0 0 10px var(--main-shadow, rgba(0, 0, 0, .2))}html.dark .aksrt-theme-box:hover,body.dark-theme .aksrt-theme-box:hover{box-shadow:0 0 10px var(--main-shadow, rgba(0, 0, 0, .28))}.aksrt-theme-box{transition:box-shadow var(--dur) var(--ease),transform var(--dur) var(--ease)}.aksrt-card-hover:hover,.aksrt-card-hover:focus-within{box-shadow:none;transform:none}.aksrt-card-hover-lg:hover,.aksrt-card-hover-lg:focus-within{box-shadow:var(--shadow-lg);transform:none}.aksrt-noshadow:hover{box-shadow:none!important;transform:none!important}.aksrt-panel{clear:both;background-color:var(--surface-1);padding:var(--card-padding);box-shadow:var(--shadow-sm);border-radius:var(--radius, 8px);margin-bottom:20px}.aksrt-blur-bg{-webkit-backdrop-filter:saturate(5) blur(20px);backdrop-filter:saturate(5) blur(20px);background:var(--blur-bg)}.site-nav--glass{-webkit-backdrop-filter:saturate(5) blur(20px);backdrop-filter:saturate(5) blur(20px);background:var(--blur-bg);border-bottom-color:transparent}.site-nav--glass.is-scrolled{box-shadow:var(--shadow-sm);border-bottom-color:var(--color-line-soft)}html.dark .site-nav--glass.is-scrolled{box-shadow:var(--shadow-sm)}.footer-tabbar--glass{-webkit-backdrop-filter:saturate(5) blur(20px);backdrop-filter:saturate(5) blur(20px);background:var(--blur-bg);box-shadow:0 -8px 24px #141c3014}html.dark .footer-tabbar--glass{box-shadow:0 -10px 28px #00000052}body.has-scrolled .site-nav--glass{box-shadow:0 4px 10px #141c300f}html.dark body.has-scrolled .site-nav--glass{box-shadow:0 4px 10px #00000040}.content-main{width:100%}.content-main--single{max-width:100%}.content-shell{width:100%;margin:0 auto;padding:32px 16px 48px;max-width:var(--container)}.site-main.content-shell.aksrt-profile{max-width:100%;box-sizing:border-box}.site-main.content-shell.aksrt-user-home{max-width:var(--container, 1440px);padding-top:0;box-sizing:border-box}@media(min-width:768px){.content-shell{padding:40px 24px 56px}}.content-shell--page{max-width:var(--container-page, 860px)}.content-shell--cms{max-width:100%;padding:0}.entry-page .entry-thumb{margin:0 0 var(--space-6, 24px);border-radius:var(--radius-lg, 12px);overflow:hidden}.entry-page .page-links{margin-top:var(--space-6, 24px);font-size:var(--text-sm, .875rem);color:var(--color-ink-3, var(--color-ink-3))}.entry-page .page-links-title{margin-right:8px;font-weight:600}main.container,main.content-shell,.site-main.container{position:relative}main.container.content-shell,main.content-shell.container,.site-main.container.content-shell{padding-left:0;padding-right:0;max-width:var(--container, var(--mian-max-width, 1200px));width:100%;box-sizing:border-box}main.container:after,main.content-shell:after,.site-main.container:after{content:"";display:table;clear:both}.content-wrap{float:left;width:100%;min-width:0;display:block;box-sizing:border-box}.content-layout{display:block;width:auto;min-width:0;max-width:none;margin-right:0;margin-left:0;box-sizing:border-box}.content-layout__primary{min-width:0;max-width:100%}.sidebar{float:left;width:311px;width:var(--sidebar-width, 311px);min-width:0;position:relative;box-sizing:border-box;display:block}.content-layout__aside{min-width:0}body.site-layout-1 .sidebar,.site-layout-1 .sidebar{display:none}body.site-layout-1 .content-layout,body.site-layout-1 .content-layout--no-sidebar,.site-layout-1 .content-layout{margin-left:0;margin-right:0}.content-layout--no-sidebar{margin-left:0!important;margin-right:0!important}body.site-layout-2 .content-layout,.site-layout-2 .content-layout{margin-right:326px;margin-right:calc(var(--sidebar-width, 311px) + 15px);margin-left:0;width:auto}body.site-layout-2 .sidebar,.site-layout-2 .sidebar{float:left;width:311px;width:var(--sidebar-width, 311px);margin-left:-311px;margin-left:calc(0px - var(--sidebar-width, 311px));position:unset}body.site-layout-3 .content-layout,.site-layout-3 .content-layout{margin-left:326px;margin-left:calc(var(--sidebar-width, 311px) + 15px);margin-right:unset;width:auto}body.site-layout-3 .sidebar,.site-layout-3 .sidebar{float:left;width:311px;width:var(--sidebar-width, 311px);margin-left:-100%;position:unset}@media(min-width:992px){.sidebar .content-layout__aside,.content-layout__aside{position:sticky;top:calc(var(--nav-height, 60px) + 16px)}.has-static-nav .content-layout__aside{top:16px}}@media(max-width:991.98px){body.site-layout-2 .content-layout,body.site-layout-3 .content-layout,.site-layout-2 .content-layout,.site-layout-3 .content-layout{margin-left:auto;margin-right:auto}body.site-layout-2 .sidebar,body.site-layout-3 .sidebar,.site-layout-2 .sidebar,.site-layout-3 .sidebar,.sidebar{display:none}}.page-header{margin-bottom:28px}.page-header .entry-title{margin-bottom:8px}.page-header p{color:var(--color-ink-3);margin:0;max-width:720px}.term-page-header{position:relative;overflow:hidden;border:1px solid var(--color-line-soft, rgba(15, 23, 42, .08));border-radius:var(--radius-lg);background:var(--surface-1);box-shadow:var(--shadow-sm, 0 1px 6px rgba(15, 23, 42, .04))}.term-page-header__body{position:relative;z-index:1;display:flex;align-items:flex-start;justify-content:space-between;gap:16px;padding:24px}.term-page-header__eyebrow{margin:0 0 8px;font-size:.8125rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--color-primary)}.term-page-header__desc{margin-top:10px}.term-page-header__cover{min-height:140px;background-position:center;background-size:cover;border-top:1px solid var(--color-line-soft, rgba(15, 23, 42, .08))}.term-page-header--simple .term-page-header__cover{display:none}.term-page-header--card{border-top:3px solid var(--color-primary, #f04494)}.term-page-header--card .term-page-header__body{padding:28px 24px}.term-page-header--cover{border:none;border-radius:var(--radius-lg);background:transparent;box-shadow:none;min-height:220px;display:flex;flex-direction:column}.term-page-header__cover-bg{position:absolute;inset:0;background-position:center;background-size:cover;border-radius:var(--radius-lg)}.term-page-header__cover-overlay{position:absolute;inset:0;background:linear-gradient(135deg,#0000008c,#00000040);border-radius:var(--radius-lg)}.term-page-header--cover .term-page-header__body{flex:1;align-items:flex-end;z-index:2}.term-page-header__body--overlay .entry-title,.term-page-header__body--overlay .term-page-header__desc,.term-page-header__body--overlay .term-page-header__eyebrow{color:#fff}.term-page-header__body--overlay .term-page-header__eyebrow{color:#fffc}@media(max-width:767px){.term-page-header--cover{min-height:160px}}.aksrt-admin-quicklink{display:inline-flex;align-items:center;gap:8px;min-height:36px;padding:0 14px;border:1px solid var(--color-line);border-radius:var(--radius-pill, 999px);background:var(--surface-2);color:var(--color-ink-2);text-decoration:none;white-space:nowrap;font-size:.875rem;font-weight:600}.aksrt-admin-quicklink:hover{color:var(--color-primary);border-color:var(--color-primary);background:var(--color-primary-soft, rgba(59, 130, 246, .08))}.archive-filter-bar{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;margin:-8px 0 24px;padding:16px 18px;border-radius:var(--radius-lg);background:var(--surface-2);border:1px solid var(--color-line-soft, rgba(15, 23, 42, .06))}.archive-filter-bar__main{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.archive-filter-bar__label{color:var(--color-ink-4);font-size:.8125rem;font-weight:600}.archive-filter-bar__chips{display:flex;align-items:center;flex-wrap:wrap;gap:8px}.archive-filter-bar__chip{display:inline-flex;align-items:center;min-height:34px;padding:0 12px;border-radius:var(--radius-pill, 999px);background:var(--surface-1);color:var(--color-ink-2);text-decoration:none;font-size:.875rem;font-weight:500}.archive-filter-bar__chip.is-active,.archive-filter-bar__chip:hover{background:var(--color-primary);color:var(--color-on-primary, #fff)}.archive-filter-bar__meta{color:var(--color-ink-4);font-size:.8125rem;font-weight:600}.archive-filter-bar__sort{display:flex;align-items:center;gap:8px;margin-left:auto}.archive-filter-bar__main--tags,.archive-filter-bar__main--custom{border-top:1px solid var(--color-line-soft, rgba(15, 23, 42, .06));padding-top:10px;margin-top:10px}.content-stream,.content-main--archive,.content-main--tag,.content-main--category,.content-main--search,.content-main--single{display:grid;gap:20px}.content-stream__list{width:100%}.content-stream__empty{padding:28px 24px;border-radius:var(--radius-lg);background:var(--surface-1);border:1px solid var(--color-line-soft, rgba(15, 23, 42, .08))}.article-shell{overflow:hidden}@media(max-width:767px){.term-page-header__body{flex-direction:column;align-items:stretch;padding:20px}.aksrt-admin-quicklink{width:fit-content}}.aksrt-pagination-ajax{display:flex;flex-direction:column;align-items:center;gap:16px;margin:40px 0 8px}.aksrt-load-more-btn{min-width:180px;height:44px;padding:0 32px;border-radius:var(--radius-btn);border:1px solid var(--color-line);background:var(--surface-page);color:var(--color-ink);font-size:14px;font-weight:600;cursor:pointer;transition:border-color .2s,color .2s,background .2s;display:inline-flex;align-items:center;justify-content:center;gap:8px}.aksrt-load-more-btn:hover{border-color:var(--color-primary);color:var(--color-primary)}.aksrt-load-more-btn:disabled{opacity:.5;pointer-events:none}.aksrt-loading-indicator{display:none;align-items:center;gap:8px;font-size:14px;color:var(--color-ink-4)}.aksrt-loading-indicator i{font-size:16px}.aksrt-scroll-sentinel{width:100%;height:1px;pointer-events:none}@media(max-width:991px){.content-layout__aside{display:none}}.content-layout__primary--cms{min-width:0}.content-layout__aside .widget a{transition:color var(--dur-fast, .15s) var(--ease)}.content-layout__aside .widget a:hover{color:var(--color-primary)}.comments-card{overflow:hidden}:root{--aksrt-footer-tabbar-height: 49px}@media(max-width:767px){.hide-on-mobile,.hidden-xs{display:none!important}}@media(min-width:768px){.hide-on-desktop,.hide-on-pc{display:none!important}}.footer-tabbar{display:none;position:fixed;bottom:0;left:0;right:0;z-index:1035;min-height:var(--aksrt-footer-tabbar-height);background:var(--blur-bg);border-top:1px solid var(--color-line-soft);padding:0 0 env(safe-area-inset-bottom,0px);justify-content:space-around;align-items:center;box-shadow:0 -4px 5px var(--main-shadow, rgba(0, 0, 0, .06));-webkit-backdrop-filter:saturate(5) blur(20px);backdrop-filter:saturate(5) blur(20px);--tabbar-active-color: #f04494;--tabbar-icon-size: 24px;--tabbar-text-size: 10px}.footer-tabbar.no-border{border-top:none}.footer-tabbar.no-shadow{box-shadow:none}.footer-tabbar.hide-text .tabbar-item span{display:none}.footer-tabbar.has-animation .tabbar-item{transition:color .15s ease,transform .15s ease}.footer-tabbar.has-animation .tabbar-item:active{transform:scale(.9)}.footer-tabbar-placeholder{display:none;height:calc(var(--aksrt-footer-tabbar-height) + env(safe-area-inset-bottom,0px))}@media(max-width:767px){.footer-tabbar{display:flex}.footer-tabbar-placeholder{display:block}.footer,.site-footer{padding-bottom:calc(var(--aksrt-footer-tabbar-height) + env(safe-area-inset-bottom,0px))}}.tabbar-item{display:flex;flex:1 1 0;min-width:0;flex-direction:column;align-items:center;justify-content:center;gap:1px;min-height:49px;padding:4px 0 3px;color:var(--muted-color, var(--color-ink-3));font-size:13px;text-decoration:none;border:none;background:none;cursor:pointer;transition:color .15s ease;-webkit-tap-highlight-color:transparent;position:relative}.tabbar-item i{font-size:var(--tabbar-icon-size, 24px);line-height:1}.tabbar-item span{display:block;font-size:var(--tabbar-text-size, 10px);font-weight:500;line-height:1;transform:scale(.8);transform-origin:center top}.tabbar-item:hover,.tabbar-item.active{color:var(--tabbar-active-color, var(--color-primary));text-decoration:none}.tabbar-item.active i{font-weight:900}.tabbar-badge{position:absolute;top:2px;right:8px;min-width:16px;height:16px;padding:0 4px;border-radius:999px;background:var(--color-error);color:#fff;font-size:10px;line-height:16px;font-style:normal;font-weight:700;text-align:center;box-shadow:0 2px 8px #ef444433}.tabbar-badge--dot{min-width:0;width:8px;height:8px;padding:0;top:4px;right:10px;font-size:0;line-height:1;box-shadow:0 0 0 2px var(--blur-bg)}.tabbar-item--add{margin-top:-22px;width:62px;height:62px;min-height:62px;border-radius:50%;background:var(--color-accent);color:#fff;box-shadow:0 12px 24px var(--color-accent-shadow);flex:0 0 62px;z-index:1}.tabbar-item--add i{font-size:22px}.tabbar-item--add span{font-size:11px}.tabbar-item--add:hover,.tabbar-item--add.active{color:#fff}.tabbar-add-backdrop{position:fixed;inset:0;z-index:951;background:#0f172a61}.tabbar-add-panel{position:fixed;left:0;right:0;bottom:0;z-index:952;padding:0 12px calc(84px + env(safe-area-inset-bottom,0px))}.tabbar-add-panel__sheet{border-radius:20px;background:var(--surface-1);border:none;box-shadow:var(--shadow-drawer);overflow:hidden}.tabbar-add-panel__head{display:flex;align-items:center;justify-content:space-between;padding:16px 18px 12px;border-bottom:1px solid var(--color-line)}.tabbar-add-panel__close{width:32px;height:32px;border:0;border-radius:50%;background:var(--surface-2);color:var(--color-ink-3)}.tabbar-add-panel__list{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;padding:18px}.tabbar-add-panel__item{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;min-height:88px;border-radius:18px;background:var(--surface-2);color:var(--color-ink-3);text-decoration:none}.tabbar-add-panel__item i{font-size:22px;color:var(--color-accent)}.tabbar-add-panel__item span{font-size:13px;font-weight:600}.tabbar-search-overlay{position:fixed;inset:0;z-index:953;background:#0f172a94;display:flex;align-items:stretch;justify-content:center}.tabbar-search-overlay__dialog{width:100%;min-height:100%;background:var(--surface-1);padding:18px 16px calc(24px + env(safe-area-inset-bottom,0px))}.tabbar-search-overlay__head{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}.tabbar-search-overlay__close{width:36px;height:36px;border:0;border-radius:50%;background:var(--surface-2);color:var(--color-ink-3)}.tabbar-search-overlay__form{display:grid;grid-template-columns:20px 1fr auto;gap:10px;align-items:center;padding:14px 14px 14px 12px;border-radius:18px;border:none;box-shadow:var(--shadow-sm);background:var(--surface-4)}.tabbar-search-overlay__icon{color:var(--color-ink-4)}.tabbar-search-overlay__form input{min-width:0;border:0;background:transparent;color:var(--color-ink);font-size:15px;outline:none}.tabbar-search-overlay__submit{border:0;border-radius:12px;padding:10px 14px;background:var(--color-accent);color:#fff;font-weight:700}.tabbar-search-overlay__types{display:flex;gap:8px;margin-top:14px}.tabbar-search-overlay__type{border:0;border-radius:999px;padding:9px 14px;background:var(--surface-2);color:var(--color-ink-3);font-size:13px;font-weight:600}.tabbar-search-overlay__type.is-active{background:var(--color-accent-soft);color:var(--color-accent)}.tabbar-search-overlay__section{margin-top:20px}.tabbar-search-overlay__section-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}.tabbar-search-overlay__label{color:var(--color-ink-3);font-size:13px;font-weight:700}.tabbar-search-overlay__clear{border:0;background:transparent;color:var(--color-accent);font-size:12px;font-weight:700}.tabbar-search-overlay__history,.tabbar-search-overlay__suggestions{display:flex;flex-direction:column;gap:10px}.tabbar-search-overlay__history-item,.tabbar-search-overlay__suggestion-item{display:flex;align-items:center;justify-content:space-between;gap:12px;width:100%;border:none;border-radius:16px;padding:12px 14px;background:var(--surface-4);box-shadow:var(--shadow-sm);color:var(--color-ink-3);text-decoration:none;text-align:left}.tabbar-search-overlay__history-item i{color:var(--color-accent)}.tabbar-search-overlay__history-item span,.tabbar-search-overlay__suggestion-item span{flex:1;min-width:0;font-size:14px;font-weight:600}.tabbar-search-overlay__suggestion-item small{color:var(--color-ink-4);font-size:12px}.tabbar-search-overlay__empty{padding:14px;border-radius:16px;background:var(--surface-2);color:var(--color-ink-4);font-size:13px;text-align:center}.tabbar-search-overlay__tags{display:flex;flex-wrap:wrap;gap:10px}.tabbar-search-overlay__tag{padding:10px 14px;border-radius:999px;background:var(--surface-2);color:var(--color-ink-3);text-decoration:none;font-size:13px}body.tabbar-search-open,body.tabbar-add-open{overflow:hidden}.footer-tabbar.scrolling-hide{transform:translateY(100%);transition:transform .3s ease}.footer-tabbar.scroll-down-hide{transition:transform .3s ease}.footer-tabbar.scroll-down-hide.is-hidden{transform:translateY(120%)}.tabbar-ontop-wrap{position:relative}.tabbar-ontop-wrap .tabbar-ontop-btn{display:none;position:absolute;inset:0;flex-direction:column;align-items:center;justify-content:center;gap:2px;padding:4px 12px;color:var(--color-primary);font-size:13px;text-decoration:none;cursor:pointer;-webkit-tap-highlight-color:transparent}.tabbar-ontop-wrap .tabbar-ontop-btn i{font-size:18px;line-height:1}.tabbar-ontop-wrap .tabbar-ontop-btn span{font-weight:500;line-height:1;transform:scale(.8);transform-origin:center top}.tabbar-ontop-wrap.is-scrolled .tabbar-ontop-btn{display:flex}.tabbar-ontop-wrap.is-scrolled>.tabbar-item:not(.tabbar-ontop-btn){opacity:0;pointer-events:none}.theme-dark .site-footer,html.dark .site-footer{background:var(--surface-1)}.theme-dark .footer-tabbar,html.dark .footer-tabbar{background:var(--blur-bg);box-shadow:0 -1px 3px #0003}.back-to-top{display:none!important}.site-footer--compact .site-footer__grid{display:flex;flex-wrap:wrap;align-items:center;gap:16px}.site-footer--compact .site-footer__brand{flex:1 1 100%}.tabbar-search-overlay[hidden]{display:none!important}.tabbar-search-overlay.is-open,.tabbar-search-overlay.show:not([hidden]){display:flex}.tabbar-search-overlay__dialog{max-width:720px;margin:0 auto}@media(min-width:768px){.tabbar-search-overlay{align-items:flex-start;padding-top:8vh;background:#0f172a73}.tabbar-search-overlay__dialog{min-height:auto;max-height:min(84vh,860px);overflow:auto;border-radius:20px;box-shadow:var(--shadow-lg, 0 20px 50px rgba(15, 23, 42, .22))}.nav-searchbox--desktop{display:none}.main-search-btn.nav-search-toggle{display:inline-flex;align-items:center;justify-content:center}}@media(max-width:767.98px){.main-search-btn.nav-search-toggle{display:none}}.site-footer--compact .site-footer__brand .site-footer__desc,.site-footer--compact .site-footer__brand .social-row,.site-footer--compact .site-footer__brand .site-footer__friends{display:none}.site-footer--compact .site-footer__col{flex:0 0 auto}.site-footer--compact .site-footer__col ul{display:flex;flex-wrap:wrap;gap:12px;list-style:none;padding:0;margin:0}.site-footer--compact .site-footer__bottom{border-top:1px solid var(--color-line);padding-top:12px;margin-top:12px}.site-footer--centered .site-footer__grid{text-align:center}.site-footer--centered .site-footer__brand{flex:1 1 100%;display:flex;flex-direction:column;align-items:center}.site-footer--centered .site-footer__col{flex:1 1 auto}.site-footer--centered .site-footer__col ul{list-style:none;padding:0;margin:0;display:flex;flex-wrap:wrap;justify-content:center;gap:16px}.footer{color:var(--footer-color, var(--color-ink-4));background:var(--footer-bg, var(--surface-1));padding:30px;font-size:13px;margin-top:auto}.footer a:not(.but):not(.focus-color){color:var(--footer-color, var(--color-ink-4))}.footer a:hover{color:var(--focus-color, var(--color-primary))}.footer .container-footer{max-width:calc(var(--mian-max-width, var(--container, 1200px)) + 180px);margin:0 auto;width:100%}.footer .list-inline{display:flex;align-items:center;gap:20px;list-style:none;margin:0 0 12px;padding:0}.footer .list-inline>li{flex:1;margin:5px 15px}.footer .list-inline>li+li+li{float:right;display:flex;align-items:flex-end;flex-direction:column;gap:10px}.footer a.footer-logo img,.footer .footer-logo img{height:40px;width:auto;max-width:180px;vertical-align:middle;display:inline-block}.footer .title-h-left{position:relative;margin:8px 0 10px;padding-bottom:8px;font-size:15px;font-weight:700;color:var(--color-ink, #333)}.footer .title-h-left:before{transition:.4s;transform-origin:left;position:absolute;content:"";width:40px;height:3px;background:var(--theme-color, var(--color-primary));left:0;bottom:3px;border-radius:5px;box-shadow:1px 1px 3px -1px var(--theme-color, var(--color-primary))}.footer .footer-muted,.footer .em09{font-size:.92em;color:var(--muted-2-color, var(--color-ink-4));line-height:1.7}.footer .opacity8{opacity:.8}.footer .fcode-links{margin:0 0 8px;line-height:1.9}.footer .fcode-links>a+a:before{content:"";width:4px;height:4px;margin:0 .5em;border-radius:50%;display:inline-block;vertical-align:middle;background:var(--main-color, var(--color-ink-2));opacity:.3;vertical-align:.2em}.footer .toggle-radius{background:var(--this-bg, var(--main-border-color, var(--surface-2, #f0f0f0)));width:29px;height:29px;display:inline-block;text-align:center;border-radius:100%;position:relative;vertical-align:text-top;line-height:29px;color:var(--footer-color, var(--color-ink-3));text-decoration:none}.footer .footer-contact{margin-top:10px}.footer .footer-contact>a{font-size:20px;margin:5px;width:35px;height:35px;line-height:35px;display:inline-block;text-align:center;border-radius:100%;background:var(--this-bg, var(--main-border-color, var(--surface-2, #f0f0f0)));color:var(--footer-color, var(--color-ink-3));text-decoration:none;vertical-align:text-top;position:relative;transition:color .15s ease,background .15s ease}.footer .footer-contact>a:hover{color:#fff;background:var(--focus-color, var(--color-primary))}.footer .footer-contact>a i{vertical-align:middle}.footer .footer-wechat-img{display:none;position:absolute;width:140px;bottom:90%;left:-55px;padding-bottom:20px;z-index:5}.footer .hover-show{position:relative}.footer .hover-show:hover .footer-wechat-img,.footer .hover-show:focus .footer-wechat-img{display:block}.footer .footer-wechat-img img{box-shadow:0 5px 10px #0003;border-radius:4px;width:100%;height:auto;background:#fff}.footer .footer-miniimg img{width:90px;margin:0 6px;height:auto;max-width:100%;display:block;border-radius:4px}.footer .footer-miniimg-2{justify-content:flex-end}.footer .footer-miniimg-2 img{height:36px;border-radius:var(--main-radius)}.footer .footer-miniimg-2 .opacity8{display:none}.footer .footer-conter{margin:8px 0 0;line-height:1.8;color:var(--footer-color, var(--color-ink-4));font-size:13px}.footer .mt10{margin-top:10px}@media(max-width:767px){.footer .hidden-xs,.hidden-xs{display:none!important}.footer{padding:10px 0}.footer .container-footer{text-align:center;padding:0 16px}.footer .list-inline{gap:10px;flex-direction:column;align-items:center;justify-content:center}.footer .list-inline>li+li+li{float:none;align-items:center}.footer .list-inline>li{margin:12px 0;max-width:none!important}.footer .footer-miniimg-2{justify-content:center}.footer .title-h-left:before{left:50%;transform:translate(-50%)}}.float-right{position:fixed;bottom:60px;right:20px;z-index:1030;text-align:center;padding-bottom:constant(safe-area-inset-bottom);padding-bottom:env(safe-area-inset-bottom);transition:.3s}.float-right .admin-set-page .admin-set-info{position:absolute;top:50%;right:.7em;transform:translateY(-50%);white-space:nowrap;line-height:1.5;margin:0;box-shadow:none;text-align:center;z-index:1}body:has(.modal.in) .float-right,.modal-open .float-right,.imgbox-show .float-right,.scroll-down .scroll-down-hide.float-right,.scroll-ing .scrolling-hide.float-right{transform:translate(200%)}body:has(.modal.in) .float-right.position-center,.modal-open .float-right.position-center,.imgbox-show .float-right.position-center,.scroll-down .scroll-down-hide.float-right.position-center,.scroll-ing .scrolling-hide.float-right.position-center{transform:translate(200%,-50%)}.float-right.position-center{bottom:auto;top:50%;transform:translateY(-50%)}.float-right .float-btn{width:40px;min-height:40px;display:flex;justify-content:center;align-items:center;flex-direction:column;padding:7px 0;font-size:1.4em;--this-color: var(--muted-2-color);--this-bg: var(--float-btn-bg);background:var(--this-bg);position:relative;color:var(--this-color)!important}.float-right .float-btn text{font-size:12px;line-height:1;margin-top:5px}.float-right badge{transform:translate(-11px,-5px) scale(.85);margin-right:-50px;right:auto;left:35px;font-size:12px;position:absolute}.filter .float-btn{-webkit-backdrop-filter:saturate(2) blur(10px);backdrop-filter:saturate(2) blur(10px)}.float-right.square .float-btn+.float-btn{border-top:1px solid var(--main-shadow)}.float-right.round .float-btn{margin-top:6px;border-radius:8px}.float-btn.ontop{opacity:0;transition:.5s;transform:translateY(10px);visibility:hidden}.ontop.show{opacity:1;transform:translateY(0);visibility:unset;display:flex!important}.float-btn .hover-show-con{right:35px;left:auto;width:200px;margin-right:15px;top:0;font-size:14px;line-height:1.4;color:var(--main-color);padding:10px;margin-top:0;border-radius:var(--main-radius);cursor:auto}.float-btn .dropdown-menu:before{left:auto;top:15px;right:-5px}.float-btn.service-wechat .hover-show-con{width:120px;top:-40px;padding:0}.float-btn.service-wechat .dropdown-menu:before{top:53px}.float-btn.qrcode-btn .hover-show-con{width:150px;top:-60px;padding:16px 5px 12px;text-align:center}.float-btn.qrcode-btn .qrcode{background:#fff;display:inline-flex;padding:5px}.float-btn.qrcode-btn .dropdown-menu:before{top:73px}.float-btn.newadd-btns .drop-newadd{width:137px;top:-74px}.float-btn.newadd-btns .drop-newadd:before{left:auto;top:88px;right:-5px}@media(max-width:767px){.float-btn .hover-show-con{max-width:240px}}@media(max-width:640px){.float-right{right:10px}}@keyframes aksrt-spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.aksrt-hover-zoom{transition:transform .3s ease}.aksrt-hover-zoom:hover{transform:scale(1.02)}.aksrt-hover-zoom-sm{transition:transform .3s ease}.aksrt-hover-zoom-sm:hover{transform:scale(1.02)}.aksrt-hover-show{position:relative}.aksrt-hover-show__content{position:absolute;opacity:0;visibility:hidden;transform:translateY(6px);transition:opacity .3s,transform .3s,visibility .3s;z-index:10}.aksrt-hover-show:hover .aksrt-hover-show__content{opacity:1;visibility:visible;transform:translateY(0)}.aksrt-ic-close{transition:transform .3s;cursor:pointer}.aksrt-ic-close:hover{transform:rotate(270deg)}.aksrt-hover-zoom-img img{transition:transform .3s ease}.aksrt-hover-zoom-img:hover img{transform:scale(1.02)}.aksrt-hover-zoom-img-sm img{transition:transform .3s ease}.aksrt-hover-zoom-img-sm:hover img{transform:scale(1.02)}.aksrt-hover-lift{transition:transform .2s ease,box-shadow .2s ease}.aksrt-hover-lift:hover{transform:none;box-shadow:0 4px 12px #0000001a}::view-transition-old(root),::view-transition-new(root){animation:none;mix-blend-mode:normal}.dark::view-transition-old(root),body.dark-theme::view-transition-old(root){z-index:1}.dark::view-transition-new(root),body.dark-theme::view-transition-new(root){z-index:2147483646}::view-transition-old(root){z-index:2147483646}::view-transition-new(root){z-index:1}@supports not ((-webkit-backdrop-filter: blur(20px)) or (backdrop-filter: blur(20px))){:root{--aksrt-blur-bg: #ffffff}.dark{--aksrt-blur-bg: var(--surface-1)}}body.aksrt-scroll-down .site-nav.nav-scroll-hide{transform:translateY(-100%);transition:transform .3s ease}body.aksrt-scroll-up .site-nav.nav-scroll-hide{transform:translateY(0);transition:transform .3s ease}body.aksrt-scrolling .aksrt-floating-right.scrolling-hide{opacity:0;pointer-events:none;transition:opacity .2s}body.aksrt-body-scroll .aksrt-show-on-scroll{opacity:1;visibility:visible}.aksrt-show-on-scroll{opacity:0;visibility:hidden;transition:opacity .3s,visibility .3s}.aksrt-cursor-zoom-in{cursor:zoom-in}.aksrt-announcement{position:relative;overflow:hidden;padding:10px 12px;background:var(--surface-2, #F4F5F7);border-radius:var(--radius, 10px)}.aksrt-announcement__icon{position:absolute;left:12px;top:50%;transform:translateY(-50%);color:var(--color-primary, var(--color-primary));font-size:14px;z-index:1}.aksrt-announcement__track{margin-left:24px;overflow:hidden;height:20px}.aksrt-announcement__list{margin:0;padding:0;list-style:none;animation:aksrt-marquee 10s linear infinite}.aksrt-announcement__item{height:20px;line-height:20px;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.aksrt-announcement__item a{color:var(--color-ink-2);text-decoration:none}.aksrt-announcement__item a:hover{color:var(--color-primary, var(--color-primary))}@keyframes aksrt-marquee{0%{transform:translateY(0)}to{transform:translateY(-100%)}}.aksrt-announcement:hover .aksrt-announcement__list{animation-play-state:paused}[data-aksrt-animate]{will-change:opacity,transform}[data-aksrt-sticky]{will-change:transform}.aksrt-user-ranking{margin:0;padding:0;list-style:none}.aksrt-user-ranking__item{display:flex;align-items:center;gap:10px;padding:10px 0;border-bottom:1px solid var(--color-line-soft, #F0F1F3)}.aksrt-user-ranking__item:last-child{border-bottom:none}.aksrt-user-ranking__rank{display:flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:6px;background:var(--surface-2, #F4F5F7);color:var(--color-ink-3);font-size:12px;font-weight:600;flex-shrink:0}.aksrt-user-ranking__item--top:nth-child(1) .aksrt-user-ranking__rank{background:gold;color:#fff}.aksrt-user-ranking__item--top:nth-child(2) .aksrt-user-ranking__rank{background:silver;color:#fff}.aksrt-user-ranking__item--top:nth-child(3) .aksrt-user-ranking__rank{background:#cd7f32;color:#fff}.aksrt-user-ranking__avatar{flex-shrink:0}.aksrt-user-ranking__avatar img{width:36px;height:36px;border-radius:50%;object-fit:cover}.aksrt-user-ranking__name{flex:1;min-width:0;font-size:14px;color:var(--color-ink);text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.aksrt-user-ranking__name:hover{color:var(--color-primary, var(--color-primary))}.aksrt-user-ranking__value{font-size:12px;color:var(--color-ink-4, #8A8F98);white-space:nowrap}.aksrt-cover-cards{display:grid;grid-template-columns:repeat(var(--cols, 2),1fr);gap:12px}.aksrt-cover-card{position:relative;border-radius:var(--radius-lg, 8px);overflow:hidden;min-height:160px;display:flex;align-items:flex-end;transition:transform .3s,box-shadow .3s}.aksrt-cover-card:hover{transform:translateY(-3px);box-shadow:0 8px 24px #00000026}.aksrt-cover-card__bg{position:absolute;inset:0}.aksrt-cover-card__bg img{width:100%;height:100%;object-fit:cover;transition:transform .5s}.aksrt-cover-card:hover .aksrt-cover-card__bg img{transform:scale(1.02)}.aksrt-cover-card__overlay{position:absolute;inset:0;background:linear-gradient(180deg,#0000 30%,#0009)}.aksrt-cover-card__content{position:relative;z-index:1;padding:16px;width:100%}.aksrt-cover-card__link{text-decoration:none;color:inherit;display:block}.aksrt-cover-card__title{margin:0 0 4px;font-size:16px;font-weight:600;color:#fff;text-shadow:0 1px 3px rgba(0,0,0,.4)}.aksrt-cover-card__desc{margin:0;font-size:13px;color:#ffffffd9;text-shadow:0 1px 2px rgba(0,0,0,.3);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.aksrt-video-widget{position:relative;width:100%;overflow:hidden;border-radius:var(--radius, 10px);background:#000}.aksrt-video-widget iframe,.aksrt-video-widget video{position:absolute;top:0;left:0;width:100%;height:100%;border:none}.aksrt-float-label{position:relative}.aksrt-float-label__input{width:100%;padding:14px 12px 6px;font-size:14px;border:1px solid var(--color-line);border-radius:var(--radius, 10px);background:var(--surface-1, #fff);color:var(--color-ink);transition:border-color .2s}.aksrt-float-label__input:focus{outline:none;border-color:var(--color-primary, var(--color-primary))}.aksrt-float-label__label{position:absolute;left:12px;top:50%;transform:translateY(-50%);font-size:14px;color:var(--color-ink-4, #8A8F98);pointer-events:none;transition:all .2s ease}.aksrt-float-label__input:focus+.aksrt-float-label__label,.aksrt-float-label__input:not(:placeholder-shown)+.aksrt-float-label__label{top:8px;transform:translateY(0);font-size:11px;color:var(--color-primary, var(--color-primary))}[data-aksrt-parallax]{will-change:transform;transition:transform .1s linear}.aksrt-level-bar{position:relative;height:8px;background:var(--surface-2, #F4F5F7);border-radius:4px;overflow:hidden}.aksrt-level-bar__fill{height:100%;background:linear-gradient(90deg,var(--color-primary, var(--color-primary)),var(--color-primary-active, #5A85BD));border-radius:4px;transition:width .5s ease}.aksrt-level-bar__label{position:absolute;right:0;top:-18px;font-size:11px;color:var(--color-ink-4, #8A8F98)}.empty-state{text-align:center;padding:60px 20px;color:var(--color-ink-3, #777)}.empty-state__icon{font-size:56px;color:var(--surface-3, #e8e8e8);margin-bottom:16px;line-height:1}.empty-state__icon i{display:inline-block}.empty-state__title{font-size:16px;font-weight:500;color:var(--color-ink-2, #4e5358);margin-bottom:8px}.empty-state__desc{font-size:13px;color:var(--color-ink-4, #999);margin-bottom:20px;max-width:320px;margin-left:auto;margin-right:auto;line-height:1.6}.empty-state__action{display:inline-flex;align-items:center;gap:6px}.empty-state--sm{padding:30px 16px}.empty-state--sm .empty-state__icon{font-size:40px}.empty-state--sm .empty-state__title{font-size:14px}.empty-state--sm .empty-state__desc{font-size:12px}.search-empty{text-align:center;padding:80px 20px}.search-empty__icon{font-size:64px;color:var(--surface-3, #e8e8e8);margin-bottom:16px}.search-empty__title{font-size:18px;font-weight:500;color:var(--color-ink-2, #4e5358);margin-bottom:8px}.search-empty__suggestions{font-size:13px;color:var(--color-ink-4, #999)}.search-empty__suggestions a{color:var(--color-primary, #f04494);text-decoration:none}.search-empty__suggestions a:hover{text-decoration:underline}@media(max-width:767px){.empty-state{padding:40px 16px}.empty-state__icon{font-size:44px}}.pagination,.aksrt-pagination,.navigation.pagination .nav-links,.pagenav.ajax-pag{display:flex;align-items:center;justify-content:center;gap:6px;padding:24px 0;flex-wrap:wrap}.pagination a,.pagination span,.aksrt-pagination a,.aksrt-pagination span,.navigation.pagination a,.navigation.pagination span,.pagenav.ajax-pag a,.pagenav.ajax-pag span{display:inline-flex;align-items:center;justify-content:center;min-width:34px;min-height:34px;padding:6px 10px;font-size:13px;line-height:1.4;color:var(--muted-color, var(--color-ink-3));background:var(--surface-4, var(--main-border-color, rgba(50, 50, 50, .06)));border:1px solid transparent;border-radius:var(--radius-xs, 3px);text-decoration:none;transition:all .2s;user-select:none}.pagination a:hover,.aksrt-pagination a:hover,.navigation.pagination a:hover,.pagenav.ajax-pag a:hover{opacity:1;color:var(--color-primary);background:var(--color-primary-soft)}.pagination .current,.aksrt-pagination .current,.navigation.pagination .current,.pagenav.ajax-pag .current{color:var(--color-on-primary, #fff);background:var(--color-primary, #f04494);border-color:var(--color-primary, #f04494);font-weight:500}.pagination .dots,.aksrt-pagination .dots,.navigation.pagination .dots,.pagenav.ajax-pag .dots{border:none;background:none;color:var(--color-ink-4, #999);cursor:default;min-width:auto;padding-left:4px;padding-right:4px}.pagination .prev,.pagination .next,.aksrt-pagination .prev,.aksrt-pagination .next,.navigation.pagination .prev,.navigation.pagination .next,.pagenav.ajax-pag .prev,.pagenav.ajax-pag .next{font-size:12px}.pagination .prev i,.pagination .next i,.aksrt-pagination .prev i,.aksrt-pagination .next i,.navigation.pagination .prev i,.navigation.pagination .next i,.pagenav.ajax-pag .prev i,.pagenav.ajax-pag .next i{font-size:11px}.pagenav.ajax-pag .pag-jump,.aksrt-pagination .pag-jump,.navigation.pagination .pag-jump{display:inline-flex;align-items:center;gap:6px;margin-left:6px}.pagenav.ajax-pag .jump-input,.aksrt-pagination .jump-input,.navigation.pagination .jump-input{width:64px;min-width:64px;padding:6px 8px 5px;font-size:13px;line-height:1.4;color:var(--muted-color, var(--color-ink-3));background:var(--main-bg-color, #fff);border:1px solid var(--main-border-color, rgba(50, 50, 50, .06));border-radius:3px;box-shadow:none}.pagenav.ajax-pag .jump-input:focus,.aksrt-pagination .jump-input:focus,.navigation.pagination .jump-input:focus{outline:none;border-color:var(--focus-color, var(--color-primary, #f04494))}.pagenav.ajax-pag .pag-jump .ajax-next,.aksrt-pagination .pag-jump .ajax-next,.navigation.pagination .pag-jump .ajax-next{min-width:auto;cursor:pointer}.pagination__nav{display:flex;gap:8px;justify-content:center;margin-top:8px}.pagination__nav a{display:inline-flex;align-items:center;gap:4px;padding:6px 14px;font-size:13px;color:var(--color-ink-2, #4e5358);background:var(--surface-1, #fff);border:1px solid var(--color-line, rgba(50, 50, 50, .06));border-radius:6px;text-decoration:none;transition:all .2s}.pagination__nav a:hover{color:var(--color-primary, #f04494);border-color:var(--color-primary, #f04494)}@media(max-width:767px){.pagination a,.pagination span,.aksrt-pagination a,.aksrt-pagination span,.navigation.pagination a,.navigation.pagination span,.pagenav.ajax-pag a,.pagenav.ajax-pag span{min-width:28px;font-size:12px;margin:2px}.pagenav.ajax-pag .pag-jump,.aksrt-pagination .pag-jump,.navigation.pagination .pag-jump{width:100%;justify-content:center;margin-left:0;margin-top:6px}.pagenav.ajax-pag .jump-input,.aksrt-pagination .jump-input,.navigation.pagination .jump-input{width:58px;min-width:58px;font-size:12px}}.aksrt-modal-runtime{--modal-ink: var(--color-ink, #333333);--modal-ink-2: var(--color-ink-2, #4e5358);--modal-muted: var(--color-ink-3, #777777);--modal-line: var(--color-line-stronger, rgba(50, 50, 50, .12));--modal-line-soft: var(--color-line, rgba(50, 50, 50, .06));--modal-surface: var(--surface-1, #ffffff);--modal-surface-2: var(--surface-2, #eeeeee);--modal-surface-3: var(--surface-4, #f8f8f8);--modal-primary: var(--color-primary, #f04494);--modal-on-primary: var(--color-on-primary, #ffffff);--modal-warning: var(--color-warning, #f59e0b);--modal-success: var(--color-success, #10b981);--modal-danger: var(--color-danger, #ef4444)}html.aksrt-modal-runtime-open,body.aksrt-modal-runtime-open{overflow:hidden}.aksrt-modal-runtime.modal-mini,.aksrt-modal-runtime.full-sm{width:auto;max-width:none;margin:0}.aksrt-modal-runtime{position:fixed;inset:0;z-index:100050;display:flex;align-items:center;justify-content:center;opacity:0;visibility:hidden;transition:opacity .2s ease,visibility .2s ease;pointer-events:none}.aksrt-modal-runtime.is-open{opacity:1;visibility:visible;pointer-events:auto}.aksrt-modal-runtime__overlay{position:absolute;inset:0;background:#00000080}.aksrt-modal-runtime__dialog{position:relative;z-index:1;width:min(440px,calc(100vw - 32px));max-height:min(88vh,720px);overflow:auto;background:var(--surface-1, #fff);border-radius:16px;box-shadow:0 18px 48px #0f121c38;transform:translateY(12px) scale(.98);transition:transform .22s cubic-bezier(.22,1,.36,1);color:var(--color-ink, #1f2937)}.aksrt-modal-runtime.is-open .aksrt-modal-runtime__dialog{transform:translateY(0) scale(1)}.aksrt-modal-runtime--bottom{align-items:flex-end}.aksrt-modal-runtime--bottom .aksrt-modal-runtime__dialog{width:100%;max-width:100%;border-radius:16px 16px 0 0;max-height:90vh;transform:translateY(24px)}.aksrt-modal-runtime--bottom.is-open .aksrt-modal-runtime__dialog{transform:translateY(0)}.aksrt-modal-runtime__header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px 18px 0}.aksrt-modal-runtime__title{margin:0;font-size:17px;line-height:1.35}.aksrt-modal-runtime__content{padding:18px 18px 16px}.aksrt-modal-runtime__loading,.aksrt-modal-runtime__error-body{padding:28px 8px;text-align:center;color:var(--color-ink-2, #6b7280);font-size:14px}.aksrt-modal-runtime__skeleton{display:grid;gap:12px;padding:8px 2px}.aksrt-modal-runtime__skeleton-line,.aksrt-modal-runtime__skeleton-block{display:block;border-radius:8px;background:linear-gradient(90deg,#94a3b829,#94a3b84d,#94a3b829);background-size:200% 100%;animation:aksrt-modal-skeleton 1.2s ease-in-out infinite}.aksrt-modal-runtime__skeleton-line{width:72%;height:12px}.aksrt-modal-runtime__skeleton-line.is-wide{width:92%;height:18px}.aksrt-modal-runtime__skeleton-line.is-short{width:44%}.aksrt-modal-runtime__skeleton-block{width:100%;min-height:84px}.aksrt-modal-runtime__retry{cursor:pointer}@keyframes aksrt-modal-skeleton{0%{background-position:100% 0}to{background-position:-100% 0}}.aksrt-modal-runtime .touch.border-title,.aksrt-modal-runtime .border-title{font-size:16px;font-weight:700;margin:0 0 14px;display:flex;align-items:baseline;flex-wrap:wrap;gap:6px}.aksrt-modal-runtime .aksrt-modal-runtime__close{position:absolute;top:10px;right:10px;width:32px;height:32px;border:0;border-radius:999px;background:transparent;color:var(--color-ink-3, #9ca3af);cursor:pointer;font-size:18px;line-height:1}.aksrt-modal-runtime .but-average.modal-buts,.aksrt-modal-runtime .modal-buts{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:16px}.aksrt-modal-runtime .but-average.modal-buts .but,.aksrt-modal-runtime .modal-buts .but,.aksrt-modal-runtime .modal-buts button{min-height:40px;border-radius:10px;border:1px solid var(--color-line, #e5e7eb);background:var(--surface-4, #f3f4f6);color:var(--color-ink, #111);font-weight:600;cursor:pointer}.aksrt-modal-runtime .modal-buts .but.c-blue,.aksrt-modal-runtime .modal-buts button.c-blue,.aksrt-modal-runtime .modal-buts .btn-primary{background:var(--color-primary, #f04494);border-color:color-mix(in srgb,var(--color-primary, #f04494) 70%,#000 0%);color:var(--color-on-primary, #fff)}.aksrt-modal-runtime.aksrt-checkin-details-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog{width:min(400px,calc(100vw - 32px));border-radius:var(--main-radius, 8px);background:var(--main-bg-color, #fff)}.aksrt-modal-runtime.aksrt-checkin-details-modal .aksrt-modal-runtime__content{padding:0}.aksrt-modal-runtime.aksrt-checkin-details-modal .aksrt-modal-runtime__skeleton{padding:24px 18px}.ajaxpager.is-ajax-loading{position:relative;min-height:80px}.post_ajax_loader{display:none}.post_ajax_loader.is-loading{display:block;animation:aksrtFadeIn .2s ease both}.ajax-item.is-fading{opacity:.35;transition:opacity .15s ease}.aksrt-report-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog{width:min(600px,calc(100vw - 32px));border-radius:var(--main-radius, 8px);background:var(--main-bg-color, #fff)}.aksrt-report-modal .edit-header{position:relative;padding-right:32px}.aksrt-report-modal .edit-header .close{position:absolute;right:0;top:50%;transform:translateY(-50%);float:none;font-size:20px;line-height:1;cursor:pointer;background:none;border:0;padding:2px 6px}.aksrt-report-modal .form-but-radio .badg{cursor:pointer;user-select:none}.aksrt-user-ban-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog{width:min(600px,calc(100vw - 32px));border-radius:var(--main-radius, 8px);background:var(--main-bg-color, #fff)}.aksrt-user-ban-info-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog,.aksrt-user-ban-appeal-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog,.aksrt-user-ban-log-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog{width:min(400px,calc(100vw - 32px));border-radius:var(--main-radius, 8px);background:var(--main-bg-color, #fff)}.aksrt-user-ban-info-modal .aksrt-modal-runtime__skeleton,.aksrt-user-ban-appeal-modal .aksrt-modal-runtime__skeleton,.aksrt-user-ban-log-modal .aksrt-modal-runtime__skeleton,.aksrt-user-ban-modal .aksrt-modal-runtime__skeleton{padding:24px 18px}.aksrt-user-ban-modal .edit-header,.aksrt-user-ban-appeal-modal .edit-header{position:relative;padding-right:32px}.aksrt-user-ban-modal .edit-header .close,.aksrt-user-ban-appeal-modal .edit-header .close{position:absolute;right:0;top:50%;transform:translateY(-50%);float:none;font-size:20px;line-height:1;cursor:pointer;background:none;border:0;padding:2px 6px}.aksrt-user-ban-modal .modal-colorful-header,.aksrt-user-ban-modal .colorful-bg,.aksrt-user-ban-info-modal .modal-colorful-header,.aksrt-user-ban-info-modal .colorful-bg,.aksrt-user-ban-log-modal .modal-colorful-header,.aksrt-user-ban-log-modal .colorful-bg{position:relative;overflow:hidden;color:var(--this-color);background:var(--this-bg);background-size:120%}.aksrt-user-ban-modal .modal-colorful-header,.aksrt-user-ban-info-modal .modal-colorful-header,.aksrt-user-ban-log-modal .modal-colorful-header{position:absolute;top:0;right:0;left:0;display:flex;align-items:center;justify-content:center;height:120px;border-radius:var(--main-radius, 8px) var(--main-radius, 8px) 0 0}.aksrt-user-ban-modal .modal-colorful-header+*,.aksrt-user-ban-info-modal .modal-colorful-header+*,.aksrt-user-ban-log-modal .modal-colorful-header+*{margin-top:120px}.aksrt-user-ban-modal .modal-colorful-header>.close,.aksrt-user-ban-info-modal .modal-colorful-header>.close,.aksrt-user-ban-log-modal .modal-colorful-header>.close{position:absolute;top:10px;right:10px;z-index:9;color:var(--this-color);font-size:18px;opacity:.6}.aksrt-user-ban-modal .colorful-bg>.colorful-make,.aksrt-user-ban-modal .colorful-bg>.colorful-make:before,.aksrt-user-ban-modal .colorful-bg>.colorful-make:after,.aksrt-user-ban-info-modal .colorful-bg>.colorful-make,.aksrt-user-ban-info-modal .colorful-bg>.colorful-make:before,.aksrt-user-ban-info-modal .colorful-bg>.colorful-make:after,.aksrt-user-ban-log-modal .colorful-bg>.colorful-make,.aksrt-user-ban-log-modal .colorful-bg>.colorful-make:before,.aksrt-user-ban-log-modal .colorful-bg>.colorful-make:after{position:absolute;color:var(--this-color);background:var(--this-bg);border-radius:100%;content:""}.aksrt-user-ban-modal .colorful-bg>.colorful-make,.aksrt-user-ban-info-modal .colorful-bg>.colorful-make,.aksrt-user-ban-log-modal .colorful-bg>.colorful-make{top:60%;bottom:0;width:323px;height:290px;animation:aksrt-ban-colorful 120s linear infinite}.aksrt-user-ban-modal .colorful-bg>.colorful-make:before,.aksrt-user-ban-info-modal .colorful-bg>.colorful-make:before,.aksrt-user-ban-log-modal .colorful-bg>.colorful-make:before{top:-325px;right:-558px;bottom:0;width:677px;height:300px;opacity:.6;transform:rotate(341deg)}.aksrt-user-ban-modal .colorful-bg>.colorful-make:after,.aksrt-user-ban-info-modal .colorful-bg>.colorful-make:after,.aksrt-user-ban-log-modal .colorful-bg>.colorful-make:after{top:-170px;bottom:0;left:-262px;width:402px;height:214px;opacity:.4;transform:rotate(326deg)}.aksrt-user-ban-modal .colorful-bg>.colorful-make+*,.aksrt-user-ban-info-modal .colorful-bg>.colorful-make+*,.aksrt-user-ban-log-modal .colorful-bg>.colorful-make+*{position:relative}.aksrt-user-ban-modal .form-but-radio label{cursor:pointer}.aksrt-user-ban-modal .form-but-radio .but-radio,.aksrt-user-ban-modal .aksrt-user-ban-form label{user-select:none}@keyframes aksrt-ban-colorful{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.order-comment-from .shop-score-box{display:flex;align-items:center;gap:8px;min-height:36px}.order-comment-from .score-title{width:4.5em;flex:0 0 auto;font-size:13px;color:var(--color-ink-2, #6b7280)}.order-comment-from .score-icon{display:inline-flex;gap:4px;align-items:center;height:30px}.order-comment-from .icon-star{display:inline-flex;width:22px;height:22px;color:#d1d5db;cursor:pointer;transition:color .12s ease,transform .12s ease}.order-comment-from .icon-star svg{width:22px;height:22px;fill:currentColor}.order-comment-from .icon-star.active,.order-comment-from .icon-star.hover{color:#fc7049}.order-comment-from .icon-star.hover{transform:scale(1.08)}.order-comment-from .score-text{min-width:3em;font-size:13px;color:var(--color-ink-2, #6b7280)}.order-comment-from textarea.form-control{width:100%;border:1px solid var(--color-line, #e5e7eb);border-radius:10px;padding:10px 12px;font-size:14px;resize:vertical;min-height:96px;background:var(--surface-1, #fff);color:var(--color-ink, #111)}:is(.order-comment-from,.aksrt-report-modal,.aksrt-after-sale-apply-modal) .form-upload .preview{display:flex;flex-wrap:wrap;gap:8px}:is(.order-comment-from,.aksrt-report-modal) .form-upload .preview .add,:is(.order-comment-from,.aksrt-report-modal,.aksrt-after-sale-apply-modal) .form-upload .preview .add,:is(.order-comment-from,.aksrt-report-modal,.aksrt-after-sale-apply-modal) .form-upload .preview .preview-item{width:80px;height:80px;border-radius:10px;border:1px dashed var(--color-line, #d1d5db);background:var(--surface-4, #f9fafb);position:relative;overflow:hidden;cursor:pointer}:is(.order-comment-from,.aksrt-report-modal,.aksrt-after-sale-apply-modal) .form-upload .preview .preview-item img{width:100%;height:100%;object-fit:cover;display:block}:is(.order-comment-from,.aksrt-report-modal,.aksrt-after-sale-apply-modal) .form-upload .preview .preview-item .remove{position:absolute;top:2px;right:2px;width:20px;height:20px;border:0;border-radius:999px;background:#0000008c;color:#fff;font-size:12px;line-height:1;cursor:pointer}:is(.order-comment-from,.aksrt-report-modal,.aksrt-after-sale-apply-modal) .form-upload .hide{display:none!important}.order-comment-from .review-error{color:#ef4444;font-size:13px;margin-top:8px}@media(max-width:768px){.aksrt-modal-runtime:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog{width:calc(100vw - 20px)}}.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}.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:.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:.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:.4;transform:rotate(326deg)}.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)}.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}.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, .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)}.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, .06))}.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)}.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}.notyn{position:fixed;bottom:6em;right:0;max-width:320px;z-index:10000000}.notyf{position:relative;margin-bottom:.6em;color:#fff;font-size:14px;line-height:1.42857143;float:right;min-width:200px;padding:1em 3em 1em 3.4em;box-shadow:-3px 3px 6px #0000001a;transform:translate(1.5em);transition:.3s;-webkit-backdrop-filter:saturate(5) blur(20px);backdrop-filter:saturate(5) blur(20px);-webkit-animation:notyf .4s cubic-bezier(.35,.71,.46,1.08);animation:notyf .4s cubic-bezier(.35,.71,.46,1.08)}.notyn-out .notyf{transform:translate(110%);opacity:.5;transition:.5s}@-webkit-keyframes notyf{0%{transform:translate(110%);opacity:0}to{transform:translate(1.5em);opacity:1}}@keyframes notyf{0%{transform:translate(110%);opacity:0}to{transform:translate(1.5em);opacity:1}}.notyf:before{font-family:"Font Awesome 5 Free";font-style:normal;font-weight:900;line-height:1;left:.8em;font-size:1.5em;position:absolute;top:0;bottom:0;margin:auto;height:20px}.notyf.success{background:linear-gradient(90deg,#0f93f9b3,#3dbdf9cc)}.notyf.success:before{content:""}.notyf.info{background:linear-gradient(90deg,#3aa236cc,#59f783cc)}.notyf.info:before{content:""}.notyf.warning,.notyf.load{background:linear-gradient(90deg,#fdaa47b3,#f79a0dcc)}.notyf.warning:before{content:""}.notyf.danger{background:linear-gradient(90deg,#fd451cb3,#fb6e4bcc)}.notyf.danger:before{content:""}.notyf .fa-close{float:right;padding:.6em;margin:-.4em}.notyf.load:before{content:"";width:.7em;height:.7em;display:inline-block;border:.08em solid transparent;border-width:calc(1px + .03em);border-radius:50%;border-top-color:var(--this-color);border-bottom-color:var(--this-color);-webkit-animation:huan-rotate 1s cubic-bezier(.7,.1,.31,.9) infinite;animation:huan-rotate 1s cubic-bezier(.7,.1,.31,.9) infinite}@-webkit-keyframes huan-rotate{0%{transform:rotate(0)}to{transform:rotate(360deg)}}@keyframes huan-rotate{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.aksrt-captcha-box{display:none}.aksrt-captcha-wrap{text-align:center}.aksrt-captcha-modal-overlay{position:fixed;inset:0;z-index:99999;display:flex;align-items:center;justify-content:center}.aksrt-captcha-modal-backdrop{position:absolute;inset:0;background:#0000008c;animation:aksrtFadeIn .2s ease}.aksrt-captcha-modal-box{position:relative;background:var(--surface-page,#fff);border-radius:12px;box-shadow:0 20px 60px #0000004d;width:90%;max-width:380px;animation:aksrtSlideUp .25s ease;overflow:hidden}.aksrt-captcha-modal-header{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid var(--color-line,#e5e7eb)}.aksrt-captcha-modal-title{font-size:15px;font-weight:600;color:var(--color-ink,#1f2937)}.aksrt-captcha-modal-close{background:none;border:none;font-size:22px;color:var(--color-ink-4,#9ca3af);cursor:pointer;padding:0;line-height:1;width:28px;height:28px;display:flex;align-items:center;justify-content:center;border-radius:6px}.aksrt-captcha-modal-close:hover{background:var(--color-bg-hover,#f3f4f6);color:var(--color-ink,#1f2937)}.aksrt-captcha-modal-body{padding:20px}.aksrt-captcha-modal-hint{font-size:13px;color:var(--color-ink-4,#9ca3af);margin:0 0 16px;text-align:center}.aksrt-captcha-modal-widget{display:flex;justify-content:center}@keyframes aksrtFadeIn{0%{opacity:0}to{opacity:1}}@keyframes aksrtSlideUp{0%{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}.aksrt-modal-runtime.aksrt-reward-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog{width:min(400px,calc(100vw - 32px));border-radius:var(--main-radius, 8px);background:var(--main-bg-color, #fff)}.aksrt-modal-runtime.aksrt-reward-modal .aksrt-modal-runtime__content{padding:0}.aksrt-reward-modal .modal-colorful-header+.aksrt-reward__body{margin-top:0;padding:135px 15px 15px;color:var(--main-color, var(--color-ink, #333))}.aksrt-reward__desc{margin:0 0 16px;text-align:center}.aksrt-modal-runtime.aksrt-reward-modal .aksrt-reward__amounts{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin:16px 0}.aksrt-modal-runtime.aksrt-reward-modal .aksrt-reward__amount{display:inline-flex;width:100%;min-width:0;min-height:46px;align-items:center;justify-content:center;gap:4px;margin:0;padding:8px 6px;border:1px solid var(--color-line-stronger, rgba(50, 50, 50, .12));border-radius:var(--radius, 8px);background:var(--surface-2, #f5f6f7);color:var(--color-ink-2, #4e5358);cursor:pointer;font-size:16px;font-weight:700;line-height:1.2;box-shadow:none;transition:border-color .2s ease,background-color .2s ease,color .2s ease,box-shadow .2s ease,transform .2s ease}.aksrt-modal-runtime.aksrt-reward-modal .aksrt-reward__amount:hover{border-color:var(--focus-color, var(--color-primary, #f04494));background:var(--color-primary-soft, rgba(240, 68, 148, .1));color:var(--focus-color, var(--color-primary, #f04494));opacity:1;transform:translateY(-1px)}.aksrt-modal-runtime.aksrt-reward-modal .aksrt-reward__amount:focus-visible{outline:0;box-shadow:0 0 0 3px var(--color-primary-soft, rgba(240, 68, 148, .2))}.aksrt-modal-runtime.aksrt-reward-modal .aksrt-reward__amount.is-active,.aksrt-modal-runtime.aksrt-reward-modal .aksrt-reward__amount.is-active:hover{border-color:var(--focus-color, var(--color-primary, #f04494));background:var(--focus-color, var(--color-primary, #f04494));color:#fff;box-shadow:0 3px 10px var(--color-primary-soft, rgba(240, 68, 148, .2));opacity:1;transform:none}.aksrt-reward__currency{font-size:13px;font-weight:400}.aksrt-reward__value{font-size:15px}.aksrt-modal-runtime.aksrt-reward-modal .aksrt-reward__custom{display:flex;width:100%;align-items:center;justify-content:space-between;gap:12px;margin:0 0 16px;color:var(--color-ink-2, #4e5358);font-size:14px;font-weight:600}.aksrt-reward__custom-label{flex:0 0 auto}.aksrt-modal-runtime.aksrt-reward-modal .aksrt-reward__input-wrap{display:flex;width:min(190px,62%);min-height:44px;align-items:center;padding:0 10px 0 12px;border:1px solid var(--color-line-stronger, rgba(50, 50, 50, .12));border-radius:var(--radius, 8px);background:var(--surface-1, #fff);transition:border-color .2s ease,box-shadow .2s ease}.aksrt-modal-runtime.aksrt-reward-modal .aksrt-reward__input-wrap:focus-within{border-color:var(--focus-color, var(--color-primary, #f04494));box-shadow:0 0 0 3px var(--color-primary-soft, rgba(240, 68, 148, .1))}.aksrt-reward__input-currency{flex:0 0 auto;margin-right:6px;color:var(--muted-color, var(--color-ink-3, #777));font-size:14px;font-weight:600}.aksrt-modal-runtime.aksrt-reward-modal .aksrt-reward__input{width:100%;min-width:0;min-height:42px;padding:0;border:0;outline:0;background:transparent;color:var(--color-ink, #333);font-size:15px;font-weight:600;text-align:right;-moz-appearance:textfield;box-shadow:none}.aksrt-reward__input::-webkit-inner-spin-button,.aksrt-reward__input::-webkit-outer-spin-button{margin:0;-webkit-appearance:none}.aksrt-reward__input::placeholder{color:var(--muted-color, var(--color-ink-4, #999));opacity:1}.aksrt-reward__input:disabled,.aksrt-reward__amount:disabled{cursor:wait;opacity:.6}.aksrt-reward__submit{min-height:42px}.aksrt-reward__submit:disabled{cursor:wait;opacity:.7}.aksrt-reward__message{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.aksrt-reward__captcha{margin:0 0 14px}.aksrt-modal-runtime--bottom.aksrt-reward-modal .aksrt-modal-runtime__dialog,.aksrt-modal-runtime--bottom.aksrt-reward-modal .modal-colorful-header{border-radius:16px 16px 0 0}
+: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-grad-ocean-1: #4facfe;--aksrt-grad-ocean-2: #00f2fe;--aksrt-grad-forest-1: #43e97b;--aksrt-grad-forest-2: #38f9d7;--aksrt-grad-night-1: #a18cd1;--aksrt-grad-night-2: #fbc2eb;--aksrt-grad-fire-1: #f83600;--aksrt-grad-fire-2: #f9d423;--aksrt-toggle-w: 44px;--aksrt-toggle-h: 24px;--aksrt-stepper-h: 36px;--aksrt-badge-h: 22px;--text-1: var(--color-ink);--text-2: var(--color-ink-2);--text-3: var(--color-ink-3);--border: var(--color-line);--color-text-1: var(--color-ink);--color-text-2: var(--color-ink-2);--color-text-3: var(--color-ink-3);--color-primary-bg: var(--color-primary-softer);--color-danger: var(--color-error)}@supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))){:root{--blur-bg: var(--surface-1);--header-bg: var(--surface-1)}}@supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))){html.dark,body.dark-theme{--blur-bg: #37383a;--header-bg: #37383a}}body.modal-open,body.dialog-open{--blur-bg: var(--surface-1);--header-bg: var(--surface-1)}body.modal-open .site-nav,body.modal-open .footer-tabbar,body.modal-open .float-right .float-btn,body.dialog-open .site-nav,body.dialog-open .footer-tabbar,body.dialog-open .float-right .float-btn{backdrop-filter:none!important;-webkit-backdrop-filter:none!important}html.dark body.modal-open,html.dark body.dialog-open,body.dark-theme.modal-open,body.dark-theme.dialog-open{--blur-bg: #37383a;--header-bg: #37383a}@media(min-width:992px){:root{--container: 960px}}@media(min-width:1200px){:root{--container: 1140px}}@media(min-width:1400px){:root{--container: 1200px}}html.dark,body.dark-theme{--color-ink: #f8fafc;--color-ink-2: #e5eef7;--color-ink-3: #b4b6bb;--color-ink-4: #888a8f;--surface-page: #292a2d;--surface-1: #323335;--surface-2: #2d2e31;--surface-3: #3a3c42;--surface-4: #26282d;--color-line: rgba(114, 114, 114, .1);--color-line-soft: rgba(184, 184, 184, .02);--color-line-stronger: rgba(255, 255, 255, .12);--color-primary-soft: rgba(240, 68, 148, .16);--color-primary-softer: rgba(240, 68, 148, .08);--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);--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);--shadow-xl: 0 0 10px rgba(24, 24, 24, .18);--shadow-xs: 0 1px 2px rgba(0, 0, 0, .18);--shadow-sm: 0 0 10px rgba(24, 24, 24, .1);--shadow: 0 0 10px rgba(24, 24, 24, .14);--shadow-lg: 0 14px 36px rgba(0, 0, 0, .32);--shadow-drawer: 0 20px 60px rgba(0, 0, 0, .6);--blur-bg: rgba(50, 51, 53, .8);--float-btn-bg: rgba(70, 70, 70, .4);--header-bg: var(--blur-bg);--footer-bg: var(--surface-1);--footer-color: var(--color-ink-4);--shadow-md: var(--shadow);--aksrt-rose-soft: rgba(244, 63, 94, .18);--aksrt-blue-soft: rgba(59, 130, 246, .18);--aksrt-green-soft: rgba(16, 185, 129, .18);--aksrt-orange-soft:rgba(249, 115, 22, .18);--aksrt-purple-soft:rgba(139, 92, 246, .18);--aksrt-cyan-soft: rgba(6, 182, 212, .18);--aksrt-yellow-soft:rgba(234, 179, 8, .18);--aksrt-red-soft: rgba(239, 68, 68, .18);--aksrt-teal-soft: rgba(20, 184, 166, .18);--aksrt-indigo-soft:rgba(99, 102, 241, .18);--aksrt-pink-soft: rgba(236, 72, 153, .18)}html.dark .site-nav,body.dark-theme .site-nav{border-bottom-color:var(--color-line);box-shadow:none}html.dark .footer-tabbar,body.dark-theme .footer-tabbar{background:#323335cc;box-shadow:0 -1px 3px #0003}html.dark .n-drawer,html.dark .n-modal,html.dark .n-dialog,body.dark-theme .n-drawer,body.dark-theme .n-modal,body.dark-theme .n-dialog{background:var(--surface-1)}html.dark .post-card,html.dark .widget,html.dark .aksrt-theme-box,html.dark .theme-box,body.dark-theme .post-card,body.dark-theme .widget,body.dark-theme .aksrt-theme-box,body.dark-theme .theme-box{border-color:transparent}*,*:before,*:after{box-sizing:border-box}html{font-size:16px;scroll-behavior:smooth;-webkit-text-size-adjust:100%;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;overflow-x:hidden;text-rendering:optimizeLegibility}body{margin:0;font-family:var(--font-body);font-size:var(--text-base);line-height:var(--leading-normal);background-color:var(--surface-page);color:var(--color-ink-2);transition:background-color .25s ease,color .25s ease;overflow-x:hidden}img,picture,video,canvas{display:block;max-width:100%;height:auto}input,button,textarea,select{font:inherit;color:inherit}button{background:none;border:0;padding:0;cursor:pointer}a{color:inherit;text-decoration:none;transition:color var(--dur-fast) var(--ease)}a:hover{color:var(--color-primary)}:focus{outline:none}::selection{background:var(--color-primary-soft);color:var(--color-ink)}.screen-reader-text,.sr-only{position:absolute!important;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.screen-reader-text:focus,.sr-only:focus{position:fixed!important;top:16px;left:16px;width:auto;height:auto;padding:8px 16px;background:var(--surface-1);color:var(--color-ink);z-index:100000;clip:auto;box-shadow:var(--shadow-lg);border-radius:var(--radius)}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important;scroll-behavior:auto!important}}h1,h2,h3,h4,h5,h6{font-weight:700;margin:20px 0 10px;color:var(--key-color, #333)}h1{font-size:22px}h2{font-size:20px}h3{font-size:18px}h4{font-size:16px}h5{font-size:15px}h6{font-size:14px}.wp-posts-content,.entry-content,.prose{font-size:16px;line-height:2;color:var(--main-color, #4e5358);min-height:90px}.wp-posts-content h1,.wp-posts-content h2,.wp-posts-content h3,.wp-posts-content h4,.wp-posts-content h5,.entry-content h1,.entry-content h2,.entry-content h3,.entry-content h4,.entry-content h5,.prose h1,.prose h2,.prose h3,.prose h4,.prose h5{font-weight:700;margin:20px 0 10px;color:var(--key-color, #333)}.wp-posts-content h1,.entry-content h1,.prose h1{font-size:22px}.wp-posts-content h2,.entry-content h2,.prose h2{font-size:20px}.wp-posts-content h3,.entry-content h3,.prose h3{font-size:18px}.wp-posts-content h4,.entry-content h4,.prose h4{font-size:16px}.wp-posts-content p,.entry-content p,.prose p{margin:0 0 20px;word-wrap:break-word}.wp-posts-content a,.entry-content a,.prose a{color:var(--focus-color, #f04494);text-decoration:none}.wp-posts-content a:hover,.entry-content a:hover,.prose a:hover{color:var(--focus-color, #f04494)}.wp-posts-content img,.entry-content img,.prose img{max-width:100%;height:auto;display:block;margin:auto}.wp-posts-content code:not(pre code),.entry-content code:not(pre code),.prose code:not(pre code){color:#ff3c98;background:#fd999933;font-family:var(--font-mono, monospace);font-size:.9em}.wp-posts-content pre,.entry-content pre,.prose pre{position:relative;background:#282c34;color:#abb2bf;padding:15px;border-radius:var(--main-radius, 8px);overflow-x:auto;font-size:14px;line-height:1.5;margin:0 0 20px}.wp-posts-content pre code,.entry-content pre code,.prose pre code{color:inherit;background:none;padding:0;font-size:inherit}.wp-posts-content table,.entry-content table,.prose table{font-size:14px;width:100%;border-collapse:collapse;margin:0 0 20px}.wp-posts-content th,.wp-posts-content td,.entry-content th,.entry-content td,.prose th,.prose td{border:1px solid var(--main-border-color, rgba(50, 50, 50, .06));padding:8px}.wp-posts-content th,.entry-content th,.prose th{background:var(--muted-border-color, rgba(0, 0, 0, .03));font-weight:700}.wp-posts-content tr:hover,.entry-content tr:hover,.prose tr:hover{background-color:#f044940d}.wp-posts-content ul,.wp-posts-content ol,.entry-content ul,.entry-content ol,.prose ul,.prose ol{margin:10px 0 20px 15px;padding-left:15px}.wp-posts-content li,.entry-content li,.prose li{margin-bottom:10px}.wp-posts-content blockquote,.entry-content blockquote,.prose blockquote{padding:15px 20px;margin:0 0 20px;border-left:4px solid var(--main-border-color, rgba(50, 50, 50, .06));background:var(--muted-border-color, rgba(0, 0, 0, .03));font-size:16px;color:var(--muted-color, #777);border-radius:0}.wp-posts-content blockquote p:last-child,.entry-content blockquote p:last-child,.prose blockquote p:last-child{margin-bottom:0}hr{border-color:var(--main-border-color, rgba(50, 50, 50, .06))}strong,b{font-weight:700}.btn,.btn-primary,.btn-outline,.btn-ghost,.btn-danger{--btn-h: 34px;--btn-px: 14px;display:inline-flex;align-items:center;justify-content:center;gap:6px;height:var(--btn-h);padding:0 var(--btn-px);font-size:var(--text-sm);font-weight:400;line-height:1.44;border:1px solid transparent;border-radius:var(--radius-btn);cursor:pointer;white-space:nowrap;user-select:none;text-decoration:none;box-shadow:none;transition:opacity .15s ease,background .15s ease,border-color .15s ease,color .15s ease,box-shadow .15s ease}.btn-sm{--btn-h: 28px;--btn-px: 8px;font-size:var(--text-xs)}.btn-lg{--btn-h: 44px;--btn-px: 18px;font-size:var(--text-md)}.btn{background:#8888881a;color:#888;border-color:transparent}.btn:hover{opacity:.8;color:#888}.btn-primary{background:var(--color-primary);color:var(--color-on-primary);border-color:var(--color-primary);box-shadow:none}.btn-primary:hover{opacity:.8;color:var(--color-on-primary);background:var(--color-primary);border-color:var(--color-primary);transform:none;box-shadow:none}.btn-primary:active{opacity:1;box-shadow:inset 0 2px 3px #00000014}.btn-outline{background:transparent;color:var(--color-ink-2);border-color:var(--color-ink-2)}.btn-outline:hover{opacity:.8;background:transparent;border-color:var(--color-ink-2);color:var(--color-ink-2)}.btn-ghost{background:transparent;color:var(--color-ink-2);border-color:transparent}.btn-ghost:hover{background:#8888881a;opacity:.8}.btn-danger{background:var(--color-error);color:#fff;border-color:var(--color-error)}.btn-danger:hover{opacity:.8;color:#fff}.btn:disabled,.btn-primary:disabled,.btn-outline:disabled,.btn-ghost:disabled,.btn-danger:disabled{opacity:.5;cursor:not-allowed;pointer-events:none}.btn-icon{width:var(--btn-h);padding:0;justify-content:center}.btn-icon.btn-sm{width:28px}.btn-icon.btn-lg{width:44px}.btn-block{display:block;width:100%}.but,.badg{display:inline-block;vertical-align:middle;padding:.3em .6em;text-align:center;font-weight:400;line-height:1.44;border-radius:4px;border:1px solid var(--this-border);box-shadow:var(--this-shadow);background:var(--this-bg);color:var(--this-color);--main-color: var(--this-color);--this-bg: rgba(136, 136, 136, .1);--this-border: transparent;--this-shadow: none;--this-color: #888;text-shadow:0 0 0;transition:.15s;cursor:pointer}.but a{color:inherit}.but .icon,.but>.fa:not(.fa-angle-right),.but>i[class*=fa-]:not(.fa-angle-right){margin-right:.25em}.but.block{display:block}.but:focus,.but:hover{opacity:.8;color:var(--this-color)}.but:active{--this-shadow: inset 0 2px 3px rgba(0, 0, 0, .08)}.but.radius,.badg.radius,.radius>.but{border-radius:50px;padding:.3em 1em}.but.main-radius,.badg.main-radius{border-radius:var(--main-radius)}.but.mini-radius,.badg.mini-radius{border-radius:var(--mini-radius)}.but.hollow,.badg.hollow{background:transparent;--this-border: var(--this-color)}.but.hollow:hover{background:var(--this-bg)}.badg.hollow[class*=b-],.but.hollow[class*=b-]{background:0 0;--this-border: var(--this-bg);--this-color: var(--this-bg)}.but.hollow[class*=b-]:hover{color:#fff}.but.hollow.c-white:hover{background:0 0;--this-color: var(--focus-color);color:var(--focus-color)}.circular,.badg.cir,.but.cir{padding:0;border-radius:5em;--this-size: 1.8em;display:inline-flex;justify-content:center;align-items:center;height:var(--this-size);min-width:var(--this-size)}.circular.radius4,.badg.cir.radius4,.but.cir.radius4{border-radius:4px}.but.cir svg.icon,.but.cir>.fa,.but.cir>.fa:not(.fa-angle-right),.but.cir>i[class*=fa-]:not(.fa-angle-right){margin:0}.but-group{display:inline-flex}.but-group>.but{border-radius:0;margin:0}.but-group>.but:first-of-type{border-radius:4px 0 0 4px}.but-group>.but:last-of-type{border-radius:0 4px 4px 0}.but-average{display:flex;border-radius:4px;overflow:hidden}.but-average.inline{display:inline-flex}.but-average.radius{border-radius:100px}.but-average.main-radius{border-radius:var(--main-radius)}.but-average.mini-radius{border-radius:var(--mini-radius)}.but-average>.but{margin:0!important;border-radius:0;flex:auto}.but-average>.but.active{--this-bg: var(--focus-color-opacity1);--this-color: var(--focus-color)}.but.but-sm{font-size:.9em;padding:.1em .4em;vertical-align:.05em}.but.badg-sm,.badg.badg-sm{font-size:.8em;padding:0 .2em;vertical-align:.1em;min-width:1.6em}.badg.badg-sm.cir{--this-size: 1.6em}.padding-lg,.badg.badg-lg,.but.padding-lg{padding:.5em 2em}.active.badg,.active>.badg:not(.vip-tag){--this-bg: var(--focus-color);--this-color: #fff}.active.badg.hollow,.active>.hollow:not(.vip-tag){--this-color: var(--focus-color)}.toggle-radius{background:var(--this-bg);width:29px;height:29px;display:inline-block;text-align:center;border-radius:100%;position:relative;vertical-align:text-top;--this-bg: var(--main-border-color)}.toggle-radius .fa,.toggle-radius .icon,.toggle-radius>i{position:absolute;top:50%;right:0!important;left:0!important;margin:auto;transform:translateY(-50%);line-height:1;width:1em;height:1em;display:inline-flex;align-items:center;justify-content:center;text-align:center}.b-gray,.b-theme,.b-red,.b-yellow,.b-blue,.b-blue-2,.b-black,.b-cyan,.b-green,.b-purple,.jb-red,.jb-pink,.jb-yellow,.jb-blue,.jb-green,.jb-purple,.jb-vip1,.jb-vip2{color:var(--this-color);background:var(--this-bg);--this-color: #fff}.jb-red,.jb-pink,.jb-yellow,.jb-blue,.jb-green,.jb-purple,.jb-vip1,.jb-vip2{border:none}.b-theme{--this-bg: var(--focus-color)}.b-gray{--this-bg: #818b95}.b-red{--this-bg: #ff5473}.b-yellow{--this-bg: #ff6f06}.b-cyan{--this-bg: #08c4c1}.b-blue{--this-bg: #2997f7}.b-blue-2{--this-bg: #5c7cff}.b-green{--this-bg: #12b928}.b-purple{--this-bg: #d448f5}.b-black{--this-bg: #121517}.jb-red{--this-bg: linear-gradient(135deg, #fd7a64 10%, #fb2d2d 100%)}.jb-yellow{--this-bg: linear-gradient(135deg, #f59f54 10%, #ff6922 100%)}.jb-blue{--this-bg: linear-gradient(135deg, #59c3fb 10%, #268df7 100%)}.jb-green{--this-bg: linear-gradient(135deg, #60e464 10%, #5cb85b 100%)}.jb-purple{--this-bg: linear-gradient(135deg, #f98dfb 10%, #ea00f9 100%)}.jb-pink{--this-bg: linear-gradient(135deg, #ff5e7f 30%, #ff967e 100%)}.jb-vip1{--this-bg: linear-gradient(25deg, #eabe7b 10%, #f5e3c7 70%, #edc788 100%);--this-color: #866127}.jb-vip2{--this-bg: linear-gradient(317deg, #4d4c4c 30%, #7b7b7b 70%, #5f5c5c 100%);--this-color: #ddd}.c-theme,.c-gray,.c-white,.c-red,.c-red-2,.c-yellow,.c-blue,.c-blue-2,.c-green,.c-cyan,.c-purple{color:var(--this-color)}.c-theme{--this-color: var(--focus-color);--this-bg: var(--focus-color-opacity1)}.c-gray{--this-color: #888;--this-bg: rgba(136, 136, 136, .1)}.c-white{--this-color: #fff;--this-bg: rgba(255, 255, 255, .1)}.c-red{--this-color: #ff5473;--this-bg: rgba(255, 84, 115, .1)}.c-red-2{--this-color: #d6064c;--this-bg: rgba(194, 41, 46, .1)}.c-yellow{--this-color: #ff6f06;--this-bg: rgba(255, 111, 6, .1)}.c-blue{--this-color: #2997f7;--this-bg: rgba(41, 151, 247, .1)}.field{display:flex;flex-direction:column;gap:6px;margin-bottom:var(--space-4)}.field-label{font-size:var(--text-sm);font-weight:600;color:var(--color-ink)}.field-help{font-size:var(--text-xs);color:var(--color-ink-3)}.field-error{font-size:var(--text-xs);color:var(--color-error)}.input,.textarea,.select,input[type=text].form-input,input[type=email].form-input,input[type=password].form-input,input[type=search].form-input,input[type=url].form-input,input[type=tel].form-input,textarea.form-textarea,select.form-select{width:100%;height:34px;padding:6px 12px;font-size:14px;color:var(--color-ink);background:var(--surface-page);border:1px solid transparent;border-radius:var(--radius);transition:border-color var(--dur-fast) var(--ease),box-shadow var(--dur-fast) var(--ease)}.textarea,textarea.form-textarea{height:auto;min-height:120px;padding:12px;line-height:var(--leading-snug);resize:vertical}.input:hover,.textarea:hover,.select:hover{border-color:var(--color-primary)}.input:focus,.textarea:focus,.select:focus{outline:none;border-color:var(--color-primary);box-shadow:0 0 2px 3px #fd53a166}.input::placeholder,.textarea::placeholder{color:var(--color-ink-4)}.select,select.form-select{appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23777' d='M6 8L1 3h10z'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center;padding-right:32px}.checkbox,.radio{display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-size:var(--text-sm);color:var(--color-ink-2)}.checkbox input,.radio input{width:16px;height:16px;accent-color:var(--color-primary);cursor:pointer}.input-group{display:flex;align-items:stretch}.input-group .input{border-radius:var(--radius) 0 0 var(--radius)}.input-group .btn{border-radius:0 var(--radius) var(--radius) 0}.form-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:var(--space-4)}.form-actions{display:flex;gap:var(--space-3);margin-top:var(--space-5)}.alert{display:flex;align-items:flex-start;gap:10px;padding:12px 14px;margin-bottom:var(--space-4);font-size:var(--text-sm);line-height:var(--leading-snug);border-radius:var(--radius);border:1px solid transparent}.alert>svg{flex-shrink:0;width:18px;height:18px;margin-top:1px}.alert--error{color:#b91c1c;background:#ef444414;border-color:#ef44444d}.alert--warning{color:#b45309;background:#f59e0b14;border-color:#f59e0b4d}.alert--success{color:#047857;background:#10b98114;border-color:#10b9814d}.alert--info{color:#1d4ed8;background:#3b82f614;border-color:#3b82f64d}html.dark .alert--error{color:#fca5a5}html.dark .alert--warning{color:#fcd34d}html.dark .alert--success{color:#6ee7b7}html.dark .alert--info{color:#93c5fd}@keyframes aksrtFadeInUp{0%{opacity:0;transform:translateY(20px)}to{opacity:1;transform:none}}@keyframes aksrtFadeInDown{0%{opacity:0;transform:translateY(-20px)}to{opacity:1;transform:none}}@keyframes aksrtFadeInLeft{0%{opacity:0;transform:translate(-20px)}to{opacity:1;transform:none}}@keyframes aksrtFadeInRight{0%{opacity:0;transform:translate(20px)}to{opacity:1;transform:none}}@keyframes aksrtZoomIn{0%{opacity:0;transform:scale(.85)}to{opacity:1;transform:none}}@keyframes aksrtZoomInUp{0%{opacity:0;transform:scale(.85) translateY(20px)}to{opacity:1;transform:none}}@keyframes aksrtBounceIn{0%{opacity:0;transform:scale(.3)}50%{opacity:1;transform:scale(1.08)}70%{transform:scale(.95)}to{opacity:1;transform:none}}@keyframes aksrtBounce{0%,20%,50%,80%,to{transform:translateY(0)}40%{transform:translateY(-12px)}60%{transform:translateY(-6px)}}@keyframes aksrtPulse{0%{transform:scale(1)}50%{transform:scale(1.06)}to{transform:scale(1)}}@keyframes aksrtFlash{0%,50%,to{opacity:1}25%,75%{opacity:0}}@keyframes aksrtRubberBand{0%{transform:scale(1)}30%{transform:scaleX(1.25) scaleY(.75)}40%{transform:scaleX(.75) scaleY(1.25)}50%{transform:scaleX(1.15) scaleY(.85)}65%{transform:scaleX(.95) scaleY(1.05)}75%{transform:scaleX(1.05) scaleY(.95)}to{transform:scale(1)}}@keyframes aksrtShake{0%,to{transform:translate(0)}10%,30%,50%,70%,90%{transform:translate(-4px)}20%,40%,60%,80%{transform:translate(4px)}}@keyframes aksrtSwing{20%{transform:rotate(15deg)}40%{transform:rotate(-10deg)}60%{transform:rotate(5deg)}80%{transform:rotate(-5deg)}to{transform:none}}@keyframes aksrtTada{0%{transform:scale(1)}10%,20%{transform:scale(.9) rotate(-3deg)}30%,50%,70%,90%{transform:scale(1.1) rotate(3deg)}40%,60%,80%{transform:scale(1.1) rotate(-3deg)}to{transform:scale(1) rotate(0)}}@keyframes aksrtWobble{0%{transform:none}15%{transform:translate(-25%) rotate(-5deg)}30%{transform:translate(20%) rotate(3deg)}45%{transform:translate(-15%) rotate(-3deg)}60%{transform:translate(10%) rotate(2deg)}75%{transform:translate(-5%) rotate(-1deg)}to{transform:none}}@keyframes aksrtSlideInUp{0%{opacity:0;transform:translateY(100%)}to{opacity:1;transform:none}}@keyframes aksrtSlideInDown{0%{opacity:0;transform:translateY(-100%)}to{opacity:1;transform:none}}@keyframes aksrtSlideInLeft{0%{opacity:0;transform:translate(-100%)}to{opacity:1;transform:none}}@keyframes aksrtSlideInRight{0%{opacity:0;transform:translate(100%)}to{opacity:1;transform:none}}@keyframes aksrtHeartBeat{0%{transform:scale(1)}14%{transform:scale(1.3)}28%{transform:scale(1)}42%{transform:scale(1.3)}70%{transform:scale(1)}to{transform:scale(1)}}@keyframes aksrtJello{0%,to{transform:none}11%{transform:skew(-12.5deg) skewY(-12.5deg)}22%{transform:skew(6.25deg) skewY(6.25deg)}33%{transform:skew(-6.25deg) skewY(-6.25deg)}44%{transform:skew(3.125deg) skewY(3.125deg)}55%{transform:skew(-3.125deg) skewY(-3.125deg)}66%{transform:skew(1.5625deg) skewY(1.5625deg)}77%{transform:skew(-1.5625deg) skewY(-1.5625deg)}88%{transform:skew(.78125deg) skewY(.78125deg)}}@keyframes aksrtHeadShake{0%{transform:translate(0)}6.5%{transform:translate(-6px) rotateY(-9deg)}18.5%{transform:translate(5px) rotateY(7deg)}31.5%{transform:translate(-3px) rotateY(-5deg)}43.5%{transform:translate(2px) rotateY(3deg)}50%{transform:translate(0)}to{transform:translate(0)}}@keyframes aksrtBounceOut{0%{transform:scale(1)}25%{transform:scale(.95)}50%{transform:scale(1.1)}75%{transform:scale(.97)}to{transform:scale(1);opacity:0}}@keyframes aksrtZoomOut{0%{opacity:1;transform:none}to{opacity:0;transform:scale(.85)}}@keyframes aksrtBackInUp{0%{opacity:0;transform:translateY(30px) scale(.7)}80%{opacity:.8;transform:translateY(-5px) scale(1.02)}to{opacity:1;transform:none}}@keyframes aksrtBackInDown{0%{opacity:0;transform:translateY(-30px) scale(.7)}80%{opacity:.8;transform:translateY(5px) scale(1.02)}to{opacity:1;transform:none}}@keyframes aksrtFlip{0%{transform:perspective(400px) rotateY(90deg);opacity:0}40%{transform:perspective(400px) rotateY(-10deg)}70%{transform:perspective(400px) rotateY(10deg)}to{transform:perspective(400px) rotateY(0);opacity:1}}@keyframes aksrtFlipInX{0%{transform:perspective(400px) rotateX(90deg);opacity:0}40%{transform:perspective(400px) rotateX(-10deg)}70%{transform:perspective(400px) rotateX(10deg)}to{transform:perspective(400px) rotateX(0);opacity:1}}.aksrt-anim-fade-in{animation:aksrtFadeIn var(--dur-base, .4s) var(--ease-out, ease) both}.aksrt-anim-fade-up{animation:aksrtFadeInUp var(--dur-base, .4s) var(--ease-out, ease) both}.aksrt-anim-fade-down{animation:aksrtFadeInDown var(--dur-base, .4s) var(--ease-out, ease) both}.aksrt-anim-fade-left{animation:aksrtFadeInLeft var(--dur-base, .4s) var(--ease-out, ease) both}.aksrt-anim-fade-right{animation:aksrtFadeInRight var(--dur-base, .4s) var(--ease-out, ease) both}.aksrt-anim-zoom-in{animation:aksrtZoomIn var(--dur-base, .4s) var(--ease-out, ease) both}.aksrt-anim-zoom-in-up{animation:aksrtZoomInUp var(--dur-base, .4s) var(--ease-out, ease) both}.aksrt-anim-bounce-in{animation:aksrtBounceIn .6s var(--ease-out, ease) both}.aksrt-anim-bounce{animation:aksrtBounce 1s ease both}.aksrt-anim-pulse{animation:aksrtPulse 1s ease infinite}.aksrt-anim-flash{animation:aksrtFlash 1s ease both}.aksrt-anim-rubber-band{animation:aksrtRubberBand .8s ease both}.aksrt-anim-shake{animation:aksrtShake .5s ease both}.aksrt-anim-swing{animation:aksrtSwing .6s var(--ease-out, ease) both}.aksrt-anim-tada{animation:aksrtTada 1s ease both}.aksrt-anim-wobble{animation:aksrtWobble .8s ease both}.aksrt-anim-heart-beat{animation:aksrtHeartBeat 1.3s ease both}.aksrt-anim-jello{animation:aksrtJello .8s ease both}.aksrt-anim-head-shake{animation:aksrtHeadShake .8s ease both}.aksrt-anim-bounce-out{animation:aksrtBounceOut .5s ease both}.aksrt-anim-zoom-out{animation:aksrtZoomOut .3s ease both}.aksrt-anim-back-in-up{animation:aksrtBackInUp .5s var(--ease-out, ease) both}.aksrt-anim-back-in-down{animation:aksrtBackInDown .5s var(--ease-out, ease) both}.aksrt-anim-slide-up{animation:aksrtSlideInUp .5s var(--ease-out, ease) both}.aksrt-anim-slide-down{animation:aksrtSlideInDown .5s var(--ease-out, ease) both}.aksrt-anim-slide-left{animation:aksrtSlideInLeft .5s var(--ease-out, ease) both}.aksrt-anim-slide-right{animation:aksrtSlideInRight .5s var(--ease-out, ease) both}.aksrt-anim-flip{animation:aksrtFlip .6s var(--ease-out, ease) both}.aksrt-anim-flip-x{animation:aksrtFlipInX .6s var(--ease-out, ease) both}.aksrt-anim-fast{--dur-base: .2s}.aksrt-anim-slow{--dur-base: .6s}.aksrt-anim-delay-1{animation-delay:.1s}.aksrt-anim-delay-2{animation-delay:.2s}.aksrt-anim-delay-3{animation-delay:.3s}.aksrt-anim-delay-4{animation-delay:.4s}.aksrt-anim-delay-5{animation-delay:.5s}.aksrt-anim-delay-6{animation-delay:.6s}.aksrt-anim-delay-7{animation-delay:.7s}.aksrt-anim-delay-8{animation-delay:.8s}.aksrt-anim-infinite{animation-iteration-count:infinite}@media(prefers-reduced-motion:reduce){.aksrt-anim-fade-in,.aksrt-anim-fade-up,.aksrt-anim-fade-down,.aksrt-anim-fade-left,.aksrt-anim-fade-right,.aksrt-anim-zoom-in,.aksrt-anim-zoom-in-up,.aksrt-anim-bounce-in,.aksrt-anim-bounce,.aksrt-anim-pulse,.aksrt-anim-flash,.aksrt-anim-rubber-band,.aksrt-anim-shake,.aksrt-anim-swing,.aksrt-anim-tada,.aksrt-anim-wobble,.aksrt-anim-heart-beat,.aksrt-anim-jello,.aksrt-anim-head-shake,.aksrt-anim-bounce-out,.aksrt-anim-zoom-out,.aksrt-anim-back-in-up,.aksrt-anim-back-in-down,.aksrt-anim-slide-up,.aksrt-anim-slide-down,.aksrt-anim-slide-left,.aksrt-anim-slide-right,.aksrt-anim-flip,.aksrt-anim-flip-x{animation:none!important}}.aksrt-timeline{position:relative;padding-left:28px;margin:0;list-style:none}.aksrt-timeline:before{content:"";position:absolute;left:8px;top:4px;bottom:4px;width:2px;background:var(--color-line, rgba(50, 50, 50, .1));border-radius:1px}.aksrt-timeline__item{position:relative;padding-bottom:24px}.aksrt-timeline__item:last-child{padding-bottom:0}.aksrt-timeline__dot{position:absolute;left:-24px;top:4px;width:12px;height:12px;border-radius:50%;background:var(--color-primary);border:2px solid var(--surface-1, #fff);box-shadow:0 0 0 2px var(--color-primary-soft, rgba(240, 68, 148, .12));z-index:1}.aksrt-timeline__dot--outline{background:var(--surface-1, #fff);border:2px solid var(--color-primary);box-shadow:none}.aksrt-timeline__dot--muted{background:var(--color-ink-4, #999);box-shadow:0 0 0 2px var(--surface-2, #eee)}.aksrt-timeline__time{display:block;font-size:12px;color:var(--color-ink-4, #999);margin-bottom:4px}.aksrt-timeline__title{font-weight:600;color:var(--color-ink, #333);margin-bottom:4px}.aksrt-timeline__content{font-size:14px;color:var(--color-ink-3, #777);line-height:1.6}.aksrt-steps{display:flex;align-items:flex-start;gap:0;margin:0;padding:0;list-style:none;counter-reset:aksrt-step}.aksrt-steps__item{flex:1;position:relative;text-align:center;counter-increment:aksrt-step}.aksrt-steps__item:not(:last-child):after{content:"";position:absolute;top:16px;left:calc(50% + 20px);right:calc(-50% + 20px);height:2px;background:var(--color-line, rgba(50, 50, 50, .1))}.aksrt-steps__icon{display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;border-radius:50%;background:var(--surface-2, #eee);color:var(--color-ink-3, #777);font-size:14px;font-weight:700;margin:0 auto 8px;position:relative;z-index:1}.aksrt-steps__item--active .aksrt-steps__icon{background:var(--color-primary);color:var(--color-on-primary, #fff)}.aksrt-steps__item--done .aksrt-steps__icon{background:var(--color-success);color:#fff}.aksrt-steps__item--done:not(:last-child):after{background:var(--color-success)}.aksrt-steps__label{font-size:13px;font-weight:600;color:var(--color-ink-3, #777)}.aksrt-steps__item--active .aksrt-steps__label{color:var(--color-primary)}.aksrt-steps__item--done .aksrt-steps__label{color:var(--color-success)}.aksrt-steps--vertical{flex-direction:column;gap:0;padding-left:20px}.aksrt-steps--vertical .aksrt-steps__item{text-align:left;padding-left:28px;padding-bottom:20px}.aksrt-steps--vertical .aksrt-steps__item:not(:last-child):after{top:34px;left:36px;right:auto;width:2px;height:calc(100% - 34px)}.aksrt-steps--vertical .aksrt-steps__icon{position:absolute;left:0;margin:0}.aksrt-img-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:4px;border-radius:var(--radius, 8px);overflow:hidden}.aksrt-img-grid img{width:100%;aspect-ratio:1;object-fit:cover;display:block;cursor:pointer;transition:transform .2s ease}.aksrt-img-grid img:hover{transform:scale(1.03)}.aksrt-img-grid--2{grid-template-columns:repeat(2,1fr)}.aksrt-img-grid--4{grid-template-columns:repeat(4,1fr)}@media(max-width:480px){.aksrt-img-grid--4{grid-template-columns:repeat(3,1fr)}}.aksrt-toggle{position:relative;display:inline-flex;align-items:center;cursor:pointer;user-select:none;gap:8px}.aksrt-toggle__input{position:absolute;opacity:0;width:0;height:0;pointer-events:none}.aksrt-toggle__track{position:relative;width:var(--aksrt-toggle-w, 44px);height:var(--aksrt-toggle-h, 24px);background:var(--surface-3, #e8e8e8);border-radius:var(--aksrt-toggle-h, 24px);transition:background .2s ease;flex-shrink:0}.aksrt-toggle__track:after{content:"";position:absolute;top:2px;left:2px;width:calc(var(--aksrt-toggle-h, 24px) - 4px);height:calc(var(--aksrt-toggle-h, 24px) - 4px);background:#fff;border-radius:50%;box-shadow:0 1px 3px #00000026;transition:transform .2s ease}.aksrt-toggle__input:checked+.aksrt-toggle__track{background:var(--color-primary)}.aksrt-toggle__input:checked+.aksrt-toggle__track:after{transform:translate(calc(var(--aksrt-toggle-w, 44px) - var(--aksrt-toggle-h, 24px)))}.aksrt-toggle__input:focus-visible+.aksrt-toggle__track{box-shadow:var(--focus-ring, 0 0 2px 3px rgba(253, 83, 161, .4))}.aksrt-toggle__input:disabled+.aksrt-toggle__track{opacity:.5;cursor:not-allowed}.aksrt-toggle__label{font-size:14px;color:var(--color-ink-2, #4e5358)}.aksrt-checkbox{display:inline-flex;align-items:center;gap:8px;cursor:pointer;user-select:none}.aksrt-checkbox__input{position:absolute;opacity:0;width:0;height:0;pointer-events:none}.aksrt-checkbox__box{position:relative;width:18px;height:18px;border:2px solid var(--color-line, rgba(50, 50, 50, .15));border-radius:4px;background:var(--surface-1, #fff);transition:background .15s ease,border-color .15s ease;flex-shrink:0}.aksrt-checkbox__box:after{content:"";position:absolute;top:2px;left:5px;width:5px;height:9px;border:2px solid #fff;border-top:none;border-left:none;transform:rotate(45deg) scale(0);transition:transform .15s ease}.aksrt-checkbox__input:checked+.aksrt-checkbox__box{background:var(--color-primary);border-color:var(--color-primary)}.aksrt-checkbox__input:checked+.aksrt-checkbox__box:after{transform:rotate(45deg) scale(1)}.aksrt-checkbox__input:focus-visible+.aksrt-checkbox__box{box-shadow:var(--focus-ring, 0 0 2px 3px rgba(253, 83, 161, .4))}.aksrt-checkbox__input:disabled+.aksrt-checkbox__box{opacity:.5;cursor:not-allowed}.aksrt-checkbox__label{font-size:14px;color:var(--color-ink-2, #4e5358)}.aksrt-stepper{display:inline-flex;align-items:center;border:1px solid var(--color-line, rgba(50, 50, 50, .1));border-radius:var(--radius, 8px);overflow:hidden;height:var(--aksrt-stepper-h, 36px)}.aksrt-stepper__btn{display:flex;align-items:center;justify-content:center;width:var(--aksrt-stepper-h, 36px);height:100%;background:var(--surface-2, #eee);color:var(--color-ink-2, #4e5358);border:none;cursor:pointer;font-size:16px;font-weight:600;transition:background .15s ease,color .15s ease;user-select:none}.aksrt-stepper__btn:hover{background:var(--surface-3, #e8e8e8);color:var(--color-ink, #333)}.aksrt-stepper__btn:disabled{opacity:.4;cursor:not-allowed}.aksrt-stepper__input{width:48px;height:100%;border:none;text-align:center;font-size:14px;font-weight:600;color:var(--color-ink, #333);background:var(--surface-1, #fff);-moz-appearance:textfield}.aksrt-stepper__input::-webkit-outer-spin-button,.aksrt-stepper__input::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}.aksrt-stepper__input:focus{outline:none;background:var(--color-primary-softer, rgba(240, 68, 148, .05))}.aksrt-avatar--xs{width:var(--aksrt-avatar-size-xs);height:var(--aksrt-avatar-size-xs)}.aksrt-avatar--sm{width:var(--aksrt-avatar-size-sm);height:var(--aksrt-avatar-size-sm)}.aksrt-avatar{width:var(--aksrt-avatar-size);height:var(--aksrt-avatar-size)}.aksrt-avatar--lg{width:var(--aksrt-avatar-size-lg);height:var(--aksrt-avatar-size-lg)}.aksrt-avatar--xl{width:var(--aksrt-avatar-size-xl);height:var(--aksrt-avatar-size-xl)}img.avatar,img.avatar-mini,img.avatar-md,img.avatar-lg,.avatar-img img{border-radius:var(--radius-avatar, 50%);object-fit:cover}.aksrt-ripple{position:relative;overflow:hidden}.aksrt-ripple__wave{position:absolute;border-radius:50%;background:#ffffff59;transform:scale(0);animation:aksrtRippleWave .6s ease-out forwards;pointer-events:none}@keyframes aksrtRippleWave{to{transform:scale(4);opacity:0}}.aksrt-wave{position:absolute!important;border-radius:50%;background:#ffffff59;pointer-events:none!important;z-index:1;transform:scale(0);animation:aksrtWaveRipple .6s ease-out forwards}@keyframes aksrtWaveRipple{to{transform:scale(1);opacity:0}}.modal-stacked .modal-dialog{transition:transform .25s var(--ease, ease),filter .25s ease}.modal-stacked .modal-dialog.modal-behind{transform:scale(.92);filter:brightness(.85);pointer-events:none}.aksrt-btn--rose{background:var(--aksrt-rose);color:#fff}.aksrt-btn--rose:hover,.aksrt-btn--rose:focus-visible{background:#e11d48}.aksrt-btn--blue{background:var(--aksrt-blue);color:#fff}.aksrt-btn--blue:hover,.aksrt-btn--blue:focus-visible{background:#2563eb}.aksrt-btn--green{background:var(--aksrt-green);color:#fff}.aksrt-btn--green:hover,.aksrt-btn--green:focus-visible{background:#059669}.aksrt-btn--orange{background:var(--aksrt-orange);color:#fff}.aksrt-btn--orange:hover,.aksrt-btn--orange:focus-visible{background:#ea580c}.aksrt-btn--purple{background:var(--aksrt-purple);color:#fff}.aksrt-btn--purple:hover,.aksrt-btn--purple:focus-visible{background:#7c3aed}.aksrt-btn--cyan{background:var(--aksrt-cyan);color:#fff}.aksrt-btn--cyan:hover,.aksrt-btn--cyan:focus-visible{background:#0891b2}.aksrt-btn--yellow{background:var(--aksrt-yellow);color:#422006}.aksrt-btn--yellow:hover,.aksrt-btn--yellow:focus-visible{background:#ca8a04;color:#fff}.aksrt-btn--red{background:var(--aksrt-red);color:#fff}.aksrt-btn--red:hover,.aksrt-btn--red:focus-visible{background:#dc2626}.aksrt-btn--teal{background:var(--aksrt-teal);color:#fff}.aksrt-btn--teal:hover,.aksrt-btn--teal:focus-visible{background:#0d9488}.aksrt-btn--indigo{background:var(--aksrt-indigo);color:#fff}.aksrt-btn--indigo:hover,.aksrt-btn--indigo:focus-visible{background:#4f46e5}.aksrt-btn--pink{background:var(--aksrt-pink);color:#fff}.aksrt-btn--pink:hover,.aksrt-btn--pink:focus-visible{background:#db2777}.aksrt-btn--rose-soft{background:var(--aksrt-rose-soft);color:var(--aksrt-rose)}.aksrt-btn--blue-soft{background:var(--aksrt-blue-soft);color:var(--aksrt-blue)}.aksrt-btn--green-soft{background:var(--aksrt-green-soft);color:var(--aksrt-green)}.aksrt-btn--orange-soft{background:var(--aksrt-orange-soft);color:var(--aksrt-orange)}.aksrt-btn--purple-soft{background:var(--aksrt-purple-soft);color:var(--aksrt-purple)}.aksrt-btn--cyan-soft{background:var(--aksrt-cyan-soft);color:var(--aksrt-cyan)}.aksrt-btn--yellow-soft{background:var(--aksrt-yellow-soft);color:#92400e}.aksrt-btn--red-soft{background:var(--aksrt-red-soft);color:var(--aksrt-red)}.aksrt-btn--teal-soft{background:var(--aksrt-teal-soft);color:var(--aksrt-teal)}.aksrt-btn--indigo-soft{background:var(--aksrt-indigo-soft);color:var(--aksrt-indigo)}.aksrt-btn--pink-soft{background:var(--aksrt-pink-soft);color:var(--aksrt-pink)}.aksrt-btn--grad-vip{background:linear-gradient(135deg,var(--aksrt-vip-gold-1),var(--aksrt-vip-gold-2));color:#5a3d00;border:none}.aksrt-btn--grad-sunset{background:linear-gradient(135deg,var(--aksrt-grad-sunset-1),var(--aksrt-grad-sunset-2));color:#fff;border:none}.aksrt-btn--grad-ocean{background:linear-gradient(135deg,var(--aksrt-grad-ocean-1),var(--aksrt-grad-ocean-2));color:#fff;border:none}.aksrt-btn--grad-forest{background:linear-gradient(135deg,var(--aksrt-grad-forest-1),var(--aksrt-grad-forest-2));color:#064e3b;border:none}.aksrt-btn--grad-night{background:linear-gradient(135deg,var(--aksrt-grad-night-1),var(--aksrt-grad-night-2));color:#4c1d95;border:none}.aksrt-btn--grad-fire{background:linear-gradient(135deg,var(--aksrt-grad-fire-1),var(--aksrt-grad-fire-2));color:#fff;border:none}.aksrt-btn--grad-primary{background:linear-gradient(135deg,var(--color-primary),var(--color-primary-active));color:var(--color-on-primary);border:none}.aksrt-btn--grad-vip:hover,.aksrt-btn--grad-sunset:hover,.aksrt-btn--grad-ocean:hover,.aksrt-btn--grad-forest:hover,.aksrt-btn--grad-night:hover,.aksrt-btn--grad-fire:hover,.aksrt-btn--grad-primary:hover{filter:brightness(1.08);box-shadow:0 4px 16px #00000026}.aksrt-text-grad-primary{background:linear-gradient(135deg,var(--color-primary),var(--color-primary-active));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}.aksrt-text-grad-vip{background:linear-gradient(135deg,var(--aksrt-vip-gold-1),var(--aksrt-vip-gold-2));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}.aksrt-text-grad-sunset{background:linear-gradient(135deg,var(--aksrt-grad-sunset-1),var(--aksrt-grad-sunset-2));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}.aksrt-text-grad-ocean{background:linear-gradient(135deg,var(--aksrt-grad-ocean-1),var(--aksrt-grad-ocean-2));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}.aksrt-text-grad-forest{background:linear-gradient(135deg,var(--aksrt-grad-forest-1),var(--aksrt-grad-forest-2));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}.aksrt-text-grad-night{background:linear-gradient(135deg,var(--aksrt-grad-night-1),var(--aksrt-grad-night-2));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}.aksrt-text-grad-fire{background:linear-gradient(135deg,var(--aksrt-grad-fire-1),var(--aksrt-grad-fire-2));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}.aksrt-badge{display:inline-flex;align-items:center;justify-content:center;height:var(--aksrt-badge-h, 22px);padding:0 8px;border-radius:var(--radius-pill, 100px);font-size:12px;font-weight:600;line-height:1;white-space:nowrap;vertical-align:middle}.aksrt-badge--primary{background:var(--color-primary-soft);color:var(--color-primary)}.aksrt-badge--success{background:#10b9811f;color:var(--color-success)}.aksrt-badge--warning{background:#f59e0b1f;color:var(--color-warning)}.aksrt-badge--error{background:#ef44441f;color:var(--color-error)}.aksrt-badge--info{background:#3b82f61f;color:var(--color-info)}.aksrt-badge--rose{background:var(--aksrt-rose-soft);color:var(--aksrt-rose)}.aksrt-badge--blue{background:var(--aksrt-blue-soft);color:var(--aksrt-blue)}.aksrt-badge--green{background:var(--aksrt-green-soft);color:var(--aksrt-green)}.aksrt-badge--orange{background:var(--aksrt-orange-soft);color:var(--aksrt-orange)}.aksrt-badge--purple{background:var(--aksrt-purple-soft);color:var(--aksrt-purple)}.aksrt-badge--cyan{background:var(--aksrt-cyan-soft);color:var(--aksrt-cyan)}.aksrt-badge--yellow{background:var(--aksrt-yellow-soft);color:#92400e}.aksrt-badge--red{background:var(--aksrt-red-soft);color:var(--aksrt-red)}.aksrt-badge--teal{background:var(--aksrt-teal-soft);color:var(--aksrt-teal)}.aksrt-badge--indigo{background:var(--aksrt-indigo-soft);color:var(--aksrt-indigo)}.aksrt-badge--pink{background:var(--aksrt-pink-soft);color:var(--aksrt-pink)}.aksrt-badge--grad-vip{background:linear-gradient(135deg,var(--aksrt-vip-gold-1),var(--aksrt-vip-gold-2));color:#5a3d00}.aksrt-badge--grad-primary{background:linear-gradient(135deg,var(--color-primary),var(--color-primary-active));color:var(--color-on-primary)}.aksrt-badge--solid-primary{background:var(--color-primary);color:#fff}.aksrt-badge--solid-success{background:var(--color-success);color:#fff}.aksrt-badge--solid-warning{background:var(--color-warning);color:#fff}.aksrt-badge--solid-error{background:var(--color-error);color:#fff}.aksrt-badge--solid-info{background:var(--color-info);color:#fff}.aksrt-badge--solid-rose{background:var(--aksrt-rose);color:#fff}.aksrt-badge--solid-blue{background:var(--aksrt-blue);color:#fff}.aksrt-badge--solid-green{background:var(--aksrt-green);color:#fff}.aksrt-badge--solid-orange{background:var(--aksrt-orange);color:#fff}.aksrt-badge--solid-purple{background:var(--aksrt-purple);color:#fff}.aksrt-badge--solid-cyan{background:var(--aksrt-cyan);color:#fff}.aksrt-badge--solid-yellow{background:var(--aksrt-yellow);color:#422006}.aksrt-badge--solid-red{background:var(--aksrt-red);color:#fff}.aksrt-badge--solid-teal{background:var(--aksrt-teal);color:#fff}.aksrt-badge--solid-indigo{background:var(--aksrt-indigo);color:#fff}.aksrt-badge--solid-pink{background:var(--aksrt-pink);color:#fff}.aksrt-badge--sm{height:18px;padding:0 6px;font-size:11px}.aksrt-badge--lg{height:28px;padding:0 12px;font-size:13px}.aksrt-bg-rose{background-color:var(--aksrt-rose-soft)}.aksrt-bg-blue{background-color:var(--aksrt-blue-soft)}.aksrt-bg-green{background-color:var(--aksrt-green-soft)}.aksrt-bg-orange{background-color:var(--aksrt-orange-soft)}.aksrt-bg-purple{background-color:var(--aksrt-purple-soft)}.aksrt-bg-cyan{background-color:var(--aksrt-cyan-soft)}.aksrt-bg-yellow{background-color:var(--aksrt-yellow-soft)}.aksrt-bg-red{background-color:var(--aksrt-red-soft)}.aksrt-bg-teal{background-color:var(--aksrt-teal-soft)}.aksrt-bg-indigo{background-color:var(--aksrt-indigo-soft)}.aksrt-bg-pink{background-color:var(--aksrt-pink-soft)}.aksrt-text-rose{color:var(--aksrt-rose)}.aksrt-text-blue{color:var(--aksrt-blue)}.aksrt-text-green{color:var(--aksrt-green)}.aksrt-text-orange{color:var(--aksrt-orange)}.aksrt-text-purple{color:var(--aksrt-purple)}.aksrt-text-cyan{color:var(--aksrt-cyan)}.aksrt-text-yellow{color:var(--aksrt-yellow)}.aksrt-text-red{color:var(--aksrt-red)}.aksrt-text-teal{color:var(--aksrt-teal)}.aksrt-text-indigo{color:var(--aksrt-indigo)}.aksrt-text-pink{color:var(--aksrt-pink)}html,body{overflow-x:clip}@supports not (overflow-x: clip){html,body{overflow-x:hidden}}.reading-progress-bar{position:fixed;top:0;left:0;height:3px;width:0%;z-index:9999;pointer-events:none;background:linear-gradient(90deg,var(--color-primary),var(--color-primary-light));transition:width .1s ease-out}body{font-family:var(--font-body);font-size:var(--text-base);line-height:var(--leading-normal);color:var(--color-ink-2);background-color:var(--surface-page)}a[href]{transition:color .2s,background .3s,opacity .3s,box-shadow .3s,transform .3s}.fa.fab{font-family:"Font Awesome 5 Brands";font-weight:400}.skip-link{position:absolute;left:16px;top:12px;z-index:1100;padding:10px 14px;border-radius:999px;background:var(--color-primary);color:#fff;transform:translateY(-140%);transition:transform .2s ease}.skip-link:focus{transform:translateY(0)}.site-wrapper{min-height:100vh;display:flex;flex-direction:column;font-size:var(--text-base);line-height:var(--leading-normal)}.site-main{flex:1}.container,main.container,.site-main.container{width:100%;max-width:var(--container, var(--mian-max-width, 1200px));margin-right:auto;margin-left:auto;padding-right:0;padding-left:0;--bs-gutter-x: 0;--bs-gutter-y: 0}.header-slider-card .icon-cover-card{font-size:12px}.header-slider-search .line-form-input:focus,.header-slider-search .line-form-input:focus-visible{border:0;outline:0;box-shadow:none}.header-slider-search .line-form .abs-right{position:absolute;right:.7em;top:50%;transform:translateY(-50%);margin-top:0}@media(min-width:576px){.container,main.container{max-width:var(--container, var(--mian-max-width, 1200px))}}@media(min-width:768px){.container,main.container{max-width:var(--container, var(--mian-max-width, 1200px))}}@media(min-width:992px){.container,main.container{max-width:var(--container, var(--mian-max-width, 1200px))}}@media(min-width:1200px){.container,main.container{max-width:var(--container, var(--mian-max-width, 1200px))}}@media(min-width:1240px){.container,main.container,.fluid-widget-wrap .widget-container{width:auto;max-width:var(--container, var(--mian-max-width, 1200px))}}@media(min-width:1400px){.container,main.container{max-width:var(--container, var(--mian-max-width, 1200px))}}@media(max-width:991.98px){.container,main.container{padding-right:15px;padding-left:15px}}@media(max-width:767px){.container,main.container{width:100%;max-width:100%;padding-right:15px;padding-left:15px}}.aksrt-user-level-badge,.aksrt-user-level-icon,.aksrt-identity-medal,.aksrt-identity-certification-icon,.aksrt-header-user-medal{display:inline-flex;align-items:center;justify-content:center;width:auto;height:1.1em;max-width:6em;object-fit:contain;vertical-align:-.15em;flex:0 0 auto;box-sizing:border-box}.aksrt-user-level-badge{padding:0 .35em;font-size:.78em;line-height:1.1;white-space:nowrap}.aksrt-identity-medal--comment,.aksrt-user-level-icon--comment,.aksrt-header-user-medal--comment{height:1em;vertical-align:-.12em}.article-avatar name,.article-avatar .name,.user-name,.aksrt-author-hero__name,.uc-header__name,.aksrt-header-badges{flex-wrap:nowrap;white-space:nowrap}.user-card.author .user-name>name{overflow:visible}.user-card.author .user-name>name>.display-name,.user-card.widget .user-name>name>.display-name{min-width:0;overflow:hidden;text-overflow:ellipsis}.user-card.author .user-name>name>.follow{flex-shrink:0}.aksrt-author-hero__name,.uc-header__name{display:inline-flex;align-items:center;gap:4px}.aksrt-author-hero__name-inner{display:inline-flex;align-items:center;gap:4px;min-width:0}.aksrt-ip-geo-badge{display:inline-flex;align-items:center;min-width:0;margin:2px 0;padding:.3em .6em;border:1px solid var(--color-line-soft, var(--main-border-color, rgba(136, 136, 136, .18)));border-radius:var(--mini-radius, 4px);background:var(--surface-2, rgba(136, 136, 136, .1));box-shadow:var(--shadow-xs, 0 1px 3px rgba(0, 0, 0, .08));color:var(--color-ink-3, #666);font-size:.92em;line-height:1.44;vertical-align:middle;box-sizing:border-box}.no-results{padding:48px 20px;text-align:center;color:var(--color-ink-3)}.navbar-top .aksrt-navbar-account{display:inline-flex;align-items:center;gap:4px;margin:0}.navbar-top .aksrt-navbar-account>li{position:relative;display:inline-flex;align-items:center;flex:0 0 auto;padding:0;overflow:visible}.navbar-top .aksrt-navbar-account>li>.sub-menu{top:calc(100% + 5px);right:0;left:auto;z-index:1002;margin:0}.navbar-top .aksrt-nav-icon,.navbar-top .aksrt-nav-avatar{display:inline-flex!important;align-items:center;justify-content:center;width:34px;height:34px;padding:0!important;overflow:visible!important;border:0!important;border-radius:0;background:transparent!important;box-shadow:none!important}.navbar-top .aksrt-nav-icon .icon{width:1.2em;height:1.2em}.navbar-top .aksrt-nav-avatar{margin-top:0}.navbar-top .aksrt-nav-avatar .avatar{display:block;width:29px;height:29px;border-radius:50%;object-fit:cover}.navbar-top .navbar-form .toggle-radius:not(.social-login-item):not(.xh-social-item){flex:0 0 29px;margin:0;vertical-align:middle}.navbar-top .navbar-form.flex.ac{min-height:34px}.navbar-top .navbar-form .aksrt-navbar-language{margin:0}.navbar-top .navbar-but>.hover-show{display:inline-block}.navbar-search{z-index:1001;opacity:0;transform:translate3d(0,-24px,0);transition:transform .24s ease,opacity .18s ease,visibility 0s linear .24s;will-change:transform,opacity}.navbar-search.show{opacity:1;transform:translateZ(0);transition-delay:0s}.navbar-search .line-form-input:focus,.navbar-search .line-form-input:focus-visible{outline:0;box-shadow:none}.navbar-search .search-close-box{display:flex;justify-content:flex-end;max-width:660px;margin:16px auto 0}.navbar-search .search-close-box>.but{float:none}.aksrt-header-userbox{color:var(--color-ink-2);line-height:1.42857143}.aksrt-header-userbox .muted-box.padding-6{background-color:var(--this-bg, var(--color-line-soft));border-radius:4px;padding:6px}.payvip-icon{background:linear-gradient(300deg,#4c4d51,#2a2a31 15%,#85858a 40%,#393a3c,#393838,#5e5f62);color:#ece0e0;--this-color: #ece0e0;border:none;text-shadow:none}.aksrt-header-userbox>.mt6.em09>.but.block{display:block;box-sizing:border-box;width:100%}.aksrt-nav-notification .aksrt-notification-badge{position:absolute;top:2px;right:2px;display:inline-flex;align-items:center;justify-content:center;min-width:16px;height:16px;padding:0 4px;border:2px solid var(--surface-1);border-radius:999px;background:#e74c3c;color:#fff;font-size:9px;font-weight:700;line-height:1}.navbar-top .aksrt-header-userbox .header-user-href>a,.aksrt-header-userbox .header-user-href>a{max-height:none}.mobile-navbar .aksrt-header-userbox{width:auto;max-width:400px;margin:auto}.aksrt-theme-box{border:none;border-radius:var(--radius, 8px);background:var(--surface-1);box-shadow:var(--shadow-sm);transition:box-shadow .2s ease}.aksrt-theme-box:hover{box-shadow:var(--shadow)}.aksrt-box-bg{background-color:var(--surface-1);box-shadow:var(--shadow-sm)}.aksrt-box-body,.aksrt-box-header{padding:var(--card-padding)}.aksrt-box-header{border-bottom:1px solid var(--color-line-soft)}.aksrt-box-header+.aksrt-box-body{padding-top:0}.aksrt-box-body.notop,.aksrt-box-header.notop{padding-top:0}.aksrt-box-body.nobottom,.aksrt-box-header.nobottom{padding-bottom:0}.aksrt-muted-box{background-color:var(--surface-2);border-radius:var(--radius);padding:var(--card-padding)}.aksrt-noshadow{box-shadow:none!important}.theme-box,.mb20{margin-bottom:20px}.box-bg{background-color:var(--main-bg-color, var(--surface-1));box-shadow:0 0 10px var(--main-shadow, rgba(116, 116, 116, .08))}.box-body{padding:var(--card-padding)}.box-header{padding:var(--card-padding);border-bottom:1px solid var(--color-line-soft)}.box-header+.box-body{padding-top:0}.radius8{border-radius:var(--radius)}.main-shadow{box-shadow:0 0 10px var(--main-shadow, rgba(116, 116, 116, .08))}.main-bg{background:var(--main-bg-color, var(--surface-1))}.muted-box{background-color:var(--surface-2);border-radius:var(--radius);padding:var(--card-padding)}html.dark .aksrt-theme-box,body.dark-theme .aksrt-theme-box,html.dark .box-bg,body.dark-theme .box-bg{border-color:transparent;box-shadow:0 0 10px var(--main-shadow, rgba(0, 0, 0, .2))}html.dark .aksrt-theme-box:hover,body.dark-theme .aksrt-theme-box:hover{box-shadow:0 0 10px var(--main-shadow, rgba(0, 0, 0, .28))}.aksrt-theme-box{transition:box-shadow var(--dur) var(--ease),transform var(--dur) var(--ease)}.aksrt-card-hover:hover,.aksrt-card-hover:focus-within{box-shadow:none;transform:none}.aksrt-card-hover-lg:hover,.aksrt-card-hover-lg:focus-within{box-shadow:var(--shadow-lg);transform:none}.aksrt-noshadow:hover{box-shadow:none!important;transform:none!important}.aksrt-panel{clear:both;background-color:var(--surface-1);padding:var(--card-padding);box-shadow:var(--shadow-sm);border-radius:var(--radius, 8px);margin-bottom:20px}.aksrt-blur-bg{-webkit-backdrop-filter:saturate(5) blur(20px);backdrop-filter:saturate(5) blur(20px);background:var(--blur-bg)}.site-nav--glass{-webkit-backdrop-filter:saturate(5) blur(20px);backdrop-filter:saturate(5) blur(20px);background:var(--blur-bg);border-bottom-color:transparent}.site-nav--glass.is-scrolled{box-shadow:var(--shadow-sm);border-bottom-color:var(--color-line-soft)}html.dark .site-nav--glass.is-scrolled{box-shadow:var(--shadow-sm)}.footer-tabbar--glass{-webkit-backdrop-filter:saturate(5) blur(20px);backdrop-filter:saturate(5) blur(20px);background:var(--blur-bg);box-shadow:0 -8px 24px #141c3014}html.dark .footer-tabbar--glass{box-shadow:0 -10px 28px #00000052}body.has-scrolled .site-nav--glass{box-shadow:0 4px 10px #141c300f}html.dark body.has-scrolled .site-nav--glass{box-shadow:0 4px 10px #00000040}.content-main{width:100%}.content-main--single{max-width:100%}.content-shell{width:100%;margin:0 auto;padding:32px 16px 48px;max-width:var(--container)}.site-main.content-shell.aksrt-profile{max-width:100%;box-sizing:border-box}.site-main.content-shell.aksrt-user-home{max-width:var(--container, 1440px);padding-top:0;box-sizing:border-box}@media(min-width:768px){.content-shell{padding:40px 24px 56px}}.content-shell--page{max-width:var(--container-page, 860px)}.content-shell--cms{max-width:100%;padding:0}.entry-page .entry-thumb{margin:0 0 var(--space-6, 24px);border-radius:var(--radius-lg, 12px);overflow:hidden}.entry-page .page-links{margin-top:var(--space-6, 24px);font-size:var(--text-sm, .875rem);color:var(--color-ink-3, var(--color-ink-3))}.entry-page .page-links-title{margin-right:8px;font-weight:600}main.container,main.content-shell,.site-main.container{position:relative}main.container.content-shell,main.content-shell.container,.site-main.container.content-shell{padding-left:0;padding-right:0;max-width:var(--container, var(--mian-max-width, 1200px));width:100%;box-sizing:border-box}main.container:after,main.content-shell:after,.site-main.container:after{content:"";display:table;clear:both}.content-wrap{float:left;width:100%;min-width:0;display:block;box-sizing:border-box}.content-layout{display:block;width:auto;min-width:0;max-width:none;margin-right:0;margin-left:0;box-sizing:border-box}.content-layout__primary{min-width:0;max-width:100%}.sidebar{float:left;width:311px;width:var(--sidebar-width, 311px);min-width:0;position:relative;box-sizing:border-box;display:block}.content-layout__aside{min-width:0}body.site-layout-1 .sidebar,.site-layout-1 .sidebar{display:none}body.site-layout-1 .content-layout,body.site-layout-1 .content-layout--no-sidebar,.site-layout-1 .content-layout{margin-left:0;margin-right:0}.content-layout--no-sidebar{margin-left:0!important;margin-right:0!important}body.site-layout-2 .content-layout,.site-layout-2 .content-layout{margin-right:326px;margin-right:calc(var(--sidebar-width, 311px) + 15px);margin-left:0;width:auto}body.site-layout-2 .sidebar,.site-layout-2 .sidebar{float:left;width:311px;width:var(--sidebar-width, 311px);margin-left:-311px;margin-left:calc(0px - var(--sidebar-width, 311px));position:unset}body.site-layout-3 .content-layout,.site-layout-3 .content-layout{margin-left:326px;margin-left:calc(var(--sidebar-width, 311px) + 15px);margin-right:unset;width:auto}body.site-layout-3 .sidebar,.site-layout-3 .sidebar{float:left;width:311px;width:var(--sidebar-width, 311px);margin-left:-100%;position:unset}@media(min-width:992px){.sidebar .content-layout__aside,.content-layout__aside{position:sticky;top:calc(var(--nav-height, 60px) + 16px)}.has-static-nav .content-layout__aside{top:16px}}@media(max-width:991.98px){body.site-layout-2 .content-layout,body.site-layout-3 .content-layout,.site-layout-2 .content-layout,.site-layout-3 .content-layout{margin-left:auto;margin-right:auto}body.site-layout-2 .sidebar,body.site-layout-3 .sidebar,.site-layout-2 .sidebar,.site-layout-3 .sidebar,.sidebar{display:none}}.page-header{margin-bottom:28px}.page-header .entry-title{margin-bottom:8px}.page-header p{color:var(--color-ink-3);margin:0;max-width:720px}.term-page-header{position:relative;overflow:hidden;border:1px solid var(--color-line-soft, rgba(15, 23, 42, .08));border-radius:var(--radius-lg);background:var(--surface-1);box-shadow:var(--shadow-sm, 0 1px 6px rgba(15, 23, 42, .04))}.term-page-header__body{position:relative;z-index:1;display:flex;align-items:flex-start;justify-content:space-between;gap:16px;padding:24px}.term-page-header__eyebrow{margin:0 0 8px;font-size:.8125rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--color-primary)}.term-page-header__desc{margin-top:10px}.term-page-header__cover{min-height:140px;background-position:center;background-size:cover;border-top:1px solid var(--color-line-soft, rgba(15, 23, 42, .08))}.term-page-header--simple .term-page-header__cover{display:none}.term-page-header--card{border-top:3px solid var(--color-primary, #f04494)}.term-page-header--card .term-page-header__body{padding:28px 24px}.term-page-header--cover{border:none;border-radius:var(--radius-lg);background:transparent;box-shadow:none;min-height:220px;display:flex;flex-direction:column}.term-page-header__cover-bg{position:absolute;inset:0;background-position:center;background-size:cover;border-radius:var(--radius-lg)}.term-page-header__cover-overlay{position:absolute;inset:0;background:linear-gradient(135deg,#0000008c,#00000040);border-radius:var(--radius-lg)}.term-page-header--cover .term-page-header__body{flex:1;align-items:flex-end;z-index:2}.term-page-header__body--overlay .entry-title,.term-page-header__body--overlay .term-page-header__desc,.term-page-header__body--overlay .term-page-header__eyebrow{color:#fff}.term-page-header__body--overlay .term-page-header__eyebrow{color:#fffc}@media(max-width:767px){.term-page-header--cover{min-height:160px}}.aksrt-admin-quicklink{display:inline-flex;align-items:center;gap:8px;min-height:36px;padding:0 14px;border:1px solid var(--color-line);border-radius:var(--radius-pill, 999px);background:var(--surface-2);color:var(--color-ink-2);text-decoration:none;white-space:nowrap;font-size:.875rem;font-weight:600}.aksrt-admin-quicklink:hover{color:var(--color-primary);border-color:var(--color-primary);background:var(--color-primary-soft, rgba(59, 130, 246, .08))}.archive-filter-bar{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;margin:-8px 0 24px;padding:16px 18px;border-radius:var(--radius-lg);background:var(--surface-2);border:1px solid var(--color-line-soft, rgba(15, 23, 42, .06))}.archive-filter-bar__main{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.archive-filter-bar__label{color:var(--color-ink-4);font-size:.8125rem;font-weight:600}.archive-filter-bar__chips{display:flex;align-items:center;flex-wrap:wrap;gap:8px}.archive-filter-bar__chip{display:inline-flex;align-items:center;min-height:34px;padding:0 12px;border-radius:var(--radius-pill, 999px);background:var(--surface-1);color:var(--color-ink-2);text-decoration:none;font-size:.875rem;font-weight:500}.archive-filter-bar__chip.is-active,.archive-filter-bar__chip:hover{background:var(--color-primary);color:var(--color-on-primary, #fff)}.archive-filter-bar__meta{color:var(--color-ink-4);font-size:.8125rem;font-weight:600}.archive-filter-bar__sort{display:flex;align-items:center;gap:8px;margin-left:auto}.archive-filter-bar__main--tags,.archive-filter-bar__main--custom{border-top:1px solid var(--color-line-soft, rgba(15, 23, 42, .06));padding-top:10px;margin-top:10px}.content-stream,.content-main--archive,.content-main--tag,.content-main--category,.content-main--search,.content-main--single{display:grid;gap:20px}.content-stream__list{width:100%}.content-stream__empty{padding:28px 24px;border-radius:var(--radius-lg);background:var(--surface-1);border:1px solid var(--color-line-soft, rgba(15, 23, 42, .08))}.article-shell{overflow:hidden}@media(max-width:767px){.term-page-header__body{flex-direction:column;align-items:stretch;padding:20px}.aksrt-admin-quicklink{width:fit-content}}.aksrt-pagination-ajax{display:flex;flex-direction:column;align-items:center;gap:16px;margin:40px 0 8px}.aksrt-load-more-btn{min-width:180px;height:44px;padding:0 32px;border-radius:var(--radius-btn);border:1px solid var(--color-line);background:var(--surface-page);color:var(--color-ink);font-size:14px;font-weight:600;cursor:pointer;transition:border-color .2s,color .2s,background .2s;display:inline-flex;align-items:center;justify-content:center;gap:8px}.aksrt-load-more-btn:hover{border-color:var(--color-primary);color:var(--color-primary)}.aksrt-load-more-btn:disabled{opacity:.5;pointer-events:none}.aksrt-loading-indicator{display:none;align-items:center;gap:8px;font-size:14px;color:var(--color-ink-4)}.aksrt-loading-indicator i{font-size:16px}.aksrt-scroll-sentinel{width:100%;height:1px;pointer-events:none}@media(max-width:991px){.content-layout__aside{display:none}}.content-layout__primary--cms{min-width:0}.content-layout__aside .widget a{transition:color var(--dur-fast, .15s) var(--ease)}.content-layout__aside .widget a:hover{color:var(--color-primary)}.comments-card{overflow:hidden}:root{--aksrt-footer-tabbar-height: 49px}@media(max-width:767px){.hide-on-mobile,.hidden-xs{display:none!important}}@media(min-width:768px){.hide-on-desktop,.hide-on-pc{display:none!important}}.footer-tabbar{display:none;position:fixed;bottom:0;left:0;right:0;z-index:1035;min-height:var(--aksrt-footer-tabbar-height);background:var(--blur-bg);border-top:1px solid var(--color-line-soft);padding:0 0 env(safe-area-inset-bottom,0px);justify-content:space-around;align-items:center;box-shadow:0 -4px 5px var(--main-shadow, rgba(0, 0, 0, .06));-webkit-backdrop-filter:saturate(5) blur(20px);backdrop-filter:saturate(5) blur(20px);--tabbar-active-color: #f04494;--tabbar-icon-size: 24px;--tabbar-text-size: 10px}.footer-tabbar.no-border{border-top:none}.footer-tabbar.no-shadow{box-shadow:none}.footer-tabbar.hide-text .tabbar-item span{display:none}.footer-tabbar.has-animation .tabbar-item{transition:color .15s ease,transform .15s ease}.footer-tabbar.has-animation .tabbar-item:active{transform:scale(.9)}.footer-tabbar-placeholder{display:none;height:calc(var(--aksrt-footer-tabbar-height) + env(safe-area-inset-bottom,0px))}@media(max-width:767px){.footer-tabbar{display:flex}.footer-tabbar-placeholder{display:block}.footer,.site-footer{padding-bottom:calc(var(--aksrt-footer-tabbar-height) + env(safe-area-inset-bottom,0px))}}.tabbar-item{display:flex;flex:1 1 0;min-width:0;flex-direction:column;align-items:center;justify-content:center;gap:1px;min-height:49px;padding:4px 0 3px;color:var(--muted-color, var(--color-ink-3));font-size:13px;text-decoration:none;border:none;background:none;cursor:pointer;transition:color .15s ease;-webkit-tap-highlight-color:transparent;position:relative}.tabbar-item i{font-size:var(--tabbar-icon-size, 24px);line-height:1}.tabbar-item span{display:block;font-size:var(--tabbar-text-size, 10px);font-weight:500;line-height:1;transform:scale(.8);transform-origin:center top}.tabbar-item:hover,.tabbar-item.active{color:var(--tabbar-active-color, var(--color-primary));text-decoration:none}.tabbar-item.active i{font-weight:900}.tabbar-badge{position:absolute;top:2px;right:8px;min-width:16px;height:16px;padding:0 4px;border-radius:999px;background:var(--color-error);color:#fff;font-size:10px;line-height:16px;font-style:normal;font-weight:700;text-align:center;box-shadow:0 2px 8px #ef444433}.tabbar-badge--dot{min-width:0;width:8px;height:8px;padding:0;top:4px;right:10px;font-size:0;line-height:1;box-shadow:0 0 0 2px var(--blur-bg)}.tabbar-item--add{margin-top:-22px;width:62px;height:62px;min-height:62px;border-radius:50%;background:var(--color-accent);color:#fff;box-shadow:0 12px 24px var(--color-accent-shadow);flex:0 0 62px;z-index:1}.tabbar-item--add i{font-size:22px}.tabbar-item--add span{font-size:11px}.tabbar-item--add:hover,.tabbar-item--add.active{color:#fff}.tabbar-add-backdrop{position:fixed;inset:0;z-index:951;background:#0f172a61}.tabbar-add-panel{position:fixed;left:0;right:0;bottom:0;z-index:952;padding:0 12px calc(84px + env(safe-area-inset-bottom,0px))}.tabbar-add-panel__sheet{border-radius:20px;background:var(--surface-1);border:none;box-shadow:var(--shadow-drawer);overflow:hidden}.tabbar-add-panel__head{display:flex;align-items:center;justify-content:space-between;padding:16px 18px 12px;border-bottom:1px solid var(--color-line)}.tabbar-add-panel__close{width:32px;height:32px;border:0;border-radius:50%;background:var(--surface-2);color:var(--color-ink-3)}.tabbar-add-panel__list{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;padding:18px}.tabbar-add-panel__item{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;min-height:88px;border-radius:18px;background:var(--surface-2);color:var(--color-ink-3);text-decoration:none}.tabbar-add-panel__item i{font-size:22px;color:var(--color-accent)}.tabbar-add-panel__item span{font-size:13px;font-weight:600}.tabbar-search-overlay{position:fixed;inset:0;z-index:953;background:#0f172a94;display:flex;align-items:stretch;justify-content:center}.tabbar-search-overlay__dialog{width:100%;min-height:100%;background:var(--surface-1);padding:18px 16px calc(24px + env(safe-area-inset-bottom,0px))}.tabbar-search-overlay__head{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}.tabbar-search-overlay__close{width:36px;height:36px;border:0;border-radius:50%;background:var(--surface-2);color:var(--color-ink-3)}.tabbar-search-overlay__form{display:grid;grid-template-columns:20px 1fr auto;gap:10px;align-items:center;padding:14px 14px 14px 12px;border-radius:18px;border:none;box-shadow:var(--shadow-sm);background:var(--surface-4)}.tabbar-search-overlay__icon{color:var(--color-ink-4)}.tabbar-search-overlay__form input{min-width:0;border:0;background:transparent;color:var(--color-ink);font-size:15px;outline:none}.tabbar-search-overlay__submit{border:0;border-radius:12px;padding:10px 14px;background:var(--color-accent);color:#fff;font-weight:700}.tabbar-search-overlay__types{display:flex;gap:8px;margin-top:14px}.tabbar-search-overlay__type{border:0;border-radius:999px;padding:9px 14px;background:var(--surface-2);color:var(--color-ink-3);font-size:13px;font-weight:600}.tabbar-search-overlay__type.is-active{background:var(--color-accent-soft);color:var(--color-accent)}.tabbar-search-overlay__section{margin-top:20px}.tabbar-search-overlay__section-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}.tabbar-search-overlay__label{color:var(--color-ink-3);font-size:13px;font-weight:700}.tabbar-search-overlay__clear{border:0;background:transparent;color:var(--color-accent);font-size:12px;font-weight:700}.tabbar-search-overlay__history,.tabbar-search-overlay__suggestions{display:flex;flex-direction:column;gap:10px}.tabbar-search-overlay__history-item,.tabbar-search-overlay__suggestion-item{display:flex;align-items:center;justify-content:space-between;gap:12px;width:100%;border:none;border-radius:16px;padding:12px 14px;background:var(--surface-4);box-shadow:var(--shadow-sm);color:var(--color-ink-3);text-decoration:none;text-align:left}.tabbar-search-overlay__history-item i{color:var(--color-accent)}.tabbar-search-overlay__history-item span,.tabbar-search-overlay__suggestion-item span{flex:1;min-width:0;font-size:14px;font-weight:600}.tabbar-search-overlay__suggestion-item small{color:var(--color-ink-4);font-size:12px}.tabbar-search-overlay__empty{padding:14px;border-radius:16px;background:var(--surface-2);color:var(--color-ink-4);font-size:13px;text-align:center}.tabbar-search-overlay__tags{display:flex;flex-wrap:wrap;gap:10px}.tabbar-search-overlay__tag{padding:10px 14px;border-radius:999px;background:var(--surface-2);color:var(--color-ink-3);text-decoration:none;font-size:13px}body.tabbar-search-open,body.tabbar-add-open{overflow:hidden}.footer-tabbar.scrolling-hide{transform:translateY(100%);transition:transform .3s ease}.footer-tabbar.scroll-down-hide{transition:transform .3s ease}.footer-tabbar.scroll-down-hide.is-hidden{transform:translateY(120%)}.tabbar-ontop-wrap{position:relative}.tabbar-ontop-wrap .tabbar-ontop-btn{display:none;position:absolute;inset:0;flex-direction:column;align-items:center;justify-content:center;gap:2px;padding:4px 12px;color:var(--color-primary);font-size:13px;text-decoration:none;cursor:pointer;-webkit-tap-highlight-color:transparent}.tabbar-ontop-wrap .tabbar-ontop-btn i{font-size:18px;line-height:1}.tabbar-ontop-wrap .tabbar-ontop-btn span{font-weight:500;line-height:1;transform:scale(.8);transform-origin:center top}.tabbar-ontop-wrap.is-scrolled .tabbar-ontop-btn{display:flex}.tabbar-ontop-wrap.is-scrolled>.tabbar-item:not(.tabbar-ontop-btn){opacity:0;pointer-events:none}.theme-dark .site-footer,html.dark .site-footer{background:var(--surface-1)}.theme-dark .footer-tabbar,html.dark .footer-tabbar{background:var(--blur-bg);box-shadow:0 -1px 3px #0003}.back-to-top{display:none!important}.site-footer--compact .site-footer__grid{display:flex;flex-wrap:wrap;align-items:center;gap:16px}.site-footer--compact .site-footer__brand{flex:1 1 100%}.tabbar-search-overlay[hidden]{display:none!important}.tabbar-search-overlay.is-open,.tabbar-search-overlay.show:not([hidden]){display:flex}.tabbar-search-overlay__dialog{max-width:720px;margin:0 auto}@media(min-width:768px){.tabbar-search-overlay{align-items:flex-start;padding-top:8vh;background:#0f172a73}.tabbar-search-overlay__dialog{min-height:auto;max-height:min(84vh,860px);overflow:auto;border-radius:20px;box-shadow:var(--shadow-lg, 0 20px 50px rgba(15, 23, 42, .22))}.nav-searchbox--desktop{display:none}.main-search-btn.nav-search-toggle{display:inline-flex;align-items:center;justify-content:center}}@media(max-width:767.98px){.main-search-btn.nav-search-toggle{display:none}}.site-footer--compact .site-footer__brand .site-footer__desc,.site-footer--compact .site-footer__brand .social-row,.site-footer--compact .site-footer__brand .site-footer__friends{display:none}.site-footer--compact .site-footer__col{flex:0 0 auto}.site-footer--compact .site-footer__col ul{display:flex;flex-wrap:wrap;gap:12px;list-style:none;padding:0;margin:0}.site-footer--compact .site-footer__bottom{border-top:1px solid var(--color-line);padding-top:12px;margin-top:12px}.site-footer--centered .site-footer__grid{text-align:center}.site-footer--centered .site-footer__brand{flex:1 1 100%;display:flex;flex-direction:column;align-items:center}.site-footer--centered .site-footer__col{flex:1 1 auto}.site-footer--centered .site-footer__col ul{list-style:none;padding:0;margin:0;display:flex;flex-wrap:wrap;justify-content:center;gap:16px}.footer{color:var(--footer-color, var(--color-ink-4));background:var(--footer-bg, var(--surface-1));padding:30px;font-size:13px;margin-top:auto}.footer a:not(.but):not(.focus-color){color:var(--footer-color, var(--color-ink-4))}.footer a:hover{color:var(--focus-color, var(--color-primary))}.footer .container-footer{max-width:calc(var(--mian-max-width, var(--container, 1200px)) + 180px);margin:0 auto;width:100%}.footer .list-inline{display:flex;align-items:center;gap:20px;list-style:none;margin:0 0 12px;padding:0}.footer .list-inline>li{flex:1;margin:5px 15px}.footer .list-inline>li+li+li{float:right;display:flex;align-items:flex-end;flex-direction:column;gap:10px}.footer a.footer-logo img,.footer .footer-logo img{height:40px;width:auto;max-width:180px;vertical-align:middle;display:inline-block}.footer .title-h-left{position:relative;margin:8px 0 10px;padding-bottom:8px;font-size:15px;font-weight:700;color:var(--color-ink, #333)}.footer .title-h-left:before{transition:.4s;transform-origin:left;position:absolute;content:"";width:40px;height:3px;background:var(--theme-color, var(--color-primary));left:0;bottom:3px;border-radius:5px;box-shadow:1px 1px 3px -1px var(--theme-color, var(--color-primary))}.footer .footer-muted,.footer .em09{font-size:.92em;color:var(--muted-2-color, var(--color-ink-4));line-height:1.7}.footer .opacity8{opacity:.8}.footer .fcode-links{margin:0 0 8px;line-height:1.9}.footer .fcode-links>a+a:before{content:"";width:4px;height:4px;margin:0 .5em;border-radius:50%;display:inline-block;vertical-align:middle;background:var(--main-color, var(--color-ink-2));opacity:.3;vertical-align:.2em}.footer .toggle-radius{background:var(--this-bg, var(--main-border-color, var(--surface-2, #f0f0f0)));width:29px;height:29px;display:inline-block;text-align:center;border-radius:100%;position:relative;vertical-align:text-top;line-height:29px;color:var(--footer-color, var(--color-ink-3));text-decoration:none}.footer .footer-contact{margin-top:10px}.footer .footer-contact>a{font-size:20px;margin:5px;width:35px;height:35px;line-height:35px;display:inline-block;text-align:center;border-radius:100%;background:var(--this-bg, var(--main-border-color, var(--surface-2, #f0f0f0)));color:var(--footer-color, var(--color-ink-3));text-decoration:none;vertical-align:text-top;position:relative;transition:color .15s ease,background .15s ease}.footer .footer-contact>a:hover{color:#fff;background:var(--focus-color, var(--color-primary))}.footer .footer-contact>a i{vertical-align:middle}.footer .footer-wechat-img{display:none;position:absolute;width:140px;bottom:90%;left:-55px;padding-bottom:20px;z-index:5}.footer .hover-show{position:relative}.footer .hover-show:hover .footer-wechat-img,.footer .hover-show:focus .footer-wechat-img{display:block}.footer .footer-wechat-img img{box-shadow:0 5px 10px #0003;border-radius:4px;width:100%;height:auto;background:#fff}.footer .footer-miniimg img{width:90px;margin:0 6px;height:auto;max-width:100%;display:block;border-radius:4px}.footer .footer-miniimg-2{justify-content:flex-end}.footer .footer-miniimg-2 img{height:36px;border-radius:var(--main-radius)}.footer .footer-miniimg-2 .opacity8{display:none}.footer .footer-conter{margin:8px 0 0;line-height:1.8;color:var(--footer-color, var(--color-ink-4));font-size:13px}.footer .mt10{margin-top:10px}@media(max-width:767px){.footer .hidden-xs,.hidden-xs{display:none!important}.footer{padding:10px 0}.footer .container-footer{text-align:center;padding:0 16px}.footer .list-inline{gap:10px;flex-direction:column;align-items:center;justify-content:center}.footer .list-inline>li+li+li{float:none;align-items:center}.footer .list-inline>li{margin:12px 0;max-width:none!important}.footer .footer-miniimg-2{justify-content:center}.footer .title-h-left:before{left:50%;transform:translate(-50%)}}.float-right{position:fixed;bottom:60px;right:20px;z-index:1030;text-align:center;padding-bottom:constant(safe-area-inset-bottom);padding-bottom:env(safe-area-inset-bottom);transition:.3s}.float-right .admin-set-page .admin-set-info{position:absolute;top:50%;right:.7em;transform:translateY(-50%);white-space:nowrap;line-height:1.5;margin:0;box-shadow:none;text-align:center;z-index:1}body:has(.modal.in) .float-right,.modal-open .float-right,.imgbox-show .float-right,.scroll-down .scroll-down-hide.float-right,.scroll-ing .scrolling-hide.float-right{transform:translate(200%)}body:has(.modal.in) .float-right.position-center,.modal-open .float-right.position-center,.imgbox-show .float-right.position-center,.scroll-down .scroll-down-hide.float-right.position-center,.scroll-ing .scrolling-hide.float-right.position-center{transform:translate(200%,-50%)}.float-right.position-center{bottom:auto;top:50%;transform:translateY(-50%)}.float-right .float-btn{width:40px;min-height:40px;display:flex;justify-content:center;align-items:center;flex-direction:column;padding:7px 0;font-size:1.4em;--this-color: var(--muted-2-color);--this-bg: var(--float-btn-bg);background:var(--this-bg);position:relative;color:var(--this-color)!important}.float-right .float-btn text{font-size:12px;line-height:1;margin-top:5px}.float-right badge{transform:translate(-11px,-5px) scale(.85);margin-right:-50px;right:auto;left:35px;font-size:12px;position:absolute}.filter .float-btn{-webkit-backdrop-filter:saturate(2) blur(10px);backdrop-filter:saturate(2) blur(10px)}.float-right.square .float-btn+.float-btn{border-top:1px solid var(--main-shadow)}.float-right.round .float-btn{margin-top:6px;border-radius:8px}.float-btn.ontop{opacity:0;transition:.5s;transform:translateY(10px);visibility:hidden}.ontop.show{opacity:1;transform:translateY(0);visibility:unset;display:flex!important}.float-btn .hover-show-con{right:35px;left:auto;width:200px;margin-right:15px;top:0;font-size:14px;line-height:1.4;color:var(--main-color);padding:10px;margin-top:0;border-radius:var(--main-radius);cursor:auto}.float-btn .dropdown-menu:before{left:auto;top:15px;right:-5px}.float-btn.service-wechat .hover-show-con{width:120px;top:-40px;padding:0}.float-btn.service-wechat .dropdown-menu:before{top:53px}.float-btn.qrcode-btn .hover-show-con{width:150px;top:-60px;padding:16px 5px 12px;text-align:center}.float-btn.qrcode-btn .qrcode{background:#fff;display:inline-flex;padding:5px}.float-btn.qrcode-btn .dropdown-menu:before{top:73px}.float-btn.newadd-btns .drop-newadd{width:137px;top:-74px}.float-btn.newadd-btns .drop-newadd:before{left:auto;top:88px;right:-5px}@media(max-width:767px){.float-btn .hover-show-con{max-width:240px}}@media(max-width:640px){.float-right{right:10px}}@keyframes aksrt-spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.aksrt-hover-zoom{transition:transform .3s ease}.aksrt-hover-zoom:hover{transform:scale(1.02)}.aksrt-hover-zoom-sm{transition:transform .3s ease}.aksrt-hover-zoom-sm:hover{transform:scale(1.02)}.aksrt-hover-show{position:relative}.aksrt-hover-show__content{position:absolute;opacity:0;visibility:hidden;transform:translateY(6px);transition:opacity .3s,transform .3s,visibility .3s;z-index:10}.aksrt-hover-show:hover .aksrt-hover-show__content{opacity:1;visibility:visible;transform:translateY(0)}.aksrt-ic-close{transition:transform .3s;cursor:pointer}.aksrt-ic-close:hover{transform:rotate(270deg)}.aksrt-hover-zoom-img img{transition:transform .3s ease}.aksrt-hover-zoom-img:hover img{transform:scale(1.02)}.aksrt-hover-zoom-img-sm img{transition:transform .3s ease}.aksrt-hover-zoom-img-sm:hover img{transform:scale(1.02)}.aksrt-hover-lift{transition:transform .2s ease,box-shadow .2s ease}.aksrt-hover-lift:hover{transform:none;box-shadow:0 4px 12px #0000001a}::view-transition-old(root),::view-transition-new(root){animation:none;mix-blend-mode:normal}.dark::view-transition-old(root),body.dark-theme::view-transition-old(root){z-index:1}.dark::view-transition-new(root),body.dark-theme::view-transition-new(root){z-index:2147483646}::view-transition-old(root){z-index:2147483646}::view-transition-new(root){z-index:1}@supports not ((-webkit-backdrop-filter: blur(20px)) or (backdrop-filter: blur(20px))){:root{--aksrt-blur-bg: #ffffff}.dark{--aksrt-blur-bg: var(--surface-1)}}body.aksrt-scroll-down .site-nav.nav-scroll-hide{transform:translateY(-100%);transition:transform .3s ease}body.aksrt-scroll-up .site-nav.nav-scroll-hide{transform:translateY(0);transition:transform .3s ease}body.aksrt-scrolling .aksrt-floating-right.scrolling-hide{opacity:0;pointer-events:none;transition:opacity .2s}body.aksrt-body-scroll .aksrt-show-on-scroll{opacity:1;visibility:visible}.aksrt-show-on-scroll{opacity:0;visibility:hidden;transition:opacity .3s,visibility .3s}.aksrt-cursor-zoom-in{cursor:zoom-in}.aksrt-announcement{position:relative;overflow:hidden;padding:10px 12px;background:var(--surface-2, #F4F5F7);border-radius:var(--radius, 10px)}.aksrt-announcement__icon{position:absolute;left:12px;top:50%;transform:translateY(-50%);color:var(--color-primary, var(--color-primary));font-size:14px;z-index:1}.aksrt-announcement__track{margin-left:24px;overflow:hidden;height:20px}.aksrt-announcement__list{margin:0;padding:0;list-style:none;animation:aksrt-marquee 10s linear infinite}.aksrt-announcement__item{height:20px;line-height:20px;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.aksrt-announcement__item a{color:var(--color-ink-2);text-decoration:none}.aksrt-announcement__item a:hover{color:var(--color-primary, var(--color-primary))}@keyframes aksrt-marquee{0%{transform:translateY(0)}to{transform:translateY(-100%)}}.aksrt-announcement:hover .aksrt-announcement__list{animation-play-state:paused}[data-aksrt-animate]{will-change:opacity,transform}[data-aksrt-sticky]{will-change:transform}.aksrt-user-ranking{margin:0;padding:0;list-style:none}.aksrt-user-ranking__item{display:flex;align-items:center;gap:10px;padding:10px 0;border-bottom:1px solid var(--color-line-soft, #F0F1F3)}.aksrt-user-ranking__item:last-child{border-bottom:none}.aksrt-user-ranking__rank{display:flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:6px;background:var(--surface-2, #F4F5F7);color:var(--color-ink-3);font-size:12px;font-weight:600;flex-shrink:0}.aksrt-user-ranking__item--top:nth-child(1) .aksrt-user-ranking__rank{background:gold;color:#fff}.aksrt-user-ranking__item--top:nth-child(2) .aksrt-user-ranking__rank{background:silver;color:#fff}.aksrt-user-ranking__item--top:nth-child(3) .aksrt-user-ranking__rank{background:#cd7f32;color:#fff}.aksrt-user-ranking__avatar{flex-shrink:0}.aksrt-user-ranking__avatar img{width:36px;height:36px;border-radius:50%;object-fit:cover}.aksrt-user-ranking__name{flex:1;min-width:0;font-size:14px;color:var(--color-ink);text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.aksrt-user-ranking__name:hover{color:var(--color-primary, var(--color-primary))}.aksrt-user-ranking__value{font-size:12px;color:var(--color-ink-4, #8A8F98);white-space:nowrap}.aksrt-cover-cards{display:grid;grid-template-columns:repeat(var(--cols, 2),1fr);gap:12px}.aksrt-cover-card{position:relative;border-radius:var(--radius-lg, 8px);overflow:hidden;min-height:160px;display:flex;align-items:flex-end;transition:transform .3s,box-shadow .3s}.aksrt-cover-card:hover{transform:translateY(-3px);box-shadow:0 8px 24px #00000026}.aksrt-cover-card__bg{position:absolute;inset:0}.aksrt-cover-card__bg img{width:100%;height:100%;object-fit:cover;transition:transform .5s}.aksrt-cover-card:hover .aksrt-cover-card__bg img{transform:scale(1.02)}.aksrt-cover-card__overlay{position:absolute;inset:0;background:linear-gradient(180deg,#0000 30%,#0009)}.aksrt-cover-card__content{position:relative;z-index:1;padding:16px;width:100%}.aksrt-cover-card__link{text-decoration:none;color:inherit;display:block}.aksrt-cover-card__title{margin:0 0 4px;font-size:16px;font-weight:600;color:#fff;text-shadow:0 1px 3px rgba(0,0,0,.4)}.aksrt-cover-card__desc{margin:0;font-size:13px;color:#ffffffd9;text-shadow:0 1px 2px rgba(0,0,0,.3);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.aksrt-video-widget{position:relative;width:100%;overflow:hidden;border-radius:var(--radius, 10px);background:#000}.aksrt-video-widget iframe,.aksrt-video-widget video{position:absolute;top:0;left:0;width:100%;height:100%;border:none}.aksrt-float-label{position:relative}.aksrt-float-label__input{width:100%;padding:14px 12px 6px;font-size:14px;border:1px solid var(--color-line);border-radius:var(--radius, 10px);background:var(--surface-1, #fff);color:var(--color-ink);transition:border-color .2s}.aksrt-float-label__input:focus{outline:none;border-color:var(--color-primary, var(--color-primary))}.aksrt-float-label__label{position:absolute;left:12px;top:50%;transform:translateY(-50%);font-size:14px;color:var(--color-ink-4, #8A8F98);pointer-events:none;transition:all .2s ease}.aksrt-float-label__input:focus+.aksrt-float-label__label,.aksrt-float-label__input:not(:placeholder-shown)+.aksrt-float-label__label{top:8px;transform:translateY(0);font-size:11px;color:var(--color-primary, var(--color-primary))}[data-aksrt-parallax]{will-change:transform;transition:transform .1s linear}.aksrt-level-bar{position:relative;height:8px;background:var(--surface-2, #F4F5F7);border-radius:4px;overflow:hidden}.aksrt-level-bar__fill{height:100%;background:linear-gradient(90deg,var(--color-primary, var(--color-primary)),var(--color-primary-active, #5A85BD));border-radius:4px;transition:width .5s ease}.aksrt-level-bar__label{position:absolute;right:0;top:-18px;font-size:11px;color:var(--color-ink-4, #8A8F98)}.empty-state{text-align:center;padding:60px 20px;color:var(--color-ink-3, #777)}.empty-state__icon{font-size:56px;color:var(--surface-3, #e8e8e8);margin-bottom:16px;line-height:1}.empty-state__icon i{display:inline-block}.empty-state__title{font-size:16px;font-weight:500;color:var(--color-ink-2, #4e5358);margin-bottom:8px}.empty-state__desc{font-size:13px;color:var(--color-ink-4, #999);margin-bottom:20px;max-width:320px;margin-left:auto;margin-right:auto;line-height:1.6}.empty-state__action{display:inline-flex;align-items:center;gap:6px}.empty-state--sm{padding:30px 16px}.empty-state--sm .empty-state__icon{font-size:40px}.empty-state--sm .empty-state__title{font-size:14px}.empty-state--sm .empty-state__desc{font-size:12px}.search-empty{text-align:center;padding:80px 20px}.search-empty__icon{font-size:64px;color:var(--surface-3, #e8e8e8);margin-bottom:16px}.search-empty__title{font-size:18px;font-weight:500;color:var(--color-ink-2, #4e5358);margin-bottom:8px}.search-empty__suggestions{font-size:13px;color:var(--color-ink-4, #999)}.search-empty__suggestions a{color:var(--color-primary, #f04494);text-decoration:none}.search-empty__suggestions a:hover{text-decoration:underline}@media(max-width:767px){.empty-state{padding:40px 16px}.empty-state__icon{font-size:44px}}.pagination,.aksrt-pagination,.navigation.pagination .nav-links,.pagenav.ajax-pag{display:flex;align-items:center;justify-content:center;gap:6px;padding:24px 0;flex-wrap:wrap}.pagination a,.pagination span,.aksrt-pagination a,.aksrt-pagination span,.navigation.pagination a,.navigation.pagination span,.pagenav.ajax-pag a,.pagenav.ajax-pag span{display:inline-flex;align-items:center;justify-content:center;min-width:34px;min-height:34px;padding:6px 10px;font-size:13px;line-height:1.4;color:var(--muted-color, var(--color-ink-3));background:var(--surface-4, var(--main-border-color, rgba(50, 50, 50, .06)));border:1px solid transparent;border-radius:var(--radius-xs, 3px);text-decoration:none;transition:all .2s;user-select:none}.pagination a:hover,.aksrt-pagination a:hover,.navigation.pagination a:hover,.pagenav.ajax-pag a:hover{opacity:1;color:var(--color-primary);background:var(--color-primary-soft)}.pagination .current,.aksrt-pagination .current,.navigation.pagination .current,.pagenav.ajax-pag .current{color:var(--color-on-primary, #fff);background:var(--color-primary, #f04494);border-color:var(--color-primary, #f04494);font-weight:500}.pagination .dots,.aksrt-pagination .dots,.navigation.pagination .dots,.pagenav.ajax-pag .dots{border:none;background:none;color:var(--color-ink-4, #999);cursor:default;min-width:auto;padding-left:4px;padding-right:4px}.pagination .prev,.pagination .next,.aksrt-pagination .prev,.aksrt-pagination .next,.navigation.pagination .prev,.navigation.pagination .next,.pagenav.ajax-pag .prev,.pagenav.ajax-pag .next{font-size:12px}.pagination .prev i,.pagination .next i,.aksrt-pagination .prev i,.aksrt-pagination .next i,.navigation.pagination .prev i,.navigation.pagination .next i,.pagenav.ajax-pag .prev i,.pagenav.ajax-pag .next i{font-size:11px}.pagenav.ajax-pag .pag-jump,.aksrt-pagination .pag-jump,.navigation.pagination .pag-jump{display:inline-flex;align-items:center;gap:6px;margin-left:6px}.pagenav.ajax-pag .jump-input,.aksrt-pagination .jump-input,.navigation.pagination .jump-input{width:64px;min-width:64px;padding:6px 8px 5px;font-size:13px;line-height:1.4;color:var(--muted-color, var(--color-ink-3));background:var(--main-bg-color, #fff);border:1px solid var(--main-border-color, rgba(50, 50, 50, .06));border-radius:3px;box-shadow:none}.pagenav.ajax-pag .jump-input:focus,.aksrt-pagination .jump-input:focus,.navigation.pagination .jump-input:focus{outline:none;border-color:var(--focus-color, var(--color-primary, #f04494))}.pagenav.ajax-pag .pag-jump .ajax-next,.aksrt-pagination .pag-jump .ajax-next,.navigation.pagination .pag-jump .ajax-next{min-width:auto;cursor:pointer}.pagination__nav{display:flex;gap:8px;justify-content:center;margin-top:8px}.pagination__nav a{display:inline-flex;align-items:center;gap:4px;padding:6px 14px;font-size:13px;color:var(--color-ink-2, #4e5358);background:var(--surface-1, #fff);border:1px solid var(--color-line, rgba(50, 50, 50, .06));border-radius:6px;text-decoration:none;transition:all .2s}.pagination__nav a:hover{color:var(--color-primary, #f04494);border-color:var(--color-primary, #f04494)}@media(max-width:767px){.pagination a,.pagination span,.aksrt-pagination a,.aksrt-pagination span,.navigation.pagination a,.navigation.pagination span,.pagenav.ajax-pag a,.pagenav.ajax-pag span{min-width:28px;font-size:12px;margin:2px}.pagenav.ajax-pag .pag-jump,.aksrt-pagination .pag-jump,.navigation.pagination .pag-jump{width:100%;justify-content:center;margin-left:0;margin-top:6px}.pagenav.ajax-pag .jump-input,.aksrt-pagination .jump-input,.navigation.pagination .jump-input{width:58px;min-width:58px;font-size:12px}}.aksrt-modal-runtime{--modal-ink: var(--color-ink, #333333);--modal-ink-2: var(--color-ink-2, #4e5358);--modal-muted: var(--color-ink-3, #777777);--modal-line: var(--color-line-stronger, rgba(50, 50, 50, .12));--modal-line-soft: var(--color-line, rgba(50, 50, 50, .06));--modal-surface: var(--surface-1, #ffffff);--modal-surface-2: var(--surface-2, #eeeeee);--modal-surface-3: var(--surface-4, #f8f8f8);--modal-primary: var(--color-primary, #f04494);--modal-on-primary: var(--color-on-primary, #ffffff);--modal-warning: var(--color-warning, #f59e0b);--modal-success: var(--color-success, #10b981);--modal-danger: var(--color-danger, #ef4444)}html.aksrt-modal-runtime-open,body.aksrt-modal-runtime-open{overflow:hidden}.aksrt-modal-runtime.modal-mini,.aksrt-modal-runtime.full-sm{width:auto;max-width:none;margin:0}.aksrt-modal-runtime{position:fixed;inset:0;z-index:100050;display:flex;align-items:center;justify-content:center;opacity:0;visibility:hidden;transition:opacity .2s ease,visibility .2s ease;pointer-events:none}.aksrt-modal-runtime.is-open{opacity:1;visibility:visible;pointer-events:auto}.aksrt-modal-runtime__overlay{position:absolute;inset:0;background:#00000080}.aksrt-modal-runtime__dialog{position:relative;z-index:1;width:min(440px,calc(100vw - 32px));max-height:min(88vh,720px);overflow:auto;background:var(--surface-1, #fff);border-radius:16px;box-shadow:0 18px 48px #0f121c38;transform:translateY(12px) scale(.98);transition:transform .22s cubic-bezier(.22,1,.36,1);color:var(--color-ink, #1f2937)}.aksrt-modal-runtime.is-open .aksrt-modal-runtime__dialog{transform:translateY(0) scale(1)}.aksrt-modal-runtime--bottom{align-items:flex-end}.aksrt-modal-runtime--bottom .aksrt-modal-runtime__dialog{width:100%;max-width:100%;border-radius:16px 16px 0 0;max-height:90vh;transform:translateY(24px)}.aksrt-modal-runtime--bottom.is-open .aksrt-modal-runtime__dialog{transform:translateY(0)}.aksrt-modal-runtime__header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px 18px 0}.aksrt-modal-runtime__title{margin:0;font-size:17px;line-height:1.35}.aksrt-modal-runtime__content{padding:18px 18px 16px}.aksrt-modal-runtime__loading,.aksrt-modal-runtime__error-body{padding:28px 8px;text-align:center;color:var(--color-ink-2, #6b7280);font-size:14px}.aksrt-modal-runtime__skeleton{display:grid;gap:12px;padding:8px 2px}.aksrt-modal-runtime__skeleton-line,.aksrt-modal-runtime__skeleton-block{display:block;border-radius:8px;background:linear-gradient(90deg,#94a3b829,#94a3b84d,#94a3b829);background-size:200% 100%;animation:aksrt-modal-skeleton 1.2s ease-in-out infinite}.aksrt-modal-runtime__skeleton-line{width:72%;height:12px}.aksrt-modal-runtime__skeleton-line.is-wide{width:92%;height:18px}.aksrt-modal-runtime__skeleton-line.is-short{width:44%}.aksrt-modal-runtime__skeleton-block{width:100%;min-height:84px}.aksrt-modal-runtime__retry{cursor:pointer}@keyframes aksrt-modal-skeleton{0%{background-position:100% 0}to{background-position:-100% 0}}.aksrt-modal-runtime .touch.border-title,.aksrt-modal-runtime .border-title{font-size:16px;font-weight:700;margin:0 0 14px;display:flex;align-items:baseline;flex-wrap:wrap;gap:6px}.aksrt-modal-runtime .aksrt-modal-runtime__close{position:absolute;top:10px;right:10px;width:32px;height:32px;border:0;border-radius:999px;background:transparent;color:var(--color-ink-3, #9ca3af);cursor:pointer;font-size:18px;line-height:1}.aksrt-modal-runtime .but-average.modal-buts,.aksrt-modal-runtime .modal-buts{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:16px}.aksrt-modal-runtime .but-average.modal-buts .but,.aksrt-modal-runtime .modal-buts .but,.aksrt-modal-runtime .modal-buts button{min-height:40px;border-radius:10px;border:1px solid var(--color-line, #e5e7eb);background:var(--surface-4, #f3f4f6);color:var(--color-ink, #111);font-weight:600;cursor:pointer}.aksrt-modal-runtime .modal-buts .but.c-blue,.aksrt-modal-runtime .modal-buts button.c-blue,.aksrt-modal-runtime .modal-buts .btn-primary{background:var(--color-primary, #f04494);border-color:color-mix(in srgb,var(--color-primary, #f04494) 70%,#000 0%);color:var(--color-on-primary, #fff)}.aksrt-modal-runtime.aksrt-checkin-details-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog{width:min(400px,calc(100vw - 32px));border-radius:var(--main-radius, 8px);background:var(--main-bg-color, #fff)}.aksrt-modal-runtime.aksrt-checkin-details-modal .aksrt-modal-runtime__content{padding:0}.aksrt-modal-runtime.aksrt-checkin-details-modal .aksrt-modal-runtime__skeleton{padding:24px 18px}.ajaxpager.is-ajax-loading{position:relative;min-height:80px}.post_ajax_loader{display:none}.post_ajax_loader.is-loading{display:block;animation:aksrtFadeIn .2s ease both}.ajax-item.is-fading{opacity:.35;transition:opacity .15s ease}.aksrt-report-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog{width:min(600px,calc(100vw - 32px));border-radius:var(--main-radius, 8px);background:var(--main-bg-color, #fff)}.aksrt-report-modal .edit-header{position:relative;padding-right:32px}.aksrt-report-modal .edit-header .close{position:absolute;right:0;top:50%;transform:translateY(-50%);float:none;font-size:20px;line-height:1;cursor:pointer;background:none;border:0;padding:2px 6px}.aksrt-report-modal .form-but-radio .badg{cursor:pointer;user-select:none}.aksrt-user-ban-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog{width:min(600px,calc(100vw - 32px));border-radius:var(--main-radius, 8px);background:var(--main-bg-color, #fff)}.aksrt-user-ban-info-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog,.aksrt-user-ban-appeal-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog,.aksrt-user-ban-log-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog{width:min(400px,calc(100vw - 32px));border-radius:var(--main-radius, 8px);background:var(--main-bg-color, #fff)}.aksrt-user-ban-info-modal .aksrt-modal-runtime__skeleton,.aksrt-user-ban-appeal-modal .aksrt-modal-runtime__skeleton,.aksrt-user-ban-log-modal .aksrt-modal-runtime__skeleton,.aksrt-user-ban-modal .aksrt-modal-runtime__skeleton{padding:24px 18px}.aksrt-user-ban-modal .edit-header,.aksrt-user-ban-appeal-modal .edit-header{position:relative;padding-right:32px}.aksrt-user-ban-modal .edit-header .close,.aksrt-user-ban-appeal-modal .edit-header .close{position:absolute;right:0;top:50%;transform:translateY(-50%);float:none;font-size:20px;line-height:1;cursor:pointer;background:none;border:0;padding:2px 6px}.aksrt-user-ban-modal .modal-colorful-header,.aksrt-user-ban-modal .colorful-bg,.aksrt-user-ban-info-modal .modal-colorful-header,.aksrt-user-ban-info-modal .colorful-bg,.aksrt-user-ban-log-modal .modal-colorful-header,.aksrt-user-ban-log-modal .colorful-bg{position:relative;overflow:hidden;color:var(--this-color);background:var(--this-bg);background-size:120%}.aksrt-user-ban-modal .modal-colorful-header,.aksrt-user-ban-info-modal .modal-colorful-header,.aksrt-user-ban-log-modal .modal-colorful-header{position:absolute;top:0;right:0;left:0;display:flex;align-items:center;justify-content:center;height:120px;border-radius:var(--main-radius, 8px) var(--main-radius, 8px) 0 0}.aksrt-user-ban-modal .modal-colorful-header+*,.aksrt-user-ban-info-modal .modal-colorful-header+*,.aksrt-user-ban-log-modal .modal-colorful-header+*{margin-top:120px}.aksrt-user-ban-modal .modal-colorful-header>.close,.aksrt-user-ban-info-modal .modal-colorful-header>.close,.aksrt-user-ban-log-modal .modal-colorful-header>.close{position:absolute;top:10px;right:10px;z-index:9;color:var(--this-color);font-size:18px;opacity:.6}.aksrt-user-ban-modal .colorful-bg>.colorful-make,.aksrt-user-ban-modal .colorful-bg>.colorful-make:before,.aksrt-user-ban-modal .colorful-bg>.colorful-make:after,.aksrt-user-ban-info-modal .colorful-bg>.colorful-make,.aksrt-user-ban-info-modal .colorful-bg>.colorful-make:before,.aksrt-user-ban-info-modal .colorful-bg>.colorful-make:after,.aksrt-user-ban-log-modal .colorful-bg>.colorful-make,.aksrt-user-ban-log-modal .colorful-bg>.colorful-make:before,.aksrt-user-ban-log-modal .colorful-bg>.colorful-make:after{position:absolute;color:var(--this-color);background:var(--this-bg);border-radius:100%;content:""}.aksrt-user-ban-modal .colorful-bg>.colorful-make,.aksrt-user-ban-info-modal .colorful-bg>.colorful-make,.aksrt-user-ban-log-modal .colorful-bg>.colorful-make{top:60%;bottom:0;width:323px;height:290px;animation:aksrt-ban-colorful 120s linear infinite}.aksrt-user-ban-modal .colorful-bg>.colorful-make:before,.aksrt-user-ban-info-modal .colorful-bg>.colorful-make:before,.aksrt-user-ban-log-modal .colorful-bg>.colorful-make:before{top:-325px;right:-558px;bottom:0;width:677px;height:300px;opacity:.6;transform:rotate(341deg)}.aksrt-user-ban-modal .colorful-bg>.colorful-make:after,.aksrt-user-ban-info-modal .colorful-bg>.colorful-make:after,.aksrt-user-ban-log-modal .colorful-bg>.colorful-make:after{top:-170px;bottom:0;left:-262px;width:402px;height:214px;opacity:.4;transform:rotate(326deg)}.aksrt-user-ban-modal .colorful-bg>.colorful-make+*,.aksrt-user-ban-info-modal .colorful-bg>.colorful-make+*,.aksrt-user-ban-log-modal .colorful-bg>.colorful-make+*{position:relative}.aksrt-user-ban-modal .form-but-radio label{cursor:pointer}.aksrt-user-ban-modal .form-but-radio .but-radio,.aksrt-user-ban-modal .aksrt-user-ban-form label{user-select:none}@keyframes aksrt-ban-colorful{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.order-comment-from .shop-score-box{display:flex;align-items:center;gap:8px;min-height:36px}.order-comment-from .score-title{width:4.5em;flex:0 0 auto;font-size:13px;color:var(--color-ink-2, #6b7280)}.order-comment-from .score-icon{display:inline-flex;gap:4px;align-items:center;height:30px}.order-comment-from .icon-star{display:inline-flex;width:22px;height:22px;color:#d1d5db;cursor:pointer;transition:color .12s ease,transform .12s ease}.order-comment-from .icon-star svg{width:22px;height:22px;fill:currentColor}.order-comment-from .icon-star.active,.order-comment-from .icon-star.hover{color:#fc7049}.order-comment-from .icon-star.hover{transform:scale(1.08)}.order-comment-from .score-text{min-width:3em;font-size:13px;color:var(--color-ink-2, #6b7280)}.order-comment-from textarea.form-control{width:100%;border:1px solid var(--color-line, #e5e7eb);border-radius:10px;padding:10px 12px;font-size:14px;resize:vertical;min-height:96px;background:var(--surface-1, #fff);color:var(--color-ink, #111)}:is(.order-comment-from,.aksrt-report-modal) .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) .form-upload .preview .preview-item{width:80px;height:80px;border-radius:10px;border:1px dashed var(--color-line, #d1d5db);background:var(--surface-4, #f9fafb);position:relative;overflow:hidden;cursor:pointer}: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) .form-upload .preview .preview-item .remove{position:absolute;top:2px;right:2px;width:20px;height:20px;border:0;border-radius:999px;background:#0000008c;color:#fff;font-size:12px;line-height:1;cursor:pointer}:is(.order-comment-from,.aksrt-report-modal) .form-upload .hide{display:none!important}.order-comment-from .review-error{color:#ef4444;font-size:13px;margin-top:8px}@media(max-width:768px){.aksrt-modal-runtime:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog{width:calc(100vw - 20px)}}.notyn{position:fixed;bottom:6em;right:0;max-width:320px;z-index:10000000}.notyf{position:relative;margin-bottom:.6em;color:#fff;font-size:14px;line-height:1.42857143;float:right;min-width:200px;padding:1em 3em 1em 3.4em;box-shadow:-3px 3px 6px #0000001a;transform:translate(1.5em);transition:.3s;-webkit-backdrop-filter:saturate(5) blur(20px);backdrop-filter:saturate(5) blur(20px);-webkit-animation:notyf .4s cubic-bezier(.35,.71,.46,1.08);animation:notyf .4s cubic-bezier(.35,.71,.46,1.08)}.notyn-out .notyf{transform:translate(110%);opacity:.5;transition:.5s}@-webkit-keyframes notyf{0%{transform:translate(110%);opacity:0}to{transform:translate(1.5em);opacity:1}}@keyframes notyf{0%{transform:translate(110%);opacity:0}to{transform:translate(1.5em);opacity:1}}.notyf:before{font-family:"Font Awesome 5 Free";font-style:normal;font-weight:900;line-height:1;left:.8em;font-size:1.5em;position:absolute;top:0;bottom:0;margin:auto;height:20px}.notyf.success{background:linear-gradient(90deg,#0f93f9b3,#3dbdf9cc)}.notyf.success:before{content:""}.notyf.info{background:linear-gradient(90deg,#3aa236cc,#59f783cc)}.notyf.info:before{content:""}.notyf.warning,.notyf.load{background:linear-gradient(90deg,#fdaa47b3,#f79a0dcc)}.notyf.warning:before{content:""}.notyf.danger{background:linear-gradient(90deg,#fd451cb3,#fb6e4bcc)}.notyf.danger:before{content:""}.notyf .fa-close{float:right;padding:.6em;margin:-.4em}.notyf.load:before{content:"";width:.7em;height:.7em;display:inline-block;border:.08em solid transparent;border-width:calc(1px + .03em);border-radius:50%;border-top-color:var(--this-color);border-bottom-color:var(--this-color);-webkit-animation:huan-rotate 1s cubic-bezier(.7,.1,.31,.9) infinite;animation:huan-rotate 1s cubic-bezier(.7,.1,.31,.9) infinite}@-webkit-keyframes huan-rotate{0%{transform:rotate(0)}to{transform:rotate(360deg)}}@keyframes huan-rotate{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.aksrt-captcha-box{display:none}.aksrt-captcha-wrap{text-align:center}.aksrt-captcha-modal-overlay{position:fixed;inset:0;z-index:99999;display:flex;align-items:center;justify-content:center}.aksrt-captcha-modal-backdrop{position:absolute;inset:0;background:#0000008c;animation:aksrtFadeIn .2s ease}.aksrt-captcha-modal-box{position:relative;background:var(--surface-page,#fff);border-radius:12px;box-shadow:0 20px 60px #0000004d;width:90%;max-width:380px;animation:aksrtSlideUp .25s ease;overflow:hidden}.aksrt-captcha-modal-header{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid var(--color-line,#e5e7eb)}.aksrt-captcha-modal-title{font-size:15px;font-weight:600;color:var(--color-ink,#1f2937)}.aksrt-captcha-modal-close{background:none;border:none;font-size:22px;color:var(--color-ink-4,#9ca3af);cursor:pointer;padding:0;line-height:1;width:28px;height:28px;display:flex;align-items:center;justify-content:center;border-radius:6px}.aksrt-captcha-modal-close:hover{background:var(--color-bg-hover,#f3f4f6);color:var(--color-ink,#1f2937)}.aksrt-captcha-modal-body{padding:20px}.aksrt-captcha-modal-hint{font-size:13px;color:var(--color-ink-4,#9ca3af);margin:0 0 16px;text-align:center}.aksrt-captcha-modal-widget{display:flex;justify-content:center}@keyframes aksrtFadeIn{0%{opacity:0}to{opacity:1}}@keyframes aksrtSlideUp{0%{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}.aksrt-modal-runtime.aksrt-reward-modal:not(.aksrt-modal-runtime--bottom) .aksrt-modal-runtime__dialog{width:min(400px,calc(100vw - 32px));border-radius:var(--main-radius, 8px);background:var(--main-bg-color, #fff)}.aksrt-modal-runtime.aksrt-reward-modal .aksrt-modal-runtime__content{padding:0}.aksrt-reward-modal .modal-colorful-header+.aksrt-reward__body{margin-top:0;padding:135px 15px 15px;color:var(--main-color, var(--color-ink, #333))}.aksrt-reward__desc{margin:0 0 16px;text-align:center}.aksrt-modal-runtime.aksrt-reward-modal .aksrt-reward__amounts{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin:16px 0}.aksrt-modal-runtime.aksrt-reward-modal .aksrt-reward__amount{display:inline-flex;width:100%;min-width:0;min-height:46px;align-items:center;justify-content:center;gap:4px;margin:0;padding:8px 6px;border:1px solid var(--color-line-stronger, rgba(50, 50, 50, .12));border-radius:var(--radius, 8px);background:var(--surface-2, #f5f6f7);color:var(--color-ink-2, #4e5358);cursor:pointer;font-size:16px;font-weight:700;line-height:1.2;box-shadow:none;transition:border-color .2s ease,background-color .2s ease,color .2s ease,box-shadow .2s ease,transform .2s ease}.aksrt-modal-runtime.aksrt-reward-modal .aksrt-reward__amount:hover{border-color:var(--focus-color, var(--color-primary, #f04494));background:var(--color-primary-soft, rgba(240, 68, 148, .1));color:var(--focus-color, var(--color-primary, #f04494));opacity:1;transform:translateY(-1px)}.aksrt-modal-runtime.aksrt-reward-modal .aksrt-reward__amount:focus-visible{outline:0;box-shadow:0 0 0 3px var(--color-primary-soft, rgba(240, 68, 148, .2))}.aksrt-modal-runtime.aksrt-reward-modal .aksrt-reward__amount.is-active,.aksrt-modal-runtime.aksrt-reward-modal .aksrt-reward__amount.is-active:hover{border-color:var(--focus-color, var(--color-primary, #f04494));background:var(--focus-color, var(--color-primary, #f04494));color:#fff;box-shadow:0 3px 10px var(--color-primary-soft, rgba(240, 68, 148, .2));opacity:1;transform:none}.aksrt-reward__currency{font-size:13px;font-weight:400}.aksrt-reward__value{font-size:15px}.aksrt-modal-runtime.aksrt-reward-modal .aksrt-reward__custom{display:flex;width:100%;align-items:center;justify-content:space-between;gap:12px;margin:0 0 16px;color:var(--color-ink-2, #4e5358);font-size:14px;font-weight:600}.aksrt-reward__custom-label{flex:0 0 auto}.aksrt-modal-runtime.aksrt-reward-modal .aksrt-reward__input-wrap{display:flex;width:min(190px,62%);min-height:44px;align-items:center;padding:0 10px 0 12px;border:1px solid var(--color-line-stronger, rgba(50, 50, 50, .12));border-radius:var(--radius, 8px);background:var(--surface-1, #fff);transition:border-color .2s ease,box-shadow .2s ease}.aksrt-modal-runtime.aksrt-reward-modal .aksrt-reward__input-wrap:focus-within{border-color:var(--focus-color, var(--color-primary, #f04494));box-shadow:0 0 0 3px var(--color-primary-soft, rgba(240, 68, 148, .1))}.aksrt-reward__input-currency{flex:0 0 auto;margin-right:6px;color:var(--muted-color, var(--color-ink-3, #777));font-size:14px;font-weight:600}.aksrt-modal-runtime.aksrt-reward-modal .aksrt-reward__input{width:100%;min-width:0;min-height:42px;padding:0;border:0;outline:0;background:transparent;color:var(--color-ink, #333);font-size:15px;font-weight:600;text-align:right;-moz-appearance:textfield;box-shadow:none}.aksrt-reward__input::-webkit-inner-spin-button,.aksrt-reward__input::-webkit-outer-spin-button{margin:0;-webkit-appearance:none}.aksrt-reward__input::placeholder{color:var(--muted-color, var(--color-ink-4, #999));opacity:1}.aksrt-reward__input:disabled,.aksrt-reward__amount:disabled{cursor:wait;opacity:.6}.aksrt-reward__submit{min-height:42px}.aksrt-reward__submit:disabled{cursor:wait;opacity:.7}.aksrt-reward__message{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.aksrt-reward__captcha{margin:0 0 14px}.aksrt-modal-runtime--bottom.aksrt-reward-modal .aksrt-modal-runtime__dialog,.aksrt-modal-runtime--bottom.aksrt-reward-modal .modal-colorful-header{border-radius:16px 16px 0 0}
diff --git a/assets/css/frontend/modal-runtime.css b/assets/css/frontend/modal-runtime.css
index 978538f3..07493c0d 100644
--- a/assets/css/frontend/modal-runtime.css
+++ b/assets/css/frontend/modal-runtime.css
@@ -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; }
diff --git a/assets/css/routes/orders.css b/assets/css/routes/orders.css
index 9357a1b9..01628add 100644
--- a/assets/css/routes/orders.css
+++ b/assets/css/routes/orders.css
@@ -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; }
diff --git a/assets/css/routes/tickets.css b/assets/css/routes/tickets.css
new file mode 100644
index 00000000..21fd4a9a
--- /dev/null
+++ b/assets/css/routes/tickets.css
@@ -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;
+}
diff --git a/assets/js/order-after-sale.js b/assets/js/order-after-sale.js
deleted file mode 100644
index d404fe0d..00000000
--- a/assets/js/order-after-sale.js
+++ /dev/null
@@ -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, ''');
- }
-
- function colorfulHeader(title, icon) {
- return '';
- }
-
- function orderAmountText(order) {
- var raw = String(order && order.amount != null ? order.amount : '0');
- if (order && order.is_points) {
- return escapeHtml(String(order.currency_symbol || '积分')) + ' ' + (/^\d+$/.test(raw) ? raw : '0');
- }
- return '¥' + raw;
- }
-
- function orderSummaryHtml(order) {
- if (!order) return '';
- return '' +
- '
商品 ' +
- '' + escapeHtml(order.product_name || '订单') + '
' +
- '
订单号 ' +
- '' + escapeHtml(order.order_number || '') + '
' +
- '
实付 ' +
- '' + orderAmountText(order) + '
' +
- '
';
- }
-
- function buildApplyHtml(order) {
- var cfg = settings();
- var reasons = reasonOptions();
- var materials = materialOptions();
-
- var html = '';
- html += colorfulHeader('申请售后', 'fa-undo-alt');
- html += '
';
- return html;
- }
-
- function detailRow(label, value) {
- if (value === '' || value == null) return '';
- return '' + escapeHtml(label) + ' ' +
- '' + escapeHtml(value) + '
';
- }
-
- // 处理记录节点标题:用户侧提交/撤脱离不开工单状态,单独措辞;
- // 后台提交直接用目标状态,与后台列表口径一致。
- 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 '';
- }
- var html = '处理记录
';
- html += '
';
- entries.forEach(function (entry) {
- var status = String((entry && entry.status) || '');
- var note = String((entry && entry.note) || '');
- html += '';
- html += ' ';
- html += '';
- // [节点][处理状态][时间] 同一行,处理备注另起一行
- html += '
' +
- '' + escapeHtml(handlingEntryTitle(entry)) + ' ' +
- '' + escapeHtml(String((entry && entry.created_at) || '')) + ' ' +
- '
';
- if (note !== '') {
- html += '
' + escapeHtml(note).replace(/\r\n|\r|\n/g, ' ') + '
';
- }
- html += '
';
- html += ' ';
- });
- html += ' ';
- return html;
- }
-
- function buildDetailHtml(order) {
- var afterSale = (order && order.after_sale) || {};
- var latest = afterSale.latest || {};
- var status = String(latest.status || '');
-
- var html = '';
- html += colorfulHeader('售后详细', 'fa-clipboard-list');
- html += '
';
- html += orderSummaryHtml(order);
-
- html += '
' +
- '售后状态 ' +
- '' +
- '' + escapeHtml(STATUS_LABELS[status] || status) + ' ' +
- // 仅「待审核」可撤销(后端 cancel 仅放行 pending):入口随状态徽章渲染。
- (status === 'pending'
- ? '撤销售后 '
- : '') +
- '
';
-
- html += '
';
- html += detailRow('申请时间', String(latest.created_at || ''));
- html += '
';
- // 处理记录:每次后台提交一条,替代原先单行的「处理备注 / 完成时间」。
- 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 += '
申请原因
' +
- '
' + escapeHtml(latest.reason).replace(/\r\n|\r|\n/g, ' ') + '
';
- }
-
- var images = Array.isArray(latest.images) ? latest.images : [];
- if (images.length) {
- html += '
举证图片
';
- images.forEach(function (url) {
- html += '
';
- });
- html += '
';
- }
-
- html += '
';
-
- if (afterSale.can_reapply) {
- // 可再次申请:仅保留单个主按钮(与禁封申诉弹窗单按钮同式)。
- html += '
' +
- ' 再次申请售后 ' +
- '
';
- } else {
- // 不可再申请售后:不再展示阻断文案,仅保留关闭入口。
- html += '
我知道了
';
- }
-
- html += '
';
- return html;
- }
-
- function buildExpiredHtml() {
- var text = String(settings().expiredText || '抱歉,您的订单已超过售后申请时效');
- var html = '';
- html += colorfulHeader('售后提示', 'fa-clock');
- // 超过时效提示(后台 after_sale_expired_text,服务端已 wp_kses_post,支持 HTML)。
- html += '
' + text + '
';
- html += '
我知道了
';
- html += '
';
- return html;
- }
-
- // 撤销售后二次确认弹窗:待审核工单入口点入,确认后 DELETE 售后工单。
- function buildWithdrawHtml(orderNumber) {
- var html = '';
- html += colorfulHeader('撤销售后', 'fa-undo-alt');
- html += '
撤销后本次售后申请将关闭,订单恢复为可再次申请售后的状态。确定撤销吗?
';
- html += '
' +
- '取消 ' +
- '确认撤销 ' +
- '
';
- html += '
';
- return html;
- }
-
- function openModal(options) {
- if (!window.AksrtModalRuntime || typeof window.AksrtModalRuntime.open !== 'function') {
- notyf('售后组件加载失败,请刷新页面后重试', 'danger');
- return null;
- }
- var shell = window.AksrtModalRuntime.open({
- html: options.html,
- className: options.className,
- kind: options.kind,
- mobileBottom: true,
- });
- bindShell(shell);
- return shell;
- }
-
- function openApply(order) {
- return openModal({
- html: buildApplyHtml(order),
- className: 'aksrt-after-sale-apply-modal',
- kind: 'after-sale-apply',
- });
- }
-
- function openDetail(order) {
- return openModal({
- html: buildDetailHtml(order),
- className: 'aksrt-after-sale-detail-modal',
- kind: 'after-sale-detail',
- });
- }
-
- function openExpired() {
- return openModal({
- html: buildExpiredHtml(),
- className: 'aksrt-after-sale-expired-modal',
- kind: 'after-sale-expired',
- });
- }
-
- 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();
-})();
diff --git a/assets/js/order-center.js b/assets/js/order-center.js
index 7eeb8432..7e89d231 100644
--- a/assets/js/order-center.js
+++ b/assets/js/order-center.js
@@ -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 += "";
- var afterSales = Array.isArray(order.after_sales) ? order.after_sales : [];
- if (afterSales.length) {
- html += '售后记录 ';
- afterSales.forEach(function (item, index) {
- var labels = { pending: "待审核", processing: "处理中", approved: "已通过", rejected: "已拒绝", cancelled: "已取消", completed: "已完成" };
- var asStatus = String(item.status || "");
- var asLabel = labels[asStatus] || asStatus;
- html += '';
- html += '' + escapeHtml(asLabel) + " ";
- if (item.created_at) html += "" + escapeHtml(item.created_at) + " ";
- html += "
";
- // 原因 / 申请理由:新数据走 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 += '原因: ' + escapeHtml(reasonLabel) + "
";
- if (applyDesc !== "") html += '申请理由: ' + escapeHtml(applyDesc).replace(/\r\n|\r|\n/g, " ") + "
";
- } else if (item.reason) {
- html += '' + escapeHtml(item.reason).replace(/\r\n|\r|\n/g, " ") + "
";
- }
- // 查看详细:逐记录构造售后详细弹窗载荷(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 += '";
- html += " ";
- });
- html += " ";
- }
-
if (reconciliation.state === "refresh_available") {
html += '';
html += ' ';
@@ -225,17 +172,12 @@
html += "
";
}
- // 底部按钮与售后详细弹窗同式:but-average modal-buts;「关闭」与「我知道了」
+ // 底部按钮:but-average modal-buts;「关闭」与「我知道了」
// 同款(but c-blue),平铺观感由 orders.css 的 footer 按钮规则统一呈现。
html += '';
html += '关闭 ';
if (allowedActions.can_cancel) html += '关闭订单 ';
if (allowedActions.can_pay) html += '继续支付 ';
- // 售后入口:未申请时底部保留「申请售后」;已有申请时入口收拢到售后记录卡片的「查看详细」。
- var detailAfterSale = order.after_sale || {};
- if (detailAfterSale.enabled && !detailAfterSale.has_application) {
- html += '申请售后 ';
- }
html += " ";
session.content.innerHTML = html;
@@ -310,42 +252,12 @@
function actionHtml(order) {
var allowedActions = order.allowed_actions || {};
- var afterSale = order.after_sale || {};
var html = '订单详情 ';
if (allowedActions.can_cancel) html += '关闭订单 ';
if (allowedActions.can_pay) html += '继续支付 ';
- // 售后入口:申请售后 / 售后详细走 order-after-sale.js 的独立弹窗。
- if (afterSale.enabled) html += afterSaleButtons(order);
return html;
}
- // 售后按钮(列表卡片与订单详情弹窗共用):已申请 → 售后详细,否则 → 申请售后。
- // 载荷与 PHP 模板同构(order-after-sale.js 同步消费),点击即弹窗、无前置请求。
- function afterSalePayload(order) {
- var afterSale = order.after_sale || {};
- return escapeHtml(JSON.stringify({
- order_number: order.order_number || "",
- product_name: order.product_name || "",
- amount: order.amount || "0",
- is_points: !!order.is_points,
- currency_symbol: order.currency_symbol || "",
- status_label: order.status_label || "",
- after_sale: afterSale,
- }));
- }
-
- function afterSaleButtons(order) {
- var afterSale = order.after_sale || {};
- var html = "";
- if (!afterSale.enabled) return html;
- var payload = afterSalePayload(order);
- if (afterSale.has_application) {
- html += '售后详细 ';
- return html;
- }
- html += '申请售后 ';
- return html;
- }
function cardHtml(order) {
var image = order.image
@@ -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) {
diff --git a/assets/js/ticket-center.js b/assets/js/ticket-center.js
new file mode 100644
index 00000000..0bdf2b99
--- /dev/null
+++ b/assets/js/ticket-center.js
@@ -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, ''');
+ }
+
+ 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 ''
+ + escapeHtml(label || status) + ' ';
+ }
+
+ function attachmentPreview(images) {
+ if (!images || !images.length) return '';
+ var html = '';
+ images.forEach(function (url) {
+ html += '
'
+ + ' '
+ + ' ';
+ });
+ return html + '
';
+ }
+
+ function quoteBlock(quote) {
+ if (!quote) return '';
+ var text = quote.is_deleted ? '原回复已删除' : (quote.content || '');
+ return ''
+ + '' + escapeHtml(quote.author_name || '') + ' '
+ + escapeHtml(text) + ' ';
+ }
+
+ function modalShell(title, body, footer) {
+ return ''
+ + '' + body + '
'
+ + '' + (footer || '') + '
';
+ }
+
+ /* ------------------------------------------------------------------ *
+ * 列表 / 统计
+ * ------------------------------------------------------------------ */
+
+ 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 = ''
+ + '
'
+ + '
还没有工单 '
+ + '
遇到问题时提交工单,我们会尽快处理。
';
+ return;
+ }
+ var html = '';
+ items.forEach(function (ticket) {
+ html += ''
+ + ''
+ + '#' + escapeHtml(ticket.ticket_num) + ' '
+ + statusBadge(ticket.status, ticket.status_color, ticket.status_label)
+ + '
'
+ + '' + escapeHtml(ticket.title) + ' '
+ + ''
+ + '' + escapeHtml(ticket.ticket_type_name) + ' '
+ + '' + escapeHtml(ticket.created_at) + ' '
+ + (ticket.order_number ? '订单 ' + escapeHtml(ticket.order_number) + ' ' : '')
+ + '
'
+ + ''
+ + ' ';
+ });
+ 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 += '' + i + ' ';
+ }
+ 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 = '';
+ html += '
'
+ + statusBadge(ticket.status, ticket.status_color, ticket.status_label)
+ + '' + escapeHtml(ticket.ticket_type_name) + ' '
+ + '
';
+ html += '
' + escapeHtml(ticket.title) + ' ';
+ if (ticket.order_number) {
+ html += '
关联订单:' + escapeHtml(ticket.order_number) + '
';
+ }
+ html += '
' + escapeHtml(ticket.content).replace(/\r\n|\r|\n/g, ' ') + '
';
+ html += attachmentPreview(ticket.attachment);
+
+ html += '
';
+ (ticket.replies || []).forEach(function (reply) {
+ var who = reply.author_type === 'admin' ? '管理员' : '我';
+ html += '
'
+ + '
' + escapeHtml(reply.author_name || who) + ' '
+ + '' + escapeHtml(reply.created_at) + '
'
+ + quoteBlock(reply.quote)
+ + '
' + escapeHtml(reply.content).replace(/\r\n|\r|\n/g, ' ') + '
'
+ + attachmentPreview(reply.attachment)
+ + '
引用 '
+ + '
';
+ });
+ html += '
';
+
+ if (ticket.is_closed) {
+ html += '
工单已关闭,如需继续沟通请提交新工单。
';
+ return html + '
';
+ }
+
+ html += '';
+ html += ' ';
+ html += ' ';
+ html += '';
+ if (settings().attachmentEnabled) {
+ html += '上传图片 ';
+ }
+ html += '提交回复 ';
+ html += '催单 ';
+ html += '关闭工单 ';
+ html += '
';
+ return html + '';
+ }
+
+ 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 = '';
+ html += '工单标题 '
+ + ' ';
+
+ html += '工单部门 ';
+ html += '请选择部门 ';
+ types.forEach(function (type) {
+ html += '' + escapeHtml(type.name) + ' ';
+ });
+ html += ' ';
+
+ if (orders.length) {
+ html += '关联订单(可选) ';
+ html += '不关联订单 ';
+ orders.forEach(function (order) {
+ html += ''
+ + escapeHtml(order.order_number + (order.product_name ? ' ' + order.product_name : '')) + ' ';
+ });
+ html += ' ';
+ }
+
+ html += '问题描述 '
+ + ' ';
+
+ if (attachmentEnabled) {
+ html += '图片(最多 ' + state.attachmentMax + ' 张) '
+ + '
'
+ + '
上传图片 ';
+ }
+
+ html += '提交工单 ';
+ return html + ' ';
+ }
+
+ 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 = ' '
+ + '× ';
+ 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();
+ }
+})();
diff --git a/functions.php b/functions.php
index a141b09f..45f5d4b9 100644
--- a/functions.php
+++ b/functions.php
@@ -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();
diff --git a/inc/helpers/aksrt-user-center.php b/inc/helpers/aksrt-user-center.php
index 5d55aa6a..3d4d6bec 100644
--- a/inc/helpers/aksrt-user-center.php
+++ b/inc/helpers/aksrt-user-center.php
@@ -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);
}
diff --git a/inc/helpers/user-center-renderers/profile-orders.php b/inc/helpers/user-center-renderers/profile-orders.php
index 38a681e8..058e3eb3 100644
--- a/inc/helpers/user-center-renderers/profile-orders.php
+++ b/inc/helpers/user-center-renderers/profile-orders.php
@@ -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 = [
- $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,
- ]);
- ?>
-
-
-
-
-
-
-
@@ -150,13 +129,5 @@ $status_options = [
-
diff --git a/inc/helpers/user-center-renderers/profile-tickets.php b/inc/helpers/user-center-renderers/profile-tickets.php
new file mode 100644
index 00000000..d6c8ede6
--- /dev/null
+++ b/inc/helpers/user-center-renderers/profile-tickets.php
@@ -0,0 +1,100 @@
+' . esc_html__('请先登录', 'aksrt') . '';
+ return;
+}
+
+$enabled = class_exists('\AKSRT\Ticket\TicketSettings') && \AKSRT\Ticket\TicketSettings::enabled();
+if (!$enabled) {
+ echo '' . esc_html__('工单功能暂未开启', 'aksrt') . '
';
+ 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;
+?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/inc/helpers/user-center-renderers/sidebar-nav.php b/inc/helpers/user-center-renderers/sidebar-nav.php
index 9fdbe7c4..c47fe251 100644
--- a/inc/helpers/user-center-renderers/sidebar-nav.php
+++ b/inc/helpers/user-center-renderers/sidebar-nav.php
@@ -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([
diff --git a/inc/modules/captcha.php b/inc/modules/captcha.php
index 89c4a253..4ab2451d 100644
--- a/inc/modules/captcha.php
+++ b/inc/modules/captcha.php
@@ -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'),
];
}
diff --git a/src/Admin/Native/AdminListPagination.php b/src/Admin/Native/AdminListPagination.php
index 299cb63b..02f5e700 100644
--- a/src/Admin/Native/AdminListPagination.php
+++ b/src/Admin/Native/AdminListPagination.php
@@ -4,7 +4,7 @@
*
* 后台列表页分页条 —— 1:1 复刻「卡密管理」页的 zibpay_admin_pagenavi 结构。
*
- * 支付中心各页面(订单明细 / 售后服务 / 付费文章 / 优惠码 / 创作分成 / 提现审核 / 卡密管理)
+ * 支付中心各页面(订单明细 / 付费文章 / 优惠码 / 创作分成 / 提现审核 / 卡密管理)
* 原先各写一份几乎相同的翻页代码。这里统一到一处,标记沿用卡密管理那一套:
* `« ‹ 第 X 页,共 Y 页 › »` 四枚按钮 + 「每页 X 共Y」计数,没有数字页码链接。
*
diff --git a/src/Admin/Native/PayMenus.php b/src/Admin/Native/PayMenus.php
index eb4eb471..03147255 100644
--- a/src/Admin/Native/PayMenus.php
+++ b/src/Admin/Native/PayMenus.php
@@ -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'),
diff --git a/src/Admin/Native/PayOrders.php b/src/Admin/Native/PayOrders.php
index 555fa285..6e54fdc3 100644
--- a/src/Admin/Native/PayOrders.php
+++ b/src/Admin/Native/PayOrders.php
@@ -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 '' . esc_html((string) ($row['created_at'] ?? '')) . '' . esc_html((string) ($row['paid_at'] ?? '') ?: '—') . ' ';
echo '';
echo '' . esc_html__('详情', 'aksrt') . ' ';
- // 已支付是已退款的唯一前置状态;退款仍需落到一条进行中的售后工单上。
+ // 已支付是已退款的唯一前置状态。
if ($status_key === \AKSRT\Core\DomainModel::ORDER_STATUS_PAID) {
echo '';
echo ' ';
@@ -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 '' . esc_html__('售后服务不可用。', 'aksrt') . '
';
- $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 '' . esc_html(implode(' ', $policy_bits)) . '
';
-
- $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 '← ' . esc_html__('返回订单列表', 'aksrt') . '
';
- // 售后记录区块:工单卡片 + 处理记录列表(与售后面板共用 .aksrt-as-log 样式)。
- echo '';
echo '';
$rows = [
__('订单号', 'aksrt') => $order_number,
@@ -375,23 +338,6 @@ class PayOrders {
foreach ($rows as $label => $value) {
echo '' . esc_html($label) . ' ' . esc_html((string) $value) . ' ';
}
- $after_sales = \AKSRT\Payment\AfterSaleService::get_instance()->list_for_order($order_number);
- if ($after_sales) {
- echo '' . esc_html__('售后记录', 'aksrt') . ' ';
- $status_labels = $this->after_sale_status_labels();
- foreach ($after_sales as $application) {
- $as_status = (string) ($application['status'] ?? '');
- echo '';
- echo '
' . esc_html($status_labels[$as_status] ?? $as_status) . ' '
- . ' ' . esc_html((string) ($application['refund_amount'] ?? '')) . ' '
- . ' ' . esc_html((string) ($application['created_at'] ?? '')) . '
';
- echo '
' . nl2br(esc_html((string) ($application['reason'] ?? ''))) . '
';
- // 处理记录:后台每一次提交都留一条(状态 + 备注 + 时间),与售后面板同款标记。
- echo $this->render_after_sale_log((array) ($application['handling_log'] ?? []), $status_labels); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
- echo '
';
- }
- echo ' ';
- }
if (!empty($context)) {
echo '' . esc_html__('附加上下文', 'aksrt') . ' ' . esc_html(wp_json_encode($context, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)) . ' ';
}
@@ -412,322 +358,6 @@ class PayOrders {
echo '';
}
- 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 ' ';
- echo '' . esc_html__('全部售后状态', 'aksrt') . ' ';
- foreach ($this->after_sale_status_labels() as $key => $label) echo '' . esc_html($label) . ' ';
- echo ' ' . esc_html__('开始日期', 'aksrt') . ' ' . esc_html__('结束日期', 'aksrt') . ' ';
- submit_button(__('筛选', 'aksrt'), 'secondary', '', false);
- echo ' ';
- echo '';
- echo 'ID 订单号 用户 原因 申请资料 申请说明 退款金额 状态 操作 / 处理记录 ';
- if (!$items) echo '' . esc_html__('暂无售后申请', 'aksrt') . ' ';
- $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(' ', $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 的后台缩略图保持一致(光 )。
- $thumbs[] = ' ';
- }
- // 举证图片并入申请说明:缩略图展示在描述文字下方,不再单独成列。
- $desc_cell = ($desc_text !== '' ? nl2br(esc_html($desc_text)) : '') . ($thumbs ? '' . implode('', $thumbs) . '
' : '');
- 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 '';
- echo '' . esc_html((string) $id) . '' . esc_html((string) ($item['created_at'] ?? '')) . ' ';
- echo '' . esc_html($number) . '' . esc_html($type_label) . ($product_name !== '' ? ' · ' . esc_html($product_name) : '') . ' ';
- echo '' . esc_html($user ? $user->display_name . ' (#' . $uid . ')' : '#' . $uid) . ' ';
- echo '' . $reason_cell . ' ';
- echo '' . $materials_cell . ' ';
- echo '' . $desc_cell . ' ';
- echo '' . esc_html($refund_amount) . ' ';
- echo '' . esc_html($status_label) . ' ';
- echo '' . esc_html__('操作', 'aksrt') . ' ';
- echo ' ';
- echo '';
- $detail_fields = [
- __('订单号', 'aksrt') => '' . esc_html($number) . ' ',
- __('商品', '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 '';
- foreach ($detail_fields as $field_label => $field_value) {
- echo '' . esc_html($field_label) . ' ' . $field_value . ' ';
- }
- // 处理备注:单独占整行,放在申请说明上方;每次提交一条,含状态与时间。
- echo '' . esc_html__('处理备注', 'aksrt') . ' '
- . $this->render_after_sale_log((array) ($item['handling_log'] ?? []), $status_labels) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
- . ' ';
- // 举证图片并入申请说明,单独占整行展示。
- echo '' . esc_html__('申请说明', 'aksrt') . ' ' . $desc_cell . ' ';
- echo '
';
- $status_options = '';
- $current_status = (string) $item['status'];
- foreach ($status_labels as $key => $label) {
- $status_options .= '
' . esc_html($label) . ' ';
- }
- if (!isset($status_labels[$current_status])) {
- // 历史状态(如已删除的 approved)不在可选项内:如实呈现且禁用,防止误提交。
- $status_options .= '
' . esc_html($current_status) . ' ';
- }
- echo '
';
- echo '
' . esc_html__('售后状态', 'aksrt') . ' ' . esc_html__('选择目标状态后点击确认;取消仅收起面板。', 'aksrt') . '
';
- echo '
';
- echo '
' . $status_options . '
';
- echo '
';
- echo '
';
- // 勾选后在确认时把关联订单推到已退款;订单本已退款时呈现为不可改的已选状态
- echo '
' . esc_html__('标记订单已退款', 'aksrt') . '';
- echo '
';
- echo '' . esc_html__('取消', 'aksrt') . ' ';
- echo ''
- . esc_html__('确认', 'aksrt') . ' ';
- echo '
';
- echo '
';
- }
- echo '
';
- // 翻页链接显式带上当前筛选条件(不含 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 '';
- }
-
- /** 售后状态白名单: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> $log
- * @param array $status_labels
- */
- private function render_after_sale_log(array $log, array $status_labels): string {
- if (!$log) {
- return '' . esc_html__('暂无处理记录', 'aksrt') . '
';
- }
- $service = \AKSRT\Payment\AfterSaleService::class;
- $html = '';
- 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 .= ''
- . '' . esc_html((string) ($entry['created_at'] ?? '')) . ' '
- . '' . esc_html($label) . ' '
- . '' . ($note !== '' ? nl2br(esc_html($note)) : '—') . ' '
- . ' ';
- }
- return $html . ' ';
- }
-
- 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 '' . esc_html($map[$code]) . '
';
- if ($code === 'after_sale_update_failed' && isset($_GET['after_sale_error'])) {
- echo '' . esc_html(sanitize_text_field((string) wp_unslash($_GET['after_sale_error']))) . '
';
- }
}
private function page_url(string $slug, array $args = []): string {
diff --git a/src/Admin/Native/UsersMenus.php b/src/Admin/Native/UsersMenus.php
index e2d783a7..b1593679 100644
--- a/src/Admin/Native/UsersMenus.php
+++ b/src/Admin/Native/UsersMenus.php
@@ -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 '' . esc_html__('立即处理', 'aksrt') . '
';
}
}
+
+ 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 '' . esc_html__('工单待接单', 'aksrt') . ' ';
+ echo '
' . esc_html(sprintf(
+ /* translators: %d count */
+ __('您有 %d 个工单待接单', 'aksrt'),
+ $pending_tickets
+ )) . '
';
+ echo '
' . esc_html__('立即处理', 'aksrt') . '
';
+ }
+ }
}
private function ensure_cap(): void {
diff --git a/src/Admin/Native/UsersMenusTicketsCenter.php b/src/Admin/Native/UsersMenusTicketsCenter.php
new file mode 100644
index 00000000..41ee0fe2
--- /dev/null
+++ b/src/Admin/Native/UsersMenusTicketsCenter.php
@@ -0,0 +1,512 @@
+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 '';
+ echo '
' . esc_html__('工单管理', 'aksrt') . ' ';
+ echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+
+ $this->render_ticket_notices();
+ echo '
';
+ foreach (['tickets' => __('工单列表', 'aksrt'), 'config' => __('配置', 'aksrt')] as $key => $label) {
+ printf(
+ '%s ',
+ esc_url(admin_url('users.php?page=' . self::TICKET_PAGE_SLUG . '&tab=' . $key)),
+ $tab === $key ? 'nav-tab-active' : '',
+ esc_html($label)
+ );
+ }
+ echo ' ';
+
+ if ($tab === 'config') {
+ $this->render_ticket_config_tab();
+ } else {
+ $this->render_ticket_list_tab();
+ }
+ echo '
';
+ }
+
+ /** 写操作后的 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('', 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(
+ ''
+ . ' '
+ . ' '
+ . ' '
+ . ' %s'
+ . '%s ',
+ 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;}';
+ }
+}
diff --git a/src/Admin/Native/UsersMenusTicketsConfig.php b/src/Admin/Native/UsersMenusTicketsConfig.php
new file mode 100644
index 00000000..90ea7c13
--- /dev/null
+++ b/src/Admin/Native/UsersMenusTicketsConfig.php
@@ -0,0 +1,165 @@
+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 '' . esc_html__('工单部门', 'aksrt') . ' ';
+ echo '';
+ echo 'ID ' . esc_html__('名称', 'aksrt') . ' '
+ . '' . esc_html__('说明', 'aksrt') . ' '
+ . '' . esc_html__('受理管理员', 'aksrt') . ' ';
+ foreach ($types as $row) {
+ $edit_url = add_query_arg(['type_id' => (int) $row['id']], $this->ticket_page_url(['tab' => 'config']));
+ $cells = sprintf(
+ '%d %s %s %s ',
+ (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 .= '' . esc_html__('编辑', 'aksrt') . ' ';
+ $cells .= $this->ticket_delete_button('type_delete', 'type_id', (int) $row['id'], __('删除', 'aksrt'));
+ $cells .= ' ';
+ echo $cells; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ }
+ echo '
';
+
+ echo '' . esc_html($editing === null ? __('新增部门', 'aksrt') : __('编辑部门', 'aksrt')) . ' ';
+ echo '';
+ echo ' ';
+ echo ' ';
+ echo ' ';
+ echo ' ';
+ echo wp_nonce_field('aksrt_ticket_type_' . (int) ($editing['id'] ?? 0), '_wpnonce', true, false);
+ echo '
';
+ echo '
';
+ echo '
';
+ submit_button(__('保存部门', 'aksrt'), 'primary', '', false);
+ echo ' ';
+ }
+
+ /** 自定义状态:默认状态拒绝改删。 */
+ 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 '' . esc_html__('工单状态', 'aksrt') . ' ';
+ echo '' . esc_html__('默认状态不可修改或删除;删除非默认状态前需先清空该状态下的工单。', 'aksrt') . '
';
+ echo '';
+ echo 'ID ' . esc_html__('名称', 'aksrt') . ' '
+ . '' . esc_html__('颜色', 'aksrt') . ' '
+ . '' . esc_html__('完结', 'aksrt') . ' '
+ . '' . esc_html__('默认', 'aksrt') . ' ';
+ foreach ($statuses as $row) {
+ $status_cells = sprintf(
+ '%d %s '
+ . '%s '
+ . '%s %s ',
+ (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 .= '' . esc_html__('编辑', 'aksrt') . ' ';
+ $status_cells .= $this->ticket_delete_button('status_delete', 'status_id', (int) $row['id'], __('删除', 'aksrt'));
+ } else {
+ $status_cells .= '—';
+ }
+ $status_cells .= ' ';
+ echo $status_cells; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ }
+ echo '
';
+
+ echo '' . esc_html($editing === null ? __('新增状态', 'aksrt') : __('编辑状态', 'aksrt')) . ' ';
+ echo '';
+ echo ' ';
+ echo ' ';
+ echo ' ';
+ echo ' ';
+ echo wp_nonce_field('aksrt_ticket_status_def_' . (int) ($editing['id'] ?? 0), '_wpnonce', true, false);
+ echo ' ';
+ echo ' ';
+ echo ' ' . esc_html__('完结状态', 'aksrt') . ' ';
+ submit_button(__('保存状态', 'aksrt'), 'primary', '', false);
+ echo '
';
+ }
+
+ /** 预设回复:表格 + 行内编辑 + 新建表单。 */
+ 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 '' . esc_html__('预设回复', 'aksrt') . ' ';
+ echo '';
+ echo 'ID ' . esc_html__('内容', 'aksrt') . ' ';
+ if ($rows === []) {
+ echo '' . esc_html__('暂无预设回复', 'aksrt') . ' ';
+ }
+ foreach ($rows as $row) {
+ $reply_cells = sprintf(
+ '%d %s ',
+ (int) $row['id'],
+ esc_html((string) $row['content'])
+ );
+ $reply_cells .= '' . esc_html__('编辑', 'aksrt') . ' ';
+ $reply_cells .= $this->ticket_delete_button('prereply_delete', 'prereply_id', (int) $row['id'], __('删除', 'aksrt'));
+ $reply_cells .= ' ';
+ echo $reply_cells; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ }
+ echo '
';
+
+ echo '' . esc_html($editing === null ? __('新增预设回复', 'aksrt') : __('编辑预设回复', 'aksrt')) . ' ';
+ echo '';
+ echo ' ';
+ echo ' ';
+ echo ' ';
+ echo ' ';
+ echo wp_nonce_field('aksrt_ticket_prereply_' . (int) ($editing['id'] ?? 0), '_wpnonce', true, false);
+ echo '' . esc_textarea((string) ($editing['content'] ?? '')) . '
';
+ submit_button(__('保存预设回复', 'aksrt'), 'primary', '', false);
+ echo ' ';
+ }
+}
diff --git a/src/Admin/Native/UsersMenusTicketsList.php b/src/Admin/Native/UsersMenusTicketsList.php
new file mode 100644
index 00000000..b2a7cc78
--- /dev/null
+++ b/src/Admin/Native/UsersMenusTicketsList.php
@@ -0,0 +1,271 @@
+ticket_page_url());
+
+ // 状态 subsubsub(完结态单独放最后,默认视图不显示已关闭工单)
+ echo '';
+ $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(
+ '%s %s ',
+ $status === $key ? 'current' : '',
+ esc_url($url),
+ esc_html($label),
+ esc_html($sep)
+ );
+ }
+ echo ' ';
+
+ // 筛选 + 搜索
+ echo '';
+ echo ' ';
+ echo '';
+ echo '' . esc_html__('全部状态', 'aksrt') . ' ';
+ foreach ($statuses as $row) {
+ printf(
+ '%s ',
+ esc_attr((string) $row['status_key']),
+ selected($status, (string) $row['status_key'], false),
+ esc_html((string) $row['name'])
+ );
+ }
+ echo ' ';
+ echo '';
+ echo '' . esc_html__('全部部门', 'aksrt') . ' ';
+ foreach ($types as $row) {
+ printf(
+ '%s ',
+ (int) $row['id'],
+ selected($type_id, (int) $row['id'], false),
+ esc_html((string) $row['name'])
+ );
+ }
+ echo ' ';
+ echo ' ';
+ submit_button(__('筛选', 'aksrt'), 'primary', '', false);
+ echo ' ';
+
+ // 新建工单入口
+ printf(
+ '%s
',
+ 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 '';
+ echo '
';
+ echo '';
+ echo '' . esc_html__('状态', 'aksrt') . ' ';
+ echo '' . esc_html__('工单号', 'aksrt') . ' ';
+ echo '' . esc_html__('标题', 'aksrt') . ' ';
+ echo '' . esc_html__('部门', 'aksrt') . ' ';
+ echo '' . esc_html__('用户', 'aksrt') . ' ';
+ echo '' . esc_html__('跟进人', 'aksrt') . ' ';
+ echo '' . esc_html__('提交时间', 'aksrt') . ' ';
+ echo '' . esc_html__('最后回复', 'aksrt') . ' ';
+ echo ' ';
+
+ if ($items === []) {
+ echo '' . esc_html__('暂无工单', 'aksrt') . ' ';
+ }
+
+ foreach ($items as $ticket) {
+ $is_open = $open_id === (int) $ticket['id'];
+ printf(
+ '%s '
+ . '%s %s %s %s %s '
+ . '%s %s ',
+ 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 '' . $this->render_ticket_detail_panel($ticket) . ' '; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+ }
+ }
+ echo '
';
+
+ \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 $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 = '';
+
+ // 工单正文
+ $html .= '
';
+
+ // 沟通记录
+ $html .= '
' . esc_html__('沟通记录', 'aksrt') . ' ';
+ if ($replies === []) {
+ $html .= '
' . esc_html__('暂无沟通记录', 'aksrt') . '
';
+ }
+ $html .= '
';
+ 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 .= '';
+ $html .= '' . esc_html($who) . ' '
+ . ' ' . esc_html((string) $reply['created_at']) . '
';
+ if (is_array($reply['quote'] ?? null)) {
+ $quote = $reply['quote'];
+ $html .= ''
+ . esc_html(!empty($quote['is_deleted']) ? __('原回复已删除', 'aksrt') : (string) $quote['content'])
+ . ' ';
+ }
+ $html .= '' . nl2br(esc_html((string) $reply['content'])) . '
';
+ $html .= ' ';
+ }
+ $html .= ' ';
+
+ // 管理员回复:已完结工单不再给回复框,只提示已关闭。
+ if (!empty($ticket['is_closed'])) {
+ $html .= '
' . esc_html__('工单已关闭,无法继续回复。', 'aksrt') . '
';
+ } else {
+ $html .= '
';
+ $html .= ' ';
+ $html .= ' ';
+ $html .= ' ';
+ $html .= ' ';
+ $html .= wp_nonce_field('aksrt_ticket_reply_' . $id, '_wpnonce', true, false);
+ $html .= '
';
+ $html .= '' . esc_html__('提交回复', 'aksrt') . '
';
+ $html .= ' ';
+ }
+
+ // 状态迁移 / 部门 / 跟进人
+ $html .= '
';
+ $html .= ' ';
+ $html .= ' ';
+ $html .= ' ';
+ $html .= ' ';
+ $html .= wp_nonce_field('aksrt_ticket_status_' . $id, '_wpnonce', true, false);
+ $html .= '' . esc_html__('状态', 'aksrt') . ' ';
+ foreach (\AKSRT\Ticket\TicketStatusService::all() as $row) {
+ printf(
+ '%s ',
+ esc_attr((string) $row['status_key']),
+ selected((string) $ticket['status'], (string) $row['status_key'], false),
+ esc_html((string) $row['name'])
+ );
+ }
+ $html .= ' ';
+ $html .= '' . esc_html__('部门', 'aksrt') . ' ';
+ foreach (\AKSRT\Ticket\TicketTypeService::all() as $row) {
+ printf(
+ '%s ',
+ (int) $row['id'],
+ selected((int) $ticket['ticket_type_id'], (int) $row['id'], false),
+ esc_html((string) $row['name'])
+ );
+ }
+ $html .= ' ';
+ $html .= '' . esc_html__('跟进人 ID', 'aksrt') . ' ';
+ $html .= '' . esc_html__('保存', 'aksrt') . '
';
+ $html .= ' ';
+
+ // 后台备注
+ $html .= '
' . esc_html__('后台备注', 'aksrt') . ' ';
+ if ($notes === []) {
+ $html .= '
' . esc_html__('暂无备注', 'aksrt') . '
';
+ }
+ $html .= '
';
+ $html .= '
';
+ $html .= ' ';
+ $html .= ' ';
+ $html .= ' ';
+ $html .= ' ';
+ $html .= wp_nonce_field('aksrt_ticket_note_' . $id, '_wpnonce', true, false);
+ $html .= ' '
+ . '' . esc_html__('添加备注', 'aksrt') . '
';
+ $html .= ' ';
+
+ $html .= '
';
+ return $html;
+ }
+}
diff --git a/src/Admin/Schema/SchemaPayment.php b/src/Admin/Schema/SchemaPayment.php
index b2ccc977..89372a28 100644
--- a/src/Admin/Schema/SchemaPayment.php
+++ b/src/Admin/Schema/SchemaPayment.php
@@ -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' => '抱歉,您的订单已超过售后申请时效
', '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张)'],
- ]],
- ],
- ],
],
];
}
diff --git a/src/Admin/Schema/SchemaPermissions.php b/src/Admin/Schema/SchemaPermissions.php
index f66505ba..8715be6a 100644
--- a/src/Admin/Schema/SchemaPermissions.php
+++ b/src/Admin/Schema/SchemaPermissions.php
@@ -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],
diff --git a/src/Admin/Schema/SchemaUsers.php b/src/Admin/Schema/SchemaUsers.php
index f6af01f7..d4597db3 100644
--- a/src/Admin/Schema/SchemaUsers.php
+++ b/src/Admin/Schema/SchemaUsers.php
@@ -612,6 +612,7 @@ class SchemaUsers {
],
],
],
+ 'ticket' => Users\SchemaUsersTicket::build(),
];
}
}
diff --git a/src/Admin/Schema/Users/SchemaUsersTicket.php b/src/Admin/Schema/Users/SchemaUsersTicket.php
new file mode 100644
index 00000000..c651d3a7
--- /dev/null
+++ b/src/Admin/Schema/Users/SchemaUsersTicket.php
@@ -0,0 +1,40 @@
+ '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' => '提交工单后请耐心等待处理
', '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' => '同一工单两次催单之间的最小间隔'],
+ ]],
+ ],
+ ];
+ }
+}
diff --git a/src/Core/DBMigrator.php b/src/Core/DBMigrator.php
index 1e711722..e284de89 100644
--- a/src/Core/DBMigrator.php
+++ b/src/Core/DBMigrator.php
@@ -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';
diff --git a/src/Core/DBMigratorPart2.php b/src/Core/DBMigratorPart2.php
index f0185d54..780a1c2c 100644
--- a/src/Core/DBMigratorPart2.php
+++ b/src/Core/DBMigratorPart2.php
@@ -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);
}
}
diff --git a/src/Core/DBMigratorPart3.php b/src/Core/DBMigratorPart3.php
index 608ebb80..ce44ba5d 100644
--- a/src/Core/DBMigratorPart3.php
+++ b/src/Core/DBMigratorPart3.php
@@ -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',
diff --git a/src/Core/DBMigratorTicket.php b/src/Core/DBMigratorTicket.php
new file mode 100644
index 00000000..bb99ddc6
--- /dev/null
+++ b/src/Core/DBMigratorTicket.php
@@ -0,0 +1,177 @@
+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']);
+ }
+ }
+}
diff --git a/src/Core/ScriptsPart1b.php b/src/Core/ScriptsPart1b.php
index a216c2da..2104df1d 100644
--- a/src/Core/ScriptsPart1b.php
+++ b/src/Core/ScriptsPart1b.php
@@ -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',
diff --git a/src/Core/ScriptsPart1c.php b/src/Core/ScriptsPart1c.php
index 3e763219..c36097c9 100644
--- a/src/Core/ScriptsPart1c.php
+++ b/src/Core/ScriptsPart1c.php
@@ -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,
diff --git a/src/Payment/AfterSaleService.php b/src/Payment/AfterSaleService.php
deleted file mode 100644
index ee6970dd..00000000
--- a/src/Payment/AfterSaleService.php
+++ /dev/null
@@ -1,427 +0,0 @@
-[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 $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 $form
- * @return array
- */
- private function sanitize_apply_form(array $form): array {
- $reason_label = sanitize_text_field((string) ($form['reason_label'] ?? ''));
- if (mb_strlen($reason_label) > 120) $reason_label = mb_substr($reason_label, 0, 120);
- $materials = [];
- if (is_array($form['materials'] ?? null)) {
- foreach ($form['materials'] as $item) {
- if (!is_array($item) || count($materials) >= 10) continue;
- $label = sanitize_text_field((string) ($item['label'] ?? ''));
- $value = sanitize_text_field((string) ($item['value'] ?? ''));
- if ($label === '' || $value === '') continue;
- if (mb_strlen($label) > 120) $label = mb_substr($label, 0, 120);
- if (mb_strlen($value) > 500) $value = mb_substr($value, 0, 500);
- $materials[] = ['label' => $label, 'value' => $value];
- }
- }
- $description = sanitize_textarea_field((string) ($form['description'] ?? ''));
- if (mb_strlen($description) > 2000) $description = mb_substr($description, 0, 2000);
- $clean = [];
- if ($reason_label !== '') $clean['reason_label'] = $reason_label;
- if ($materials !== []) $clean['materials'] = $materials;
- if ($description !== '') $clean['description'] = $description;
- return $clean;
- }
-
- /**
- * 退款金额:积分单取整,现金单经 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> */
- public function list_for_order(string $order_number): array {
- global $wpdb;
- $rows = $wpdb->get_results($wpdb->prepare("SELECT * FROM {$this->table()} WHERE order_number = %s ORDER BY id DESC", $order_number), ARRAY_A);
- return array_map([$this, 'normalize'], (array) $rows);
- }
-
- /** @return list> */
- public function admin_list(array $args = []): array {
- global $wpdb;
- $where = [];
- $params = [];
- $status = sanitize_key((string) ($args['status'] ?? ''));
- $valid = [self::STATUS_PENDING, self::STATUS_PROCESSING, self::STATUS_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 $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 */
- 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 $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>
- */
- 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;
- }
-}
diff --git a/src/Payment/BalanceGateway.php b/src/Payment/BalanceGateway.php
index fa9d6425..455b8741 100644
--- a/src/Payment/BalanceGateway.php
+++ b/src/Payment/BalanceGateway.php
@@ -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 {
diff --git a/src/Payment/OrderCenterQuery.php b/src/Payment/OrderCenterQuery.php
index 0cec4ef4..87bb4da8 100644
--- a/src/Payment/OrderCenterQuery.php
+++ b/src/Payment/OrderCenterQuery.php
@@ -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 */
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(),
diff --git a/src/Payment/OrderRepositoryAdmin.php b/src/Payment/OrderRepositoryAdmin.php
index 930efeb1..b0779168 100644
--- a/src/Payment/OrderRepositoryAdmin.php
+++ b/src/Payment/OrderRepositoryAdmin.php
@@ -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,
diff --git a/src/Payment/OrderRepositoryCore.php b/src/Payment/OrderRepositoryCore.php
index 3d154d05..74b6a031 100644
--- a/src/Payment/OrderRepositoryCore.php
+++ b/src/Payment/OrderRepositoryCore.php
@@ -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) {
diff --git a/src/Payment/UserOrders.php b/src/Payment/UserOrders.php
index cb2b5eff..8fc8de26 100644
--- a/src/Payment/UserOrders.php
+++ b/src/Payment/UserOrders.php
@@ -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> $records
- * @return array
- */
- private function after_sale_state(string $group, string $order_number, string $paid_at, ?AfterSaleService $service, array $records = []): array {
- $settings = class_exists('\AKSRT\Settings\PaymentRuntime')
- ? \AKSRT\Settings\PaymentRuntime::get_after_sale_settings()
- : [];
- $settings = is_array($settings) ? $settings : [];
-
- $open_statuses = ['pending', 'processing'];
- $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 */
private function empty_view(OrderCenterQuery $query): array {
diff --git a/src/Settings/PaymentRuntime.php b/src/Settings/PaymentRuntime.php
index d6d12714..39a0988b 100644
--- a/src/Settings/PaymentRuntime.php
+++ b/src/Settings/PaymentRuntime.php
@@ -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,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;
diff --git a/src/Ticket/TicketAttachment.php b/src/Ticket/TicketAttachment.php
new file mode 100644
index 00000000..ff3deacd
--- /dev/null
+++ b/src/Ticket/TicketAttachment.php
@@ -0,0 +1,59 @@
+
+ */
+ 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
+ */
+ 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);
+ }
+}
diff --git a/src/Ticket/TicketConfigRest.php b/src/Ticket/TicketConfigRest.php
new file mode 100644
index 00000000..98fa0a0f
--- /dev/null
+++ b/src/Ticket/TicketConfigRest.php
@@ -0,0 +1,284 @@
+ 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|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]);
+ }
+}
diff --git a/src/Ticket/TicketNoteService.php b/src/Ticket/TicketNoteService.php
new file mode 100644
index 00000000..8268c447
--- /dev/null
+++ b/src/Ticket/TicketNoteService.php
@@ -0,0 +1,107 @@
+>
+ */
+ 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|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 $row
+ * @return array
+ */
+ 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 : '';
+ }
+}
diff --git a/src/Ticket/TicketNotifier.php b/src/Ticket/TicketNotifier.php
new file mode 100644
index 00000000..9c892f01
--- /dev/null
+++ b/src/Ticket/TicketNotifier.php
@@ -0,0 +1,122 @@
+ 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 $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('/');
+ }
+}
diff --git a/src/Ticket/TicketNumber.php b/src/Ticket/TicketNumber.php
new file mode 100644
index 00000000..f9d6f33e
--- /dev/null
+++ b/src/Ticket/TicketNumber.php
@@ -0,0 +1,46 @@
+get_var($wpdb->prepare(
+ "SELECT id FROM {$table} WHERE ticket_num = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL
+ $ticket_num
+ ));
+ return $found !== null;
+ }
+}
diff --git a/src/Ticket/TicketPrereplyService.php b/src/Ticket/TicketPrereplyService.php
new file mode 100644
index 00000000..1b2ebe48
--- /dev/null
+++ b/src/Ticket/TicketPrereplyService.php
@@ -0,0 +1,128 @@
+>
+ */
+ 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>
+ */
+ public static function public_list(): array {
+ $out = [];
+ foreach (self::all() as $row) {
+ $out[] = ['id' => $row['id'], 'content' => $row['content']];
+ }
+ return $out;
+ }
+
+ /** @return array|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 $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;
+ }
+}
diff --git a/src/Ticket/TicketRepository-traits/TicketQuery.php b/src/Ticket/TicketRepository-traits/TicketQuery.php
new file mode 100644
index 00000000..31480243
--- /dev/null
+++ b/src/Ticket/TicketRepository-traits/TicketQuery.php
@@ -0,0 +1,158 @@
+ $args
+ * @return array{0:string,1:list}
+ */
+ 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 $args
+ * @return list>
+ */
+ 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 $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 $args
+ * @return array 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;
+ }
+}
diff --git a/src/Ticket/TicketRepository-traits/TicketStatus.php b/src/Ticket/TicketRepository-traits/TicketStatus.php
new file mode 100644
index 00000000..682e52ba
--- /dev/null
+++ b/src/Ticket/TicketRepository-traits/TicketStatus.php
@@ -0,0 +1,98 @@
+ $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 $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 $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 $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 $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());
+ }
+}
diff --git a/src/Ticket/TicketRepository.php b/src/Ticket/TicketRepository.php
new file mode 100644
index 00000000..40fa4f88
--- /dev/null
+++ b/src/Ticket/TicketRepository.php
@@ -0,0 +1,391 @@
+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|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|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|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 $args
+ * @return array{items:list>,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 $args
+ * @return array
+ */
+ 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 $ticket
+ */
+ public function receive(int $id, array $ticket): bool {
+ return $this->receive_ticket($id, $ticket);
+ }
+
+ /**
+ * @param array $ticket
+ */
+ public function resolve(int $id, array $ticket): bool {
+ return $this->resolve_ticket($id, $ticket);
+ }
+
+ /**
+ * @param array $ticket
+ */
+ public function close(int $id, array $ticket, string $actor): bool {
+ return $this->close_ticket($id, $ticket, $actor);
+ }
+
+ /**
+ * @param array $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>
+ */
+ 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|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 $row
+ * @return array
+ */
+ 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 $row
+ * @return array
+ */
+ 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|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 : '';
+ }
+}
diff --git a/src/Ticket/TicketRest.php b/src/Ticket/TicketRest.php
new file mode 100644
index 00000000..4979c0c7
--- /dev/null
+++ b/src/Ticket/TicketRest.php
@@ -0,0 +1,427 @@
+[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 */
+ 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
+ */
+ 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>
+ */
+ 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 $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 : []);
+ }
+}
diff --git a/src/Ticket/TicketRestAdmin.php b/src/Ticket/TicketRestAdmin.php
new file mode 100644
index 00000000..a1c03713
--- /dev/null
+++ b/src/Ticket/TicketRestAdmin.php
@@ -0,0 +1,460 @@
+[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 */
+ 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
+ */
+ 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
+ */
+ 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,
+ ];
+ }
+}
diff --git a/src/Ticket/TicketService.php b/src/Ticket/TicketService.php
new file mode 100644
index 00000000..956d048c
--- /dev/null
+++ b/src/Ticket/TicketService.php
@@ -0,0 +1,46 @@
+register_routes();
+ (new TicketRestAdmin())->register_routes();
+ }
+
+ /** 供后台页与前台模板复用的下拉数据(不经过 REST)。 */
+ public static function meta(): array {
+ return [
+ 'types' => TicketTypeService::all(),
+ 'statuses' => TicketStatusService::all(),
+ 'prereplies' => TicketPrereplyService::all(),
+ ];
+ }
+}
diff --git a/src/Ticket/TicketSettings.php b/src/Ticket/TicketSettings.php
new file mode 100644
index 00000000..73607203
--- /dev/null
+++ b/src/Ticket/TicketSettings.php
@@ -0,0 +1,65 @@
+ */
+ 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)));
+ }
+}
diff --git a/src/Ticket/TicketStatusService.php b/src/Ticket/TicketStatusService.php
new file mode 100644
index 00000000..6105a670
--- /dev/null
+++ b/src/Ticket/TicketStatusService.php
@@ -0,0 +1,158 @@
+>|null status_key => row */
+ private static ?array $cache = null;
+
+ public static function flush(): void {
+ self::$cache = null;
+ }
+
+ /**
+ * 全部状态,按 sort_order / id 正序。
+ *
+ * @return list>
+ */
+ 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> status_key => row */
+ public static function map(): array {
+ if (self::$cache === null) {
+ self::all();
+ }
+ return self::$cache ?? [];
+ }
+
+ /** @return array|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 $row
+ * @return array
+ */
+ 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;
+ }
+}
diff --git a/src/Ticket/TicketTypeService.php b/src/Ticket/TicketTypeService.php
new file mode 100644
index 00000000..595c82b5
--- /dev/null
+++ b/src/Ticket/TicketTypeService.php
@@ -0,0 +1,124 @@
+>|null */
+ private static ?array $cache = null;
+
+ public static function flush(): void {
+ self::$cache = null;
+ }
+
+ /**
+ * @return list>
+ */
+ 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>
+ */
+ 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|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 $admin_ids
+ * @return list
+ */
+ 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 $row
+ * @return array
+ */
+ 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'] ?? ''),
+ ];
+ }
+}
diff --git a/src/User/Center.php b/src/User/Center.php
index 43b186df..98ce3233 100644
--- a/src/User/Center.php
+++ b/src/User/Center.php
@@ -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;
}
diff --git a/tests/admin/pay-pages-pagination.test.cjs b/tests/admin/pay-pages-pagination.test.cjs
index 1b81c740..c5764c8c 100644
--- a/tests/admin/pay-pages-pagination.test.cjs
+++ b/tests/admin/pay-pages-pagination.test.cjs
@@ -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]} 的分页条必须晚于空态分支`
diff --git a/tests/dev/render-after-sales-harness.php b/tests/dev/render-after-sales-harness.php
deleted file mode 100644
index f294c04a..00000000
--- a/tests/dev/render-after-sales-harness.php
+++ /dev/null
@@ -1,331 +0,0 @@
- 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 -> run one handler scenario, print JSON log
-namespace {
-define('ABSPATH', __DIR__ . '/');
-
-final class HarnessLog {
- /** @var list> */
- 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 '' . esc_html($text ?: 'Submit') . ' '; }
-function wp_nonce_field($a = -1, $n = '_wpnonce', $r = true, $e = true) { if ($e) echo ' '; }
-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> 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> 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";
-}
diff --git a/tests/widget-system/after-sale-log-harness.php b/tests/widget-system/after-sale-log-harness.php
deleted file mode 100644
index c711a18a..00000000
--- a/tests/widget-system/after-sale-log-harness.php
+++ /dev/null
@@ -1,257 +0,0 @@
- 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> 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> 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 */
- 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);
-}
diff --git a/tests/widget-system/after-sale-policy-contract.test.cjs b/tests/widget-system/after-sale-policy-contract.test.cjs
deleted file mode 100644
index e13b0d4f..00000000
--- a/tests/widget-system/after-sale-policy-contract.test.cjs
+++ /dev/null
@@ -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: '抱歉,您的订单已超过售后申请时效
',
- reasons: [
- { slug: 'c440cdf71825', name: '质量问题' },
- { slug: '2ead35a456a0', name: '假冒品牌' },
- { slug: 'fba26eab6b2b', name: '其他原因' },
- ],
- materials: ['联系邮箱', '联系电话'],
- desc: '',
- img_enabled: true,
- reason_placeholder: '必填,请您详细填写申请原因',
- })
-
- assert.equal(payload.overridden.enabled, false)
- assert.equal(payload.overridden.time_limit, 30)
- assert.equal(payload.overridden.img_enabled, false)
- assert.equal(payload.overridden.reason_placeholder, '请描述问题')
- assert.deepEqual(payload.overridden.reasons, [
- { slug: 'c440cdf71825', name: '质量问题' },
- { slug: '327e0ab68567', name: '发错货' },
- ])
- assert.deepEqual(payload.overridden.materials, ['联系邮箱'])
- // HTML 配置项剥离 script,保留 div
- assert.equal(payload.overridden.expired_text, '已超过时效
')
- assert.equal(payload.overridden.desc, '请保持商品完好
')
-
- assert.deepEqual(payload.expired_ok, {
- within: true,
- exact_boundary: true,
- just_expired: true,
- long_expired: true,
- no_limit: true,
- missing_paid: true,
- })
-})
-
-test('after-sale tickets persist evidence images and expose the latest row', () => {
- const schema = read('src/Core/DBMigratorPart2.php')
- assert.match(schema, /CREATE TABLE \{\$prefix\}aksrt_order_aftersale[\s\S]*?images LONGTEXT DEFAULT NULL[\s\S]*?refund_amount/)
-
- // images 列随 schema 2.5.1 引入:dbDelta 只在版本变化时重跑,列变更必须同步升版本,
- // 否则老安装不会 ALTER TABLE,写入直接报 Unknown column 'images'。
- const migrator = read('src/Core/DBMigrator.php')
- const version = migrator.match(/const SCHEMA_VERSION = '(\d+)\.(\d+)\.(\d+)'/)
- assert.ok(version, 'DBMigrator exposes a numeric SCHEMA_VERSION')
- const [major, minor, patch] = version.slice(1).map(Number)
- assert.ok(
- major > 2 || (major === 2 && minor > 5) || (major === 2 && minor === 5 && patch >= 1),
- `SCHEMA_VERSION must be at least 2.5.1 after adding order_aftersale.images, got ${major}.${minor}.${patch}`
- )
-
- const service = read('src/Payment/AfterSaleService.php')
- assert.match(service, /public function create\(int \$user_id, string \$order_number, string \$reason, array \$images = \[\], array \$apply_form = \[\]\)/)
- // 结构化申请表单:预置原因 / 申请资料 / 申请说明独立入库(reason 融合串保持原样)
- assert.match(service, /\$apply_form = \$this->sanitize_apply_form\(\$apply_form\);/)
- assert.match(service, /private function sanitize_apply_form\(array \$form\): array/)
- assert.match(service, /'apply_form' => \$apply_form === \[\] \? null : wp_json_encode\(\$apply_form/)
- assert.match(service, /\$images = \$this->sanitize_images\(\$images\);/)
- assert.match(service, /'images' => wp_json_encode\(\$images\)/)
- assert.match(service, /private function sanitize_images\(array \$images\): array \{[\s\S]*?count\(\$clean\) >= 3[\s\S]*?esc_url_raw/)
- assert.match(service, /public function latest_for_order\(string \$order_number\): \?array \{[\s\S]*?ORDER BY id DESC LIMIT 1/)
- // normalize 输出 images 数组(坏 JSON / 空串回落空数组)
- assert.match(service, /json_decode\(\(string\) \(\$row\['images'\] \?\? ''\), true\)/)
-
- // 图片上传路由:登录 + nonce + 售后/图片开关 + 订单归属;回调委托评论图片上传
- assert.match(service, /\/orders\/' \. \$number \. '\/after-sale\/upload-image'/)
- assert.match(service, /public function require_after_sale_image\(\\WP_REST_Request \$request\)/)
- assert.match(service, /get_after_sale_settings\(\)[\s\S]*?empty\(\$settings\['enabled'\]\)[\s\S]*?empty\(\$settings\['img_enabled'\]\)/)
- assert.match(service, /get_user_order\(get_current_user_id\(\), \$number\)/)
- assert.match(service, /UserController::get_instance\(\)->rest_upload_comment_image\(\$request\)/)
- assert.match(service, /'images' => \['required' => false, 'type' => 'array'[\s\S]*?count\(\$value\) <= 3/)
-
- // 提交 REST 透传 images
- // rest_submit 同步上报结构化申请表单(reason_label / materials / description)
- assert.match(service, /'reason_label' => \(string\) \$request->get_param\('reason_label'\),/)
- assert.match(service, /'materials' +\=> is_array\(\$materials\) \? \$materials : \[\],/)
- assert.match(service, /'description' +\=> \(string\) \$request->get_param\('description'\),/)
-
- // 既有售后资产契约(锁 / 退款 / 完成)不受影响
- assert.match(service, /GET_LOCK\(%s, 5\)/)
- assert.match(service, /Order::STATUS_PAID/)
-})
-
-test('order view model exposes the after-sale button matrix', () => {
- const userOrders = read('src/Payment/UserOrders.php')
- assert.match(userOrders, /private function after_sale_state\(string \$group, string \$order_number, string \$paid_at, \?AfterSaleService \$service, array \$records = \[\]\): array \{/)
-
- assert.match(userOrders, /'after_sale' => \$after_sale_state,/)
- assert.match(userOrders, /'enabled'\s*=> \$enabled,/)
- assert.match(userOrders, /'expired'\s*=> \(bool\) \$expired,/)
- assert.match(userOrders, /'has_application' => \$has_application,/)
- assert.match(userOrders, /'open'\s*=> \$open,/)
- // 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, /'