From dbb7de41e901ff592948106e39902f1365ecc94c Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 25 Oct 2026 23:29:51 +0800 Subject: [PATCH] feat(ticket): replace the after-sale flow with a general ticket system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old 售后 flow was an order-refund appendage: tickets were hard-bound to an order number, users could only file them from 「我的订单」against a paid order, and the admin action bottomed out at 「标记订单已退款」. It could not carry account questions or feature requests that have nothing to do with an order. Replace it with a general ticket system modelled on 魔方财务云's idcsmart_ticket: title + department + description are required, linking an order is optional (user's own paid orders only). Add departments, custom statuses, preset replies and internal admin notes as four configuration surfaces. Conversation history moves off the ticket row's JSON log into dedicated ticket_reply rows, which unlocks quoted replies. Front end gets a 工单中心 panel under 我的服务; back end moves from 支付中心 → 售后服务 to users.php → 工单管理. Per CLAUDE.md's rewrite-over-compatibility rule: drop the old table, build new ones, delete the old code. No dual paths, no data migration. The upstream plugin's upstream/downstream delivery and internal-ticket conversion are deliberately left out — both are multi-provider platform concerns with no meaning on a single WordPress site. Data: order_aftersale becomes six aksrt_ticket* tables (ticket, ticket_reply, ticket_status, ticket_type, ticket_prereply, ticket_note). SCHEMA_VERSION 2.6.0 -> 3.0.0. Status and department seeds are idempotent; the default status 「待接单」is flagged as non-editable. Domain: new src/Ticket/ (13 files, all under 460 lines). Attachments reuse UserController::rest_upload_comment_image with a ticket-specific permission callback, mirroring the existing /report/upload-image route rather than growing a second upload path. Notifications go through AKSRT\Messages\Message::send() and deep-link to the right surface; email/SMS stay out of scope since the theme's mail and SMS stacks are separate implementations. Refund gate opens up: marking an order refunded no longer requires an in-flight ticket, only that the order is paid. Also generalise a hardcoded special case in tools/audit-admin-contracts.php — schema domain inference now reads the parent directory instead of matching "PostList" by name, so new leaf schema files need no per-name exception. Verification: php tools/audit-admin-contracts.php exit 0, errors=0 node --test (widget-system + admin) 664 pass / 0 fail php -l over all new/changed files clean node --check over all JS clean npm run build + build:public clean Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 +- .../admin/src/constants/sections/payment.js | 11 - assets/admin/src/constants/sections/users.js | 10 + assets/admin/src/router/index.js | 1 + .../src/schema/sections/payment/afterSale.js | 80 -- .../src/schema/sections/payment/index.js | 2 - .../src/schema/sections/permissions/index.js | 4 +- .../admin/src/schema/sections/users/index.js | 2 + .../admin/src/schema/sections/users/ticket.js | 46 ++ assets/css/build/public.css | 321 +------- assets/css/build/public.min.css | 2 +- assets/css/frontend/modal-runtime.css | 321 +------- assets/css/routes/orders.css | 29 +- assets/css/routes/tickets.css | 177 +++++ assets/js/order-after-sale.js | 700 ------------------ assets/js/order-center.js | 95 +-- assets/js/ticket-center.js | 638 ++++++++++++++++ functions.php | 4 +- inc/helpers/aksrt-user-center.php | 3 +- .../user-center-renderers/profile-orders.php | 29 - .../user-center-renderers/profile-tickets.php | 100 +++ .../user-center-renderers/sidebar-nav.php | 8 + inc/modules/captcha.php | 4 +- src/Admin/Native/AdminListPagination.php | 2 +- src/Admin/Native/PayMenus.php | 11 - src/Admin/Native/PayOrders.php | 406 +--------- src/Admin/Native/UsersMenus.php | 24 + src/Admin/Native/UsersMenusTicketsCenter.php | 512 +++++++++++++ src/Admin/Native/UsersMenusTicketsConfig.php | 165 +++++ src/Admin/Native/UsersMenusTicketsList.php | 271 +++++++ src/Admin/Schema/SchemaPayment.php | 28 - src/Admin/Schema/SchemaPermissions.php | 4 +- src/Admin/Schema/SchemaUsers.php | 1 + src/Admin/Schema/Users/SchemaUsersTicket.php | 40 + src/Core/DBMigrator.php | 6 +- src/Core/DBMigratorPart2.php | 21 +- src/Core/DBMigratorPart3.php | 7 +- src/Core/DBMigratorTicket.php | 177 +++++ src/Core/ScriptsPart1b.php | 36 +- src/Core/ScriptsPart1c.php | 26 +- src/Payment/AfterSaleService.php | 427 ----------- src/Payment/BalanceGateway.php | 2 +- src/Payment/OrderCenterQuery.php | 9 +- src/Payment/OrderRepositoryAdmin.php | 8 - src/Payment/OrderRepositoryCore.php | 18 - src/Payment/UserOrders.php | 78 -- src/Settings/PaymentRuntime.php | 81 -- src/Ticket/TicketAttachment.php | 59 ++ src/Ticket/TicketConfigRest.php | 284 +++++++ src/Ticket/TicketNoteService.php | 107 +++ src/Ticket/TicketNotifier.php | 122 +++ src/Ticket/TicketNumber.php | 46 ++ src/Ticket/TicketPrereplyService.php | 128 ++++ .../TicketRepository-traits/TicketQuery.php | 158 ++++ .../TicketRepository-traits/TicketStatus.php | 98 +++ src/Ticket/TicketRepository.php | 391 ++++++++++ src/Ticket/TicketRest.php | 427 +++++++++++ src/Ticket/TicketRestAdmin.php | 460 ++++++++++++ src/Ticket/TicketService.php | 46 ++ src/Ticket/TicketSettings.php | 65 ++ src/Ticket/TicketStatusService.php | 158 ++++ src/Ticket/TicketTypeService.php | 124 ++++ src/User/Center.php | 19 + tests/admin/pay-pages-pagination.test.cjs | 32 +- tests/dev/render-after-sales-harness.php | 331 --------- .../widget-system/after-sale-log-harness.php | 257 ------- .../after-sale-policy-contract.test.cjs | 574 -------------- .../after-sale-policy-harness.php | 117 --- .../captcha-business-contract.test.cjs | 30 +- .../financial-flow-contract.test.cjs | 93 +-- .../widget-system/oauth-security-harness.php | 1 + .../order-after-sale-assets-contract.test.cjs | 91 --- .../ticket-captcha-contract.test.cjs | 72 ++ .../ticket-lifecycle-contract.test.cjs | 123 +++ .../ticket-native-admin-contract.test.cjs | 159 ++++ .../ticket-permission-contract.test.cjs | 144 ++++ .../ticket-schema-contract.test.cjs | 160 ++++ .../ticket-schema-version.test.cjs | 120 +++ tools/admin-contract-report.json | 26 +- tools/adr-classes-domain-placement.md | 2 +- tools/audit-admin-contracts.php | 18 +- 81 files changed, 5812 insertions(+), 4179 deletions(-) delete mode 100644 assets/admin/src/schema/sections/payment/afterSale.js create mode 100644 assets/admin/src/schema/sections/users/ticket.js create mode 100644 assets/css/routes/tickets.css delete mode 100644 assets/js/order-after-sale.js create mode 100644 assets/js/ticket-center.js create mode 100644 inc/helpers/user-center-renderers/profile-tickets.php create mode 100644 src/Admin/Native/UsersMenusTicketsCenter.php create mode 100644 src/Admin/Native/UsersMenusTicketsConfig.php create mode 100644 src/Admin/Native/UsersMenusTicketsList.php create mode 100644 src/Admin/Schema/Users/SchemaUsersTicket.php create mode 100644 src/Core/DBMigratorTicket.php delete mode 100644 src/Payment/AfterSaleService.php create mode 100644 src/Ticket/TicketAttachment.php create mode 100644 src/Ticket/TicketConfigRest.php create mode 100644 src/Ticket/TicketNoteService.php create mode 100644 src/Ticket/TicketNotifier.php create mode 100644 src/Ticket/TicketNumber.php create mode 100644 src/Ticket/TicketPrereplyService.php create mode 100644 src/Ticket/TicketRepository-traits/TicketQuery.php create mode 100644 src/Ticket/TicketRepository-traits/TicketStatus.php create mode 100644 src/Ticket/TicketRepository.php create mode 100644 src/Ticket/TicketRest.php create mode 100644 src/Ticket/TicketRestAdmin.php create mode 100644 src/Ticket/TicketService.php create mode 100644 src/Ticket/TicketSettings.php create mode 100644 src/Ticket/TicketStatusService.php create mode 100644 src/Ticket/TicketTypeService.php delete mode 100644 tests/dev/render-after-sales-harness.php delete mode 100644 tests/widget-system/after-sale-log-harness.php delete mode 100644 tests/widget-system/after-sale-policy-contract.test.cjs delete mode 100644 tests/widget-system/after-sale-policy-harness.php delete mode 100644 tests/widget-system/order-after-sale-assets-contract.test.cjs create mode 100644 tests/widget-system/ticket-captcha-contract.test.cjs create mode 100644 tests/widget-system/ticket-lifecycle-contract.test.cjs create mode 100644 tests/widget-system/ticket-native-admin-contract.test.cjs create mode 100644 tests/widget-system/ticket-permission-contract.test.cjs create mode 100644 tests/widget-system/ticket-schema-contract.test.cjs create mode 100644 tests/widget-system/ticket-schema-version.test.cjs 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 += '
'; - // 滚动容器与禁封申诉弹窗一致:no-scrollbar 完全隐藏滚动条(含细滚动条)+ 50vh 内层滚动。 - html += '
'; - html += orderSummaryHtml(order); - - if (reasons.length) { - html += '
'; - html += '
售后原因
'; - html += '
'; - reasons.forEach(function (reason) { - html += '' + escapeHtml(reason.name) + ''; - }); - html += '
'; - } - - materials.forEach(function (label) { - html += '
' + escapeHtml(label) + '
' + - '
'; - }); - - var placeholder = String(cfg.reasonPlaceholder || '必填,请您详细填写申请原因'); - html += '
申请说明
' + - '
'; - - if (cfg.imgEnabled) { - html += '
' + - '
上传图片
' + - '
' + - '' + - '
最多上传 ' + MAX_IMAGES + ' 张图片
' + - '
'; - } - - // 申请售后说明(后台 after_sale_desc,服务端已 wp_kses_post)。 - var desc = String(cfg.desc || ''); - if (desc) { - html += '
' + desc + '
'; - } - - html += '
'; - html += ''; - html += '
'; - return html; - } - - function detailRow(label, value) { - if (value === '' || value == null) return ''; - return '
' + escapeHtml(label) + '' + - '' + escapeHtml(value) + '
'; - } - - // 处理记录节点标题:用户侧提交/撤脱离不开工单状态,单独措辞; - // 后台提交直接用目标状态,与后台列表口径一致。 - function 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 += '
  1. '; - html += ''; - html += '
    '; - // [节点][处理状态][时间] 同一行,处理备注另起一行 - html += '
    ' + - '' + escapeHtml(handlingEntryTitle(entry)) + '' + - '' + - '
    '; - if (note !== '') { - html += '

    ' + escapeHtml(note).replace(/\r\n|\r|\n/g, '
    ') + '

    '; - } - html += '
    '; - html += '
  2. '; - }); - 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 += ""; - 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 += "
    "; - 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 + '
' + + ''; + } + + /* ------------------------------------------------------------------ * + * 列表 / 统计 + * ------------------------------------------------------------------ */ + + 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) + '' + + '' + + (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 += ''; + } + 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) + '' + + '
' + + 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 += ''; + + if (orders.length) { + 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; +?> +
+
+
+

+
+ + +
+

+
+ +
+ +
+ +
+ 0 +
+ +
+ + + +
+
+ +

+

+
+
+ + + + + +
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 ''; } - $after_sales = \AKSRT\Payment\AfterSaleService::get_instance()->list_for_order($order_number); - if ($after_sales) { - echo ''; - } if (!empty($context)) { echo ''; } @@ -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 ' '; - submit_button(__('筛选', 'aksrt'), 'secondary', '', false); - echo ''; - echo ''; - echo '
' . esc_html($label) . '' . esc_html((string) $value) . '
' . 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 '
' . esc_html__('附加上下文', 'aksrt') . '
' . esc_html(wp_json_encode($context, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)) . '
'; - if (!$items) echo ''; - $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 ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - echo ''; - } - echo '
ID订单号用户原因申请资料申请说明退款金额状态操作 / 处理记录
' . esc_html__('暂无售后申请', 'aksrt') . '
' . esc_html((string) $id) . '
' . esc_html((string) ($item['created_at'] ?? '')) . '
' . esc_html($number) . '
' . esc_html($type_label) . ($product_name !== '' ? ' · ' . esc_html($product_name) : '') . '
' . esc_html($user ? $user->display_name . ' (#' . $uid . ')' : '#' . $uid) . '' . $reason_cell . '' . $materials_cell . '' . $desc_cell . '' . esc_html($refund_amount) . '' . esc_html($status_label) . '
'; - // 翻页链接显式带上当前筛选条件(不含 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 ''; + + 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('

%s

', 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' + . '
', + 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 '' + . '' + . ''; + foreach ($types as $row) { + $edit_url = add_query_arg(['type_id' => (int) $row['id']], $this->ticket_page_url(['tab' => 'config'])); + $cells = sprintf( + ''; + echo $cells; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + echo '
ID' . esc_html__('名称', 'aksrt') . '' . esc_html__('说明', 'aksrt') . '' . esc_html__('受理管理员', 'aksrt') . '
%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 '

' . 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 '' + . '' + . '' + . ''; + foreach ($statuses as $row) { + $status_cells = sprintf( + '' + . '' + . ''; + echo $status_cells; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + echo '
ID' . esc_html__('名称', 'aksrt') . '' . esc_html__('颜色', 'aksrt') . '' . esc_html__('完结', 'aksrt') . '' . esc_html__('默认', 'aksrt') . '
%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 '

' . 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 ' '; + 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 ''; + if ($rows === []) { + echo ''; + } + foreach ($rows as $row) { + $reply_cells = sprintf( + ''; + echo $reply_cells; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + echo '
ID' . esc_html__('内容', 'aksrt') . '
' . esc_html__('暂无预设回复', 'aksrt') . '
%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 '

' . 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 '

'; + 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 ' '; + echo ' '; + submit_button(__('筛选', 'aksrt'), 'primary', '', false); + echo '
'; + + // 新建工单入口 + printf( + '

', + 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 ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + + if ($items === []) { + echo ''; + } + + foreach ($items as $ticket) { + $is_open = $open_id === (int) $ticket['id']; + printf( + '' + . '' + . '', + 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 ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + } + echo '
' . esc_html__('状态', 'aksrt') . '' . esc_html__('工单号', 'aksrt') . '' . esc_html__('标题', 'aksrt') . '' . esc_html__('部门', 'aksrt') . '' . esc_html__('用户', 'aksrt') . '' . esc_html__('跟进人', 'aksrt') . '' . esc_html__('提交时间', 'aksrt') . '' . esc_html__('最后回复', 'aksrt') . '
' . esc_html__('暂无工单', 'aksrt') . '
%s%s%s%s%s%s%s%s
' . $this->render_ticket_detail_panel($ticket) . '
'; + + \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') . ':' . esc_html((string) $ticket['ticket_num']) . '

'; + $html .= '

' . esc_html__('提交用户', 'aksrt') . ':' . esc_html((string) $ticket['user_name']) . ' (#' . esc_html((string) $ticket['user_id']) . ')

'; + if ((string) $ticket['order_number'] !== '') { + $html .= '

' . esc_html__('关联订单', 'aksrt') . ':' . esc_html((string) $ticket['order_number']) . '

'; + } + $html .= '

' . esc_html__('问题描述', 'aksrt') . ':

'; + $html .= '
' . nl2br(esc_html((string) $ticket['content'])) . '
'; + $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 .= '
  1. '; + $html .= '
    ' . esc_html($who) . '' + . '
    '; + 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 .= '
  2. '; + } + $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 .= '

'; + $html .= '
'; + } + + // 状态迁移 / 部门 / 跟进人 + $html .= '
'; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= wp_nonce_field('aksrt_ticket_status_' . $id, '_wpnonce', true, false); + $html .= '

'; + $html .= ' '; + $html .= ' '; + $html .= '

'; + $html .= '
'; + + // 后台备注 + $html .= '

' . esc_html__('后台备注', 'aksrt') . '

'; + if ($notes === []) { + $html .= '

' . esc_html__('暂无备注', 'aksrt') . '

'; + } + $html .= '
    '; + foreach ($notes as $note) { + $html .= '
  • ' . esc_html((string) $note['admin_name']) . '' + . ' ' + . '
    ' . nl2br(esc_html((string) $note['content'])) . '
  • '; + } + $html .= '
'; + $html .= '
'; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= wp_nonce_field('aksrt_ticket_note_' . $id, '_wpnonce', true, false); + $html .= '

' + . '

'; + $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 ''; } -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, /'
'/) - assert.match(js, /'
'/) - assert.ok(!js.includes('mini-scrollbar'), 'after-sale modals must not render a visible scrollbar') - - // 申请表单:预置原因徽章、所需资料、必填申请说明(placeholder 取后台配置) - assert.match(js, /reasonOptions\(\)/) - assert.match(js, /materialOptions\(\)/) - assert.match(js, /data-aksrt-after-sale-reason=/) - assert.match(js, /data-aksrt-after-sale-material=/) - assert.match(js, /var placeholder = String\(cfg\.reasonPlaceholder \|\| '必填,请您详细填写申请原因'\)/) - assert.match(js, /name="reason"/) - assert.match(js, /notyf\('请填写申请说明', 'danger'\)/) - assert.match(js, /notyf\('请选择售后原因', 'danger'\)/) - - // 图片上传:开关控制渲染,最多 3 张,上传/删除/提交 - assert.match(js, /if \(cfg\.imgEnabled\) \{/) - assert.match(js, /MAX_IMAGES = 3/) - assert.match(js, /\/after-sale\/upload-image'/) - assert.match(js, /data-aksrt-after-sale-image-remove/) - assert.match(js, /images: collectImages\(shell\)/) - assert.match(js, /reason_label: selectedReasonLabel\(shell\)/) - assert.match(js, /materials: collectMaterials\(shell\)/) - assert.match(js, /description: reasonText,/) - - // 申请说明(HTML)+ 超时效提示(HTML)由后台配置直出 - assert.match(js, /var desc = String\(cfg\.desc \|\| ''\)/) - assert.match(js, /var text = String\(settings\(\)\.expiredText \|\| '抱歉,您的订单已超过售后申请时效'\)/) - - // 售后详细:状态徽章 / 明细行 / 图片缩略图;不可再申请提示原文 + 我知道了 - // 申请表单分行展示:原因 / 申请资料 / 申请说明各自独立成行(与申请时间同格式); - // 老数据无 apply_form 时兜底展示融合串 - assert.match(js, /detailRow\('原因', String\(applyForm\.reason_label \|\| ''\)\)/) - assert.match(js, /detailRow\('申请说明', String\(applyForm\.description \|\| ''\)\)/) - assert.match(js, /STATUS_LABELS = \{[\s\S]*?pending: '待审核'[\s\S]*?completed: '已完成',/) - // 不可再申请:不再展示阻断文案,仅保留关闭入口;可再申请:仅保留单个主按钮(无「关闭」) - assert.ok(!js.includes('暂不支持再次申请售后'), 'blocked notice copy must be removed') - assert.ok(!js.includes('REAPPLY_BLOCKED_TEXT'), 'blocked text constant must be removed') - assert.ok(!js.includes('>关闭'), 'detail modal must not render a 关闭 button') - assert.match(js, /data-aksrt-modal-close>我知道了 \.but,[\s\S]{0,260}min-height:\s*62px/) - assert.match(modalCss, /-apply-modal \.but-average\.modal-buts > \.but \+ \.but,\s*\n\s*\.aksrt-after-sale-detail-modal \.but-average\.modal-buts > \.but \+ \.but,\s*\n\s*\.aksrt-after-sale-expired-modal \.but-average\.modal-buts > \.but \+ \.but,\s*\n\s*\.aksrt-after-sale-withdraw-modal \.but-average\.modal-buts > \.but \+ \.but\s*\{[^}]*border-left:\s*1px solid var\(--modal-line-soft/) - assert.match(modalCss, /-apply-modal \.but-average\.modal-buts > \.but:hover,\s*\n\s*\.aksrt-after-sale-detail-modal \.but-average\.modal-buts > \.but:hover,\s*\n\s*\.aksrt-after-sale-expired-modal \.but-average\.modal-buts > \.but:hover,\s*\n\s*\.aksrt-after-sale-withdraw-modal \.but-average\.modal-buts > \.but:hover\s*\{[^}]*background:\s*color-mix\(in srgb, var\(--modal-ink/) - assert.match(modalCss, /-apply-modal \.but-average\.modal-buts > \.but\.c-blue:hover,\s*\n\s*\.aksrt-after-sale-detail-modal \.but-average\.modal-buts > \.but\.c-blue:hover,\s*\n\s*\.aksrt-after-sale-expired-modal \.but-average\.modal-buts > \.but\.c-blue:hover,\s*\n\s*\.aksrt-after-sale-withdraw-modal \.but-average\.modal-buts > \.but\.c-blue:hover\s*\{[^}]*color:\s*color-mix/) - - // 撤销售后:入口仅随待审核状态渲染,点击先过二次确认弹窗,确认后 DELETE 工单 - assert.match(js, /status === 'pending'[\s\S]*?data-aksrt-after-sale-withdraw="' \+ escapeHtml\(\(order && order\.order_number\) \|\| ''\)/) - assert.match(js, /buildWithdrawHtml\(orderNumber\)/) - assert.match(js, /className: 'aksrt-after-sale-withdraw-modal'/) - assert.match(js, /data-aksrt-after-sale-withdraw-confirm="' \+ escapeHtml\(orderNumber\) \+ '"/) - assert.match(js, /method: 'DELETE'/) - assert.match(js, /AksrtModalRuntime\.close\('withdraw'\)/) - assert.match(js, /notyf\('售后申请已撤销', 'success'\)/) - - // 人机验证:提交与撤销前先过验证(场景未启用时挂载点缺失,run 直接放行) - assert.match(js, /function runCaptcha\(scene\)/) - assert.match(js, /runCaptcha\('after_sale_apply'\)/) - assert.match(js, /runCaptcha\('after_sale_cancel'\)/) - assert.match(js, /AKSRTCaptcha\.run\(scene\)/) - - // 提交后关闭弹窗并刷新订单列表 - assert.match(js, /AKSRTOrderCenter\.refresh\(orderNumber\)/) - assert.match(js, /window\.AksrtModalRuntime\.close\('submit'\)/) - - // 入口委托:申请 / 售后详细 - assert.match(js, /data-aksrt-after-sale-apply/) - assert.match(js, /data-aksrt-after-sale-detail/) - assert.match(js, /auto_fun\.aksrt-after-sale/) -}) - -test('order center drops the embedded after-sale form and exposes refresh()', () => { - const js = read('assets/js/order-center.js') - - // 内嵌售后表单与两个 submit 处理器已彻底移除(死代码清理) - assert.ok(!js.includes('data-aksrt-after-sale-form'), 'embedded after-sale form must be removed') - assert.ok(!js.includes('提交售后申请'), 'embedded after-sale submit copy must be removed') - assert.ok(!js.includes('onDocumentSubmit'), 'document submit handler for the after-sale form must be removed') - - // 列表卡片与订单详情弹窗底部共用新的售后按钮 - assert.match(js, /function afterSaleButtons\(order\)/) - assert.match(js, /function afterSalePayload\(order\)/) - assert.match(js, /data-aksrt-after-sale-detail="' \+ payload \+ '"/) - assert.match(js, /data-aksrt-after-sale-apply="' \+ payload \+ '"/) - assert.match(js, /if \(afterSale\.enabled\) html \+= afterSaleButtons\(order\);/) - assert.ok(!js.includes('\n html += afterSaleButtons(order);\n'), 'detail modal footer must not render the shared after-sale buttons') - // 底部按钮与禁封申诉弹窗同式(but-average modal-buts);「售后详细」入口收拢到记录卡片 - assert.match(js, /aksrt-order-detail__actions but-average modal-buts/) - assert.match(js, /data-aksrt-after-sale-apply="' \+ afterSalePayload\(order\) \+ '"/) - // 底部「关闭」与售后详细「我知道了」同款(but c-blue,平铺观感由 orders.css 呈现) - assert.match(js, /class="but c-blue" data-aksrt-modal-close>关闭'), 'record card has a view-detail entry') - assert.match(js, /data-aksrt-after-sale-detail="' \+ recordPayload \+ '"/) - // 记录卡片:原因 / 申请理由分行(新数据走 apply_form,老数据兜底融合串) - assert.ok(js.includes('aksrt-order-detail__after-sale-field">原因:'), 'record card shows 原因:') - assert.ok(js.includes('aksrt-order-detail__after-sale-field">申请理由:'), 'record card shows 申请理由:') - assert.match(js, /data-aksrt-after-sale-detail="/) - assert.match(js, /data-aksrt-after-sale-apply="/) - - // 订单详情弹窗 CSS:记录卡片「查看详细」foot 右对齐;底部按钮与售后弹窗家族 - // 同款平铺观感(modal-runtime 同款规则镜像到吸底 footer),单按钮占满整行。 - const ordersCss = read('assets/css/routes/orders.css') - assert.match(ordersCss, /\.aksrt-order-detail__after-sale-foot\s*\{[^}]*justify-content:\s*flex-end/) - assert.match(ordersCss, /\.aksrt-order-detail-modal \.aksrt-order-detail__actions\.but-average\.modal-buts > \.but\s*\{[^}]*background:\s*transparent/) - assert.match(ordersCss, /\.aksrt-order-detail-modal \.aksrt-order-detail__actions\.but-average\.modal-buts > \.but\.c-blue\s*\{[^}]*color:\s*var\(--modal-primary/) - assert.match(ordersCss, /\.aksrt-order-detail-modal \.aksrt-order-detail__actions\.but-average\.modal-buts > \.but:only-child\s*\{[^}]*grid-column:\s*1 \/ -1/) - // 分界线与悬停阴影与售后弹窗家族同款(分隔色用本弹窗 footer 同源的 --detail-line); - // footer 零垂直内边距、按钮满高(min-height 64 = 13+38+13),按钮盒=整个底部区域,安全区留 footer 底部 - assert.match(ordersCss, /\.aksrt-order-detail-modal \.aksrt-order-detail__actions\s*\{[^}]*padding:\s*0 0 env\(safe-area-inset-bottom/) - assert.match(ordersCss, /\.aksrt-order-detail-modal \.aksrt-order-detail__actions\.but-average\.modal-buts > \.but\s*\{[^}]*min-height:\s*64px/) - assert.match(ordersCss, /\.aksrt-order-detail-modal \.aksrt-order-detail__actions\.but-average\.modal-buts > \.but \+ \.but\s*\{[^}]*border-left:\s*1px solid var\(--detail-line\)/) - assert.match(ordersCss, /\.aksrt-order-detail-modal \.aksrt-order-detail__actions\.but-average\.modal-buts > \.but:hover\s*\{[^}]*background:\s*color-mix\(in srgb, var\(--modal-ink/) - assert.match(ordersCss, /\.aksrt-order-detail-modal \.aksrt-order-detail__actions\.but-average\.modal-buts > \.but\.c-blue:hover\s*\{[^}]*color:\s*color-mix/) - - // 售后记录区保留在订单详情弹窗:记录卡片带申请时间,「查看详细」逐条入口; - // 取消申请按钮与处理器已移除(撤销入口移入售后详细弹窗)。 - assert.match(js, /售后记录/) - assert.match(js, /