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 += '

售后记录

"; - } - 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 += '"; 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, /