From 1268a55dd0991a6645ab426566509c9099e2096a Mon Sep 17 00:00:00 2001 From: Claude Code Date: Fri, 3 Jul 2026 22:21:27 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AF=BC=E5=85=A5=20AKSRT=20=E4=B8=BB=E9=A2=98?= =?UTF-8?q?=E5=BC=80=E5=8F=91=E6=BA=90=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 404.php | 107 + archive-aksrt_resource.php | 192 ++ archive.php | 96 + assets/admin/extract-naive-styles.mjs | 179 ++ assets/admin/index.html | 12 + assets/admin/package-lock.json | 1853 +++++++++++++++++ assets/admin/package.json | 1 + assets/admin/src/App.vue | 271 +++ assets/admin/src/api/index.js | 120 ++ assets/admin/src/api/modules/extensions.js | 8 + assets/admin/src/api/modules/global.js | 28 + assets/admin/src/api/modules/index.js | 19 + assets/admin/src/api/modules/page-display.js | 30 + assets/admin/src/api/modules/payment.js | 7 + assets/admin/src/api/modules/permissions.js | 17 + assets/admin/src/api/modules/schema.js | 17 + assets/admin/src/api/modules/tools.js | 28 + assets/admin/src/api/modules/users.js | 36 + assets/admin/src/components/DeferredPage.vue | 162 ++ assets/admin/src/components/EmptyState.vue | 48 + assets/admin/src/components/MobileTopBar.vue | 86 + .../src/components/OAuthProviderForm.vue | 164 ++ assets/admin/src/components/PageHeader.vue | 81 + .../src/components/captcha/AliyunConfig.vue | 66 + .../components/captcha/CloudflareConfig.vue | 44 + .../src/components/captcha/GeetestConfig.vue | 25 + .../src/components/captcha/TencentConfig.vue | 42 + .../src/components/form/AksArrayTable.vue | 128 ++ .../src/components/form/AksCodeEditor.vue | 105 + assets/admin/src/components/form/AksField.vue | 824 ++++++++ .../src/components/form/AksFieldGroup.vue | 143 ++ .../src/components/form/AksFormRenderer.vue | 365 ++++ .../src/components/form/AksMediaField.vue | 167 ++ .../src/components/form/AksRepeaterField.vue | 185 ++ assets/admin/src/components/form/FaIcon.vue | 67 + .../src/components/form/FaIconPicker.vue | 221 ++ .../src/components/homepage/ModuleCanvas.vue | 313 +++ .../components/homepage/ModuleInspector.vue | 399 ++++ .../homepage/ModulePresetPicker.vue | 143 ++ assets/admin/src/composables/useDirtyState.js | 50 + .../admin/src/composables/useSectionForm.js | 140 ++ assets/admin/src/constants/admin-sections.js | 10 + assets/admin/src/constants/sections/docs.js | 20 + .../src/constants/sections/extensions.js | 80 + assets/admin/src/constants/sections/forum.js | 165 ++ assets/admin/src/constants/sections/global.js | 108 + .../admin/src/constants/sections/helpers.js | 39 + assets/admin/src/constants/sections/index.js | 66 + .../src/constants/sections/page-display.js | 118 ++ .../admin/src/constants/sections/payment.js | 100 + .../src/constants/sections/permissions.js | 74 + .../admin/src/constants/sections/post-list.js | 92 + assets/admin/src/constants/sections/shop.js | 157 ++ .../src/constants/sections/theme-info.js | 29 + assets/admin/src/constants/sections/tools.js | 67 + assets/admin/src/constants/sections/users.js | 188 ++ .../admin/src/constants/settings-defaults.js | 28 + assets/admin/src/main.js | 101 + assets/admin/src/pages/CmsCredits.vue | 165 ++ assets/admin/src/pages/ResourceDownload.vue | 238 +++ assets/admin/src/pages/SchemaPage.vue | 508 +++++ assets/admin/src/pages/SiteSettings.vue | 211 ++ .../admin/src/pages/content/BannerManager.vue | 392 ++++ .../pages/content/FloatingButtonsSettings.vue | 1003 +++++++++ .../src/pages/content/FooterSettings.vue | 988 +++++++++ .../pages/content/FooterTabbarSettings.vue | 790 +++++++ .../admin/src/pages/content/HeroManager.vue | 548 +++++ .../src/pages/content/HomepageSettings.vue | 617 ++++++ .../content/NotificationCenterSettings.vue | 155 ++ assets/admin/src/pages/docs/DocsPage.vue | 354 ++++ .../src/pages/extensions/ExtensionAI.vue | 314 +++ .../pages/extensions/ExtensionDocsMode.vue | 311 +++ .../pages/extensions/ExtensionSecurity.vue | 293 +++ .../src/pages/extensions/ExtensionTools.vue | 271 +++ .../extensions/ExtensionsSectionPage.vue | 39 + assets/admin/src/pages/forum/ForumBoards.vue | 195 ++ .../admin/src/pages/forum/ForumDashboard.vue | 223 ++ .../forum/ForumModeratorApplications.vue | 201 ++ .../admin/src/pages/forum/ForumModerators.vue | 363 ++++ assets/admin/src/pages/forum/ForumTopics.vue | 186 ++ .../components/TopicModerationDrawer.vue | 284 +++ .../src/pages/payment/PaymentAffiliate.vue | 16 + .../src/pages/payment/PaymentChargeCard.vue | 453 ++++ .../src/pages/payment/PaymentGateway.vue | 179 ++ .../admin/src/pages/payment/PaymentRebate.vue | 316 +++ .../src/pages/payment/PaymentRecharge.vue | 16 + .../src/pages/payment/PaymentRevenue.vue | 249 +++ .../src/pages/payment/PaymentSectionPage.vue | 103 + assets/admin/src/pages/payment/PaymentVip.vue | 16 + .../components/ChargeCardDetailDrawer.vue | 89 + .../payment/components/RebateDetailDrawer.vue | 175 ++ .../permissions/AttachmentPermissions.vue | 283 +++ .../pages/permissions/CommentPermissions.vue | 150 ++ .../pages/permissions/DownloadPermissions.vue | 161 ++ .../permissions/PermissionsCapabilities.vue | 245 +++ .../src/pages/post-list/CommentSettings.vue | 7 + .../src/pages/post-list/PostPageSettings.vue | 7 + assets/admin/src/pages/shop/ShopAfterSale.vue | 304 +++ assets/admin/src/pages/shop/ShopCoupons.vue | 215 ++ assets/admin/src/pages/shop/ShopDashboard.vue | 180 ++ assets/admin/src/pages/shop/ShopOrders.vue | 173 ++ assets/admin/src/pages/shop/ShopProducts.vue | 147 ++ assets/admin/src/pages/shop/ShopShipping.vue | 364 ++++ .../admin/src/pages/shop/ShopWithdrawals.vue | 138 ++ .../shop/components/AfterSaleDetailDrawer.vue | 283 +++ .../shop/components/OrderContextPanel.vue | 107 + .../shop/components/OrderDetailDrawer.vue | 440 ++++ .../shop/components/ProductEditorDrawer.vue | 263 +++ .../components/ShippingBatchActionDialog.vue | 94 + .../shop/components/ShippingDetailDrawer.vue | 237 +++ .../shop/components/WithdrawalReviewModal.vue | 68 + .../pages/theme/ThemeAuthorizationPage.vue | 42 + .../admin/src/pages/theme/ThemeInfoPage.vue | 274 +++ assets/admin/src/pages/tools/ToolsBackup.vue | 279 +++ .../src/pages/tools/ToolsEmailRollback.vue | 122 ++ assets/admin/src/pages/tools/ToolsHealth.vue | 111 + .../src/pages/tools/ToolsImportExport.vue | 191 ++ .../admin/src/pages/tools/ToolsMigration.vue | 111 + assets/admin/src/pages/tools/ToolsUpdate.vue | 201 ++ assets/admin/src/pages/users/UsersBadge.vue | 173 ++ assets/admin/src/pages/users/UsersBan.vue | 210 ++ .../admin/src/pages/users/UsersFeatures.vue | 195 ++ .../src/pages/users/UsersInviteCodes.vue | 512 +++++ assets/admin/src/pages/users/UsersLevel.vue | 187 ++ .../admin/src/pages/users/UsersMessages.vue | 308 +++ assets/admin/src/pages/users/UsersReport.vue | 357 ++++ .../src/pages/users/UsersSectionPage.vue | 40 + assets/admin/src/pages/users/UsersSignin.vue | 308 +++ assets/admin/src/pages/users/UsersVerify.vue | 338 +++ .../src/pages/users/UsersWechatTemplate.vue | 258 +++ .../users/components/ReportDetailDrawer.vue | 242 +++ .../users/components/VerifyReviewDrawer.vue | 180 ++ .../src/router/component-loaders/core.js | 19 + .../router/component-loaders/extensions.js | 5 + .../src/router/component-loaders/forum.js | 7 + .../src/router/component-loaders/index.js | 17 + .../src/router/component-loaders/payment.js | 6 + .../src/router/component-loaders/shop.js | 9 + .../src/router/component-loaders/tools.js | 7 + .../src/router/component-loaders/users.js | 12 + assets/admin/src/router/index.js | 66 + assets/admin/src/schema/index.js | 114 + .../src/schema/sections/extensions/audit.js | 68 + .../schema/sections/extensions/docs-mode.js | 125 ++ .../src/schema/sections/extensions/index.js | 16 + .../src/schema/sections/extensions/ip.js | 86 + .../src/schema/sections/extensions/push.js | 64 + .../admin/src/schema/sections/forum/basic.js | 28 + .../src/schema/sections/forum/board-page.js | 31 + .../src/schema/sections/forum/credits.js | 35 + .../src/schema/sections/forum/homepage.js | 29 + .../admin/src/schema/sections/forum/index.js | 31 + .../admin/src/schema/sections/forum/names.js | 41 + .../admin/src/schema/sections/forum/other.js | 64 + .../src/schema/sections/forum/permissions.js | 64 + .../src/schema/sections/forum/post-page.js | 60 + .../admin/src/schema/sections/forum/reply.js | 44 + .../admin/src/schema/sections/forum/upload.js | 27 + .../schema/sections/global/code-highlight.js | 68 + .../src/schema/sections/global/common.js | 99 + .../src/schema/sections/global/custom-code.js | 39 + .../admin/src/schema/sections/global/email.js | 123 ++ .../src/schema/sections/global/floating.js | 8 + .../admin/src/schema/sections/global/i18n.js | 85 + .../admin/src/schema/sections/global/index.js | 32 + .../src/schema/sections/global/layout.js | 160 ++ .../admin/src/schema/sections/global/logo.js | 36 + .../src/schema/sections/global/search.js | 135 ++ .../admin/src/schema/sections/global/seo.js | 102 + .../schema/sections/global/system-notice.js | 149 ++ .../src/schema/sections/global/tabbar.js | 8 + .../src/schema/sections/page-display/index.js | 991 +++++++++ .../src/schema/sections/payment/affiliate.js | 73 + .../src/schema/sections/payment/cardPass.js | 59 + .../src/schema/sections/payment/gateway.js | 172 ++ .../src/schema/sections/payment/index.js | 25 + .../src/schema/sections/payment/order.js | 96 + .../admin/src/schema/sections/payment/paid.js | 72 + .../src/schema/sections/payment/recharge.js | 94 + .../src/schema/sections/payment/revenue.js | 44 + .../admin/src/schema/sections/payment/vip.js | 102 + .../src/schema/sections/payment/withdrawal.js | 23 + .../src/schema/sections/permissions/index.js | 316 +++ .../src/schema/sections/post-list/comment.js | 100 + .../src/schema/sections/post-list/feature.js | 25 + .../src/schema/sections/post-list/filter.js | 110 + .../src/schema/sections/post-list/index.js | 25 + .../src/schema/sections/post-list/list.js | 158 ++ .../src/schema/sections/post-list/page.js | 155 ++ .../src/schema/sections/post-list/pay.js | 46 + .../src/schema/sections/post-list/share.js | 42 + .../src/schema/sections/post-list/submit.js | 125 ++ .../src/schema/sections/post-list/thumb.js | 70 + .../admin/src/schema/sections/shop/basic.js | 49 + .../admin/src/schema/sections/shop/contact.js | 32 + .../admin/src/schema/sections/shop/index.js | 21 + assets/admin/src/schema/sections/shop/list.js | 40 + .../schema/sections/shop/product-defaults.js | 54 + .../schema/sections/shop/shipping-config.js | 52 + .../admin/src/schema/sections/shop/single.js | 45 + .../admin/src/schema/sections/shop/timing.js | 54 + .../admin/src/schema/sections/tools/backup.js | 29 + .../schema/sections/tools/email-rollback.js | 29 + .../admin/src/schema/sections/tools/health.js | 21 + .../schema/sections/tools/import-export.js | 26 + .../admin/src/schema/sections/tools/index.js | 18 + .../src/schema/sections/tools/migration.js | 19 + .../admin/src/schema/sections/users/auth.js | 160 ++ .../admin/src/schema/sections/users/badge.js | 32 + .../src/schema/sections/users/credits.js | 98 + .../src/schema/sections/users/features.js | 45 + .../admin/src/schema/sections/users/index.js | 33 + .../admin/src/schema/sections/users/invite.js | 68 + .../admin/src/schema/sections/users/level.js | 34 + .../schema/sections/users/notifications.js | 74 + .../admin/src/schema/sections/users/oauth.js | 188 ++ .../admin/src/schema/sections/users/report.js | 58 + .../admin/src/schema/sections/users/signin.js | 27 + assets/admin/src/schema/sections/users/sms.js | 144 ++ .../admin/src/schema/sections/users/verify.js | 34 + .../admin/src/schema/sections/users/wechat.js | 51 + assets/admin/src/stores/admin-ui.js | 40 + assets/admin/src/style-target.js | 7 + assets/admin/src/style.css | 384 ++++ assets/admin/src/utils/home-module-schema.js | 535 +++++ assets/admin/src/utils/media.js | 40 + assets/admin/tsconfig.json | 1 + assets/admin/vite.config.ts | 75 + author.php | 118 ++ comments.php | 206 ++ composer.json | 18 + composer.lock | 272 +++ footer.php | 476 +++++ front-page.php | 43 + functions.php | 327 +++ go.php | 56 + header.php | 366 ++++ home.php | 16 + image.php | 93 + inc/bootstrap.php | 420 ++++ .../admin/class-aksrt-admin-ai-controller.php | 201 ++ .../class-aksrt-admin-comment-controller.php | 424 ++++ .../class-aksrt-admin-controller-base.php | 173 ++ ...class-aksrt-admin-extension-controller.php | 456 ++++ .../class-aksrt-admin-forum-controller.php | 411 ++++ .../class-aksrt-admin-global-controller.php | 575 +++++ ...s-aksrt-admin-import-export-controller.php | 408 ++++ .../class-aksrt-admin-message-controller.php | 254 +++ ...ss-aksrt-admin-page-display-controller.php | 201 ++ .../class-aksrt-admin-payment-controller.php | 753 +++++++ ...ass-aksrt-admin-permissions-controller.php | 171 ++ ...class-aksrt-admin-post-list-controller.php | 24 + .../class-aksrt-admin-schema-provider.php | 384 ++++ ...p-controller-coupon-gateway-operations.php | 97 + ...op-controller-frontend-flow-operations.php | 324 +++ ...shop-controller-fulfillment-operations.php | 354 ++++ ...admin-shop-controller-order-operations.php | 136 ++ ...-shop-controller-product-admin-columns.php | 187 ++ ...min-shop-controller-product-operations.php | 382 ++++ ...p-controller-variant-export-operations.php | 111 + .../class-aksrt-admin-shop-controller.php | 369 ++++ .../class-aksrt-admin-tools-controller.php | 285 +++ ...-user-controller-auth-phone-operations.php | 287 +++ ...dmin-user-controller-follow-operations.php | 89 + ...dmin-user-controller-invite-operations.php | 450 ++++ ...dmin-user-controller-report-operations.php | 471 +++++ ...user-controller-restriction-operations.php | 82 + ...in-user-controller-verify-integrations.php | 379 ++++ .../class-aksrt-admin-user-controller.php | 708 +++++++ ...it-aksrt-admin-forum-controller-part-1.php | 384 ++++ ...it-aksrt-admin-forum-controller-part-2.php | 182 ++ ...t-aksrt-admin-global-controller-part-1.php | 264 +++ ...t-aksrt-admin-global-controller-part-2.php | 258 +++ ...-admin-import-export-controller-part-1.php | 406 ++++ ...-admin-import-export-controller-part-2.php | 78 + ...ss-aksrt-admin-schema-extensions-audit.php | 43 + ...ksrt-admin-schema-extensions-docs-mode.php | 53 + ...class-aksrt-admin-schema-extensions-ip.php | 51 + ...ass-aksrt-admin-schema-extensions-push.php | 43 + ...aksrt-admin-schema-extensions-security.php | 111 + .../class-aksrt-admin-schema-extensions.php | 25 + .../class-aksrt-admin-schema-forum-basic.php | 28 + ...ss-aksrt-admin-schema-forum-board-page.php | 34 + ...class-aksrt-admin-schema-forum-credits.php | 33 + ...lass-aksrt-admin-schema-forum-homepage.php | 32 + .../class-aksrt-admin-schema-forum-names.php | 36 + .../class-aksrt-admin-schema-forum-other.php | 46 + ...s-aksrt-admin-schema-forum-permissions.php | 46 + ...ass-aksrt-admin-schema-forum-post-page.php | 46 + .../class-aksrt-admin-schema-forum-reply.php | 39 + .../class-aksrt-admin-schema-forum-upload.php | 29 + .../schema/class-aksrt-admin-schema-forum.php | 126 ++ .../class-aksrt-admin-schema-global.php | 530 +++++ .../class-aksrt-admin-schema-page-display.php | 530 +++++ .../class-aksrt-admin-schema-payment.php | 374 ++++ .../class-aksrt-admin-schema-permissions.php | 194 ++ .../class-aksrt-admin-schema-post-list.php | 453 ++++ .../class-aksrt-admin-schema-shop-basic.php | 37 + .../class-aksrt-admin-schema-shop-contact.php | 30 + .../class-aksrt-admin-schema-shop-list.php | 51 + ...srt-admin-schema-shop-product-defaults.php | 46 + ...ksrt-admin-schema-shop-shipping-config.php | 41 + .../class-aksrt-admin-schema-shop-single.php | 39 + .../class-aksrt-admin-schema-shop-timing.php | 38 + .../schema/class-aksrt-admin-schema-shop.php | 28 + .../class-aksrt-admin-schema-tools-backup.php | 32 + ...ksrt-admin-schema-tools-email-rollback.php | 32 + .../class-aksrt-admin-schema-tools-health.php | 29 + ...aksrt-admin-schema-tools-import-export.php | 29 + ...ass-aksrt-admin-schema-tools-migration.php | 27 + .../schema/class-aksrt-admin-schema-tools.php | 25 + .../schema/class-aksrt-admin-schema-users.php | 697 +++++++ .../trait-aksrt-auth-service-part-1a.php | 483 +++++ .../trait-aksrt-auth-service-part-1b.php | 447 ++++ .../class-aksrt-auth-email-verification.php | 529 +++++ inc/classes/class-aksrt-admin.php | 254 +++ inc/classes/class-aksrt-after-sale.php | 800 +++++++ inc/classes/class-aksrt-ai-prompt.php | 117 ++ inc/classes/class-aksrt-ai-seo.php | 111 + inc/classes/class-aksrt-ai.php | 185 ++ inc/classes/class-aksrt-api-audit.php | 250 +++ inc/classes/class-aksrt-auth-router.php | 217 ++ inc/classes/class-aksrt-auth-service.php | 414 ++++ inc/classes/class-aksrt-captcha.php | 1270 +++++++++++ inc/classes/class-aksrt-category-rewrite.php | 166 ++ inc/classes/class-aksrt-charge-card.php | 209 ++ inc/classes/class-aksrt-comment-actions.php | 348 ++++ inc/classes/class-aksrt-content-audit.php | 357 ++++ .../class-aksrt-content-visibility.php | 282 +++ inc/classes/class-aksrt-coupon.php | 276 +++ inc/classes/class-aksrt-credits-earn.php | 229 ++ inc/classes/class-aksrt-credits.php | 308 +++ inc/classes/class-aksrt-db-migrator.php | 386 ++++ inc/classes/class-aksrt-discount.php | 171 ++ inc/classes/class-aksrt-docs-presenter.php | 232 +++ inc/classes/class-aksrt-download-state.php | 208 ++ inc/classes/class-aksrt-download.php | 329 +++ inc/classes/class-aksrt-email-rollback.php | 228 ++ inc/classes/class-aksrt-follow.php | 179 ++ inc/classes/class-aksrt-forum.php | 801 +++++++ inc/classes/class-aksrt-front-submit.php | 391 ++++ inc/classes/class-aksrt-go-redirect.php | 249 +++ inc/classes/class-aksrt-home-modules.php | 233 +++ inc/classes/class-aksrt-invite.php | 617 ++++++ inc/classes/class-aksrt-ip-geo.php | 217 ++ inc/classes/class-aksrt-meilisearch.php | 284 +++ inc/classes/class-aksrt-message.php | 219 ++ inc/classes/class-aksrt-moderation.php | 307 +++ .../class-aksrt-oauth-proxy-client.php | 316 +++ .../class-aksrt-oauth-proxy-server.php | 294 +++ inc/classes/class-aksrt-oauth.php | 422 ++++ inc/classes/class-aksrt-order-repository.php | 685 ++++++ inc/classes/class-aksrt-order.php | 146 ++ inc/classes/class-aksrt-payment-gateway.php | 429 ++++ inc/classes/class-aksrt-post-meta-box.php | 550 +++++ inc/classes/class-aksrt-private-message.php | 384 ++++ inc/classes/class-aksrt-product-review.php | 313 +++ inc/classes/class-aksrt-rebate.php | 179 ++ inc/classes/class-aksrt-report-center.php | 309 +++ inc/classes/class-aksrt-resource-meta-box.php | 464 +++++ inc/classes/class-aksrt-revenue.php | 118 ++ inc/classes/class-aksrt-scripts.php | 68 + inc/classes/class-aksrt-search-push.php | 237 +++ inc/classes/class-aksrt-seo-head.php | 384 ++++ .../class-aksrt-settings-migration.php | 262 +++ inc/classes/class-aksrt-settings.php | 1326 ++++++++++++ inc/classes/class-aksrt-setup.php | 383 ++++ inc/classes/class-aksrt-shipping.php | 373 ++++ inc/classes/class-aksrt-shop-term-meta.php | 402 ++++ inc/classes/class-aksrt-shop.php | 417 ++++ inc/classes/class-aksrt-signin.php | 244 +++ inc/classes/class-aksrt-site-notice.php | 151 ++ .../class-aksrt-sms-provider-aliyun.php | 113 + .../class-aksrt-sms-provider-custom.php | 169 ++ .../class-aksrt-sms-provider-factory.php | 69 + .../class-aksrt-sms-provider-huawei.php | 109 + .../class-aksrt-sms-provider-tencent.php | 141 ++ inc/classes/class-aksrt-sms-provider.php | 49 + .../class-aksrt-sms-signature-helper.php | 87 + inc/classes/class-aksrt-sms.php | 411 ++++ inc/classes/class-aksrt-stats-tracker.php | 146 ++ inc/classes/class-aksrt-templates.php | 145 ++ inc/classes/class-aksrt-term-meta.php | 288 +++ inc/classes/class-aksrt-update-checker.php | 182 ++ inc/classes/class-aksrt-user-center.php | 140 ++ .../class-aksrt-user-credits-admin.php | 265 +++ inc/classes/class-aksrt-user-home.php | 277 +++ .../class-aksrt-user-notifications.php | 595 ++++++ inc/classes/class-aksrt-user-restriction.php | 242 +++ inc/classes/class-aksrt-user.php | 1263 +++++++++++ inc/classes/class-aksrt-wechat-notify.php | 271 +++ inc/classes/class-aksrt-widgets-batch1.php | 564 +++++ inc/classes/class-aksrt-widgets-new.php | 467 +++++ inc/classes/class-aksrt-widgets-original.php | 518 +++++ inc/classes/class-aksrt-widgets.php | 726 +++++++ inc/classes/class-aksrt-withdrawal.php | 166 ++ .../trait-aksrt-db-migrator-part-1.php | 198 ++ .../trait-aksrt-db-migrator-part-2.php | 437 ++++ .../trait-aksrt-db-migrator-part-3.php | 39 + ...trait-aksrt-forum-board-service-part-1.php | 419 ++++ ...trait-aksrt-forum-board-service-part-2.php | 425 ++++ ...trait-aksrt-forum-board-service-part-3.php | 137 ++ .../forum/class-aksrt-forum-board-service.php | 399 ++++ .../class-aksrt-forum-comment-service.php | 583 ++++++ .../forum/class-aksrt-forum-draft-service.php | 409 ++++ .../class-aksrt-forum-moderation-service.php | 720 +++++++ .../class-aksrt-forum-permission-service.php | 416 ++++ .../forum/class-aksrt-forum-score-service.php | 430 ++++ .../forum/class-aksrt-forum-topic-service.php | 212 ++ .../forum/class-aksrt-forum-view-builder.php | 419 ++++ ...trait-aksrt-forum-topic-service-part-1.php | 413 ++++ ...trait-aksrt-forum-topic-service-part-2.php | 376 ++++ ...trait-aksrt-forum-topic-service-part-3.php | 440 ++++ ...trait-aksrt-forum-topic-service-part-4.php | 266 +++ .../trait-aksrt-forum-view-builder-part-1.php | 410 ++++ .../trait-aksrt-forum-view-builder-part-2.php | 110 + .../class-aksrt-custom-code-renderer.php | 64 + .../class-aksrt-inline-style-builder.php | 163 ++ .../class-aksrt-loading-overlay-renderer.php | 72 + .../gateways/class-aksrt-alipay-gateway.php | 197 ++ .../gateways/class-aksrt-paypal-gateway.php | 252 +++ .../gateways/class-aksrt-stripe-gateway.php | 290 +++ .../class-aksrt-wechatpay-gateway.php | 245 +++ .../trait-aksrt-moderation-part-1.php | 404 ++++ .../trait-aksrt-moderation-part-2.php | 163 ++ .../oauth-traits/trait-aksrt-oauth-part-1.php | 396 ++++ .../oauth-traits/trait-aksrt-oauth-part-2.php | 166 ++ .../trait-aksrt-scripts-part-1b.php | 1281 ++++++++++++ .../trait-aksrt-scripts-part-2.php | 274 +++ .../class-aksrt-settings-capabilities.php | 274 +++ ...t-settings-category-integration-groups.php | 152 ++ .../class-aksrt-settings-category-runtime.php | 186 ++ ...-aksrt-settings-credits-payment-groups.php | 200 ++ .../class-aksrt-settings-credits-runtime.php | 267 +++ ...ass-aksrt-settings-display-page-groups.php | 285 +++ .../class-aksrt-settings-display-runtime.php | 235 +++ .../class-aksrt-settings-domain-groups.php | 199 ++ ...class-aksrt-settings-extension-runtime.php | 215 ++ .../class-aksrt-settings-forum-shop.php | 345 +++ .../class-aksrt-settings-home-cms.php | 763 +++++++ .../class-aksrt-settings-integration.php | 621 ++++++ .../settings/class-aksrt-settings-mail.php | 118 ++ .../class-aksrt-settings-post-display.php | 373 ++++ .../class-aksrt-settings-theme-search.php | 515 +++++ .../class-aksrt-settings-user-runtime.php | 300 +++ .../trait-aksrt-settings-auth-reader.php | 211 ++ .../trait-aksrt-settings-homepage-reader.php | 270 +++ .../setup-traits/trait-aksrt-setup-part-1.php | 399 ++++ .../setup-traits/trait-aksrt-setup-part-2.php | 104 + .../trait-aksrt-shipping-part-1.php | 365 ++++ .../trait-aksrt-shipping-part-2.php | 146 ++ .../shop/class-aksrt-shop-address-service.php | 207 ++ .../shop/class-aksrt-shop-cart-service.php | 176 ++ .../class-aksrt-shop-checkout-service.php | 359 ++++ .../shop/class-aksrt-shop-product-service.php | 321 +++ .../shop-traits/trait-aksrt-shop-part-1.php | 386 ++++ .../shop-traits/trait-aksrt-shop-part-2.php | 430 ++++ .../shop-traits/trait-aksrt-shop-part-3.php | 341 +++ .../class-aksrt-announcement-widget.php | 109 + .../class-aksrt-cover-cards-widget.php | 129 ++ .../widgets/class-aksrt-forum-widgets.php | 251 +++ .../widgets/class-aksrt-home-widgets.php | 842 ++++++++ .../class-aksrt-lang-switch-widget.php | 65 + .../widgets/class-aksrt-shop-widgets.php | 410 ++++ .../widgets/class-aksrt-user-card-widget.php | 150 ++ .../class-aksrt-user-ranking-widget.php | 183 ++ .../class-aksrt-video-player-widget.php | 134 ++ .../widgets/class-aksrt-yiyan-widget.php | 125 ++ inc/core-hooks.php | 11 + inc/core-hooks/part-1.php | 362 ++++ inc/core-hooks/part-2.php | 491 +++++ inc/core-hooks/part-3.php | 202 ++ inc/helpers/aksrt-credits.php | 53 + inc/helpers/aksrt-domain-model.php | 423 ++++ inc/helpers/aksrt-flow.php | 93 + inc/helpers/aksrt-frontend-tags.php | 1062 ++++++++++ inc/helpers/aksrt-homepage.php | 1100 ++++++++++ inc/helpers/aksrt-i18n-switch.php | 149 ++ inc/helpers/aksrt-icons.php | 121 ++ inc/helpers/aksrt-pay.php | 629 ++++++ inc/helpers/aksrt-posts-renderer.php | 1026 +++++++++ inc/helpers/aksrt-resource.php | 579 +++++ inc/helpers/aksrt-shortcodes.php | 255 +++ inc/helpers/aksrt-slider.php | 217 ++ inc/helpers/aksrt-template-tags.php | 1348 ++++++++++++ inc/helpers/aksrt-transactions.php | 118 ++ inc/helpers/aksrt-user-dashboard.php | 99 + inc/helpers/aksrt-vip.php | 98 + inc/helpers/class-aksrt-nav-walker.php | 145 ++ inc/helpers/menu-decoration.php | 161 ++ index.php | 53 + page.php | 117 ++ screenshot.png | Bin 0 -> 1683222 bytes search.php | 267 +++ sidebar.php | 44 + single-aksrt_resource.php | 350 ++++ single.php | 463 ++++ slim-forum.php | 217 ++ style.css | 22 + templates/forum/forum-board.php | 297 +++ templates/forum/forum-compose.php | 409 ++++ templates/forum/forum-edit.php | 362 ++++ templates/forum/forum-plate-cat.php | 107 + templates/forum/forum-plate.php | 312 +++ templates/forum/forum-tag.php | 92 + templates/forum/forum-topic.php | 635 ++++++ templates/pages/page-archives.php | 68 + templates/pages/page-bind-account.php | 139 ++ templates/pages/page-cart.php | 281 +++ templates/pages/page-categories.php | 130 ++ templates/pages/page-documentnav.php | 167 ++ templates/pages/page-download.php | 220 ++ templates/pages/page-forums.php | 331 +++ templates/pages/page-friends.php | 61 + templates/pages/page-login.php | 11 + templates/pages/page-messages.php | 236 +++ templates/pages/page-newposts.php | 67 + templates/pages/page-orders.php | 253 +++ templates/pages/page-posts-navs.php | 59 + templates/pages/page-product-edit.php | 170 ++ templates/pages/page-product.php | 413 ++++ templates/pages/page-profile.php | 1419 +++++++++++++ templates/pages/page-register.php | 11 + templates/pages/page-reset-password.php | 172 ++ templates/pages/page-revenue.php | 127 ++ templates/pages/page-sign.php | 345 +++ templates/pages/page-special.php | 148 ++ templates/pages/page-submit.php | 292 +++ templates/pages/page-user-home.php | 556 +++++ templates/pages/page-verify-email.php | 149 ++ templates/parts/content-empty.php | 39 + templates/parts/content-gallery.php | 49 + templates/parts/content-post-card.php | 174 ++ .../parts/content-profile-comment-item.php | 50 + templates/parts/content-profile-list-item.php | 106 + templates/parts/docs/doc-chapter-nav.php | 70 + templates/parts/docs/doc-index-card.php | 55 + templates/parts/docs/doc-sidebar.php | 83 + templates/parts/docs/doc-toc.php | 41 + templates/parts/docs/doc-tree.php | 46 + templates/parts/download/download-hero.php | 68 + .../download/download-permission-card.php | 116 ++ templates/parts/home-layout.php | 10 + .../parts/home-modules/module-banner.php | 104 + .../home-modules/module-button_group.php | 41 + .../parts/home-modules/module-category.php | 72 + .../module-category_recommend_cards.php | 123 ++ .../parts/home-modules/module-data_grid.php | 43 + templates/parts/home-modules/module-faq.php | 39 + .../home-modules/module-friend_links.php | 47 + templates/parts/home-modules/module-hero.php | 332 +++ .../home-modules/module-highlight_cards.php | 52 + .../parts/home-modules/module-hotspot.php | 111 + templates/parts/home-modules/module-html.php | 29 + templates/parts/home-modules/module-more.php | 103 + .../home-modules/module-partner_marquee.php | 54 + .../home-modules/module-product_showcase.php | 54 + .../parts/home-modules/module-ranking.php | 109 + .../parts/home-modules/module-recent.php | 84 + .../parts/home-modules/module-spacing.php | 17 + templates/parts/home-modules/module-tabs.php | 108 + .../parts/home-modules/module-tag_cloud.php | 40 + .../parts/home-modules/module-text_title.php | 32 + templates/parts/home-modules/module-user.php | 85 + templates/parts/login-prompt.php | 40 + templates/parts/modal-auth.php | 255 +++ templates/parts/page-header.php | 93 + templates/parts/profile/part-1.php | 400 ++++ templates/parts/profile/part-2.php | 403 ++++ templates/parts/profile/part-3.php | 403 ++++ templates/parts/profile/part-4.php | 222 ++ templates/parts/resource-download-box.php | 280 +++ templates/parts/resource-meta-box.php | 122 ++ templates/parts/resource-related.php | 64 + templates/parts/single-author-bar.php | 106 + templates/parts/special/special-cover.php | 66 + templates/parts/special/special-grid.php | 46 + templates/parts/user-center/README.md | 1 + templates/parts/user-center/uc-comments.php | 81 + templates/parts/user-center/uc-credits.php | 59 + templates/parts/user-center/uc-dashboard.php | 98 + templates/parts/user-center/uc-favorites.php | 75 + templates/parts/user-center/uc-posts.php | 71 + templates/parts/user-home/README.md | 1 + templates/parts/user-home/uh-comments.php | 76 + templates/parts/user-home/uh-hero.php | 66 + templates/parts/user-home/uh-posts.php | 55 + templates/parts/user-home/uh-sidebar.php | 106 + templates/parts/user-shared-helpers.php | 58 + templates/parts/user/author-hero.php | 153 ++ templates/shop/product-cat.php | 94 + templates/shop/product-tag.php | 93 + templates/shortcodes/archive.php | 67 + templates/shortcodes/profile.php | 58 + uninstall.php | 77 + 595 files changed, 127705 insertions(+) create mode 100644 404.php create mode 100644 archive-aksrt_resource.php create mode 100644 archive.php create mode 100644 assets/admin/extract-naive-styles.mjs create mode 100644 assets/admin/index.html create mode 100644 assets/admin/package-lock.json create mode 100644 assets/admin/package.json create mode 100644 assets/admin/src/App.vue create mode 100644 assets/admin/src/api/index.js create mode 100644 assets/admin/src/api/modules/extensions.js create mode 100644 assets/admin/src/api/modules/global.js create mode 100644 assets/admin/src/api/modules/index.js create mode 100644 assets/admin/src/api/modules/page-display.js create mode 100644 assets/admin/src/api/modules/payment.js create mode 100644 assets/admin/src/api/modules/permissions.js create mode 100644 assets/admin/src/api/modules/schema.js create mode 100644 assets/admin/src/api/modules/tools.js create mode 100644 assets/admin/src/api/modules/users.js create mode 100644 assets/admin/src/components/DeferredPage.vue create mode 100644 assets/admin/src/components/EmptyState.vue create mode 100644 assets/admin/src/components/MobileTopBar.vue create mode 100644 assets/admin/src/components/OAuthProviderForm.vue create mode 100644 assets/admin/src/components/PageHeader.vue create mode 100644 assets/admin/src/components/captcha/AliyunConfig.vue create mode 100644 assets/admin/src/components/captcha/CloudflareConfig.vue create mode 100644 assets/admin/src/components/captcha/GeetestConfig.vue create mode 100644 assets/admin/src/components/captcha/TencentConfig.vue create mode 100644 assets/admin/src/components/form/AksArrayTable.vue create mode 100644 assets/admin/src/components/form/AksCodeEditor.vue create mode 100644 assets/admin/src/components/form/AksField.vue create mode 100644 assets/admin/src/components/form/AksFieldGroup.vue create mode 100644 assets/admin/src/components/form/AksFormRenderer.vue create mode 100644 assets/admin/src/components/form/AksMediaField.vue create mode 100644 assets/admin/src/components/form/AksRepeaterField.vue create mode 100644 assets/admin/src/components/form/FaIcon.vue create mode 100644 assets/admin/src/components/form/FaIconPicker.vue create mode 100644 assets/admin/src/components/homepage/ModuleCanvas.vue create mode 100644 assets/admin/src/components/homepage/ModuleInspector.vue create mode 100644 assets/admin/src/components/homepage/ModulePresetPicker.vue create mode 100644 assets/admin/src/composables/useDirtyState.js create mode 100644 assets/admin/src/composables/useSectionForm.js create mode 100644 assets/admin/src/constants/admin-sections.js create mode 100644 assets/admin/src/constants/sections/docs.js create mode 100644 assets/admin/src/constants/sections/extensions.js create mode 100644 assets/admin/src/constants/sections/forum.js create mode 100644 assets/admin/src/constants/sections/global.js create mode 100644 assets/admin/src/constants/sections/helpers.js create mode 100644 assets/admin/src/constants/sections/index.js create mode 100644 assets/admin/src/constants/sections/page-display.js create mode 100644 assets/admin/src/constants/sections/payment.js create mode 100644 assets/admin/src/constants/sections/permissions.js create mode 100644 assets/admin/src/constants/sections/post-list.js create mode 100644 assets/admin/src/constants/sections/shop.js create mode 100644 assets/admin/src/constants/sections/theme-info.js create mode 100644 assets/admin/src/constants/sections/tools.js create mode 100644 assets/admin/src/constants/sections/users.js create mode 100644 assets/admin/src/constants/settings-defaults.js create mode 100644 assets/admin/src/main.js create mode 100644 assets/admin/src/pages/CmsCredits.vue create mode 100644 assets/admin/src/pages/ResourceDownload.vue create mode 100644 assets/admin/src/pages/SchemaPage.vue create mode 100644 assets/admin/src/pages/SiteSettings.vue create mode 100644 assets/admin/src/pages/content/BannerManager.vue create mode 100644 assets/admin/src/pages/content/FloatingButtonsSettings.vue create mode 100644 assets/admin/src/pages/content/FooterSettings.vue create mode 100644 assets/admin/src/pages/content/FooterTabbarSettings.vue create mode 100644 assets/admin/src/pages/content/HeroManager.vue create mode 100644 assets/admin/src/pages/content/HomepageSettings.vue create mode 100644 assets/admin/src/pages/content/NotificationCenterSettings.vue create mode 100644 assets/admin/src/pages/docs/DocsPage.vue create mode 100644 assets/admin/src/pages/extensions/ExtensionAI.vue create mode 100644 assets/admin/src/pages/extensions/ExtensionDocsMode.vue create mode 100644 assets/admin/src/pages/extensions/ExtensionSecurity.vue create mode 100644 assets/admin/src/pages/extensions/ExtensionTools.vue create mode 100644 assets/admin/src/pages/extensions/ExtensionsSectionPage.vue create mode 100644 assets/admin/src/pages/forum/ForumBoards.vue create mode 100644 assets/admin/src/pages/forum/ForumDashboard.vue create mode 100644 assets/admin/src/pages/forum/ForumModeratorApplications.vue create mode 100644 assets/admin/src/pages/forum/ForumModerators.vue create mode 100644 assets/admin/src/pages/forum/ForumTopics.vue create mode 100644 assets/admin/src/pages/forum/components/TopicModerationDrawer.vue create mode 100644 assets/admin/src/pages/payment/PaymentAffiliate.vue create mode 100644 assets/admin/src/pages/payment/PaymentChargeCard.vue create mode 100644 assets/admin/src/pages/payment/PaymentGateway.vue create mode 100644 assets/admin/src/pages/payment/PaymentRebate.vue create mode 100644 assets/admin/src/pages/payment/PaymentRecharge.vue create mode 100644 assets/admin/src/pages/payment/PaymentRevenue.vue create mode 100644 assets/admin/src/pages/payment/PaymentSectionPage.vue create mode 100644 assets/admin/src/pages/payment/PaymentVip.vue create mode 100644 assets/admin/src/pages/payment/components/ChargeCardDetailDrawer.vue create mode 100644 assets/admin/src/pages/payment/components/RebateDetailDrawer.vue create mode 100644 assets/admin/src/pages/permissions/AttachmentPermissions.vue create mode 100644 assets/admin/src/pages/permissions/CommentPermissions.vue create mode 100644 assets/admin/src/pages/permissions/DownloadPermissions.vue create mode 100644 assets/admin/src/pages/permissions/PermissionsCapabilities.vue create mode 100644 assets/admin/src/pages/post-list/CommentSettings.vue create mode 100644 assets/admin/src/pages/post-list/PostPageSettings.vue create mode 100644 assets/admin/src/pages/shop/ShopAfterSale.vue create mode 100644 assets/admin/src/pages/shop/ShopCoupons.vue create mode 100644 assets/admin/src/pages/shop/ShopDashboard.vue create mode 100644 assets/admin/src/pages/shop/ShopOrders.vue create mode 100644 assets/admin/src/pages/shop/ShopProducts.vue create mode 100644 assets/admin/src/pages/shop/ShopShipping.vue create mode 100644 assets/admin/src/pages/shop/ShopWithdrawals.vue create mode 100644 assets/admin/src/pages/shop/components/AfterSaleDetailDrawer.vue create mode 100644 assets/admin/src/pages/shop/components/OrderContextPanel.vue create mode 100644 assets/admin/src/pages/shop/components/OrderDetailDrawer.vue create mode 100644 assets/admin/src/pages/shop/components/ProductEditorDrawer.vue create mode 100644 assets/admin/src/pages/shop/components/ShippingBatchActionDialog.vue create mode 100644 assets/admin/src/pages/shop/components/ShippingDetailDrawer.vue create mode 100644 assets/admin/src/pages/shop/components/WithdrawalReviewModal.vue create mode 100644 assets/admin/src/pages/theme/ThemeAuthorizationPage.vue create mode 100644 assets/admin/src/pages/theme/ThemeInfoPage.vue create mode 100644 assets/admin/src/pages/tools/ToolsBackup.vue create mode 100644 assets/admin/src/pages/tools/ToolsEmailRollback.vue create mode 100644 assets/admin/src/pages/tools/ToolsHealth.vue create mode 100644 assets/admin/src/pages/tools/ToolsImportExport.vue create mode 100644 assets/admin/src/pages/tools/ToolsMigration.vue create mode 100644 assets/admin/src/pages/tools/ToolsUpdate.vue create mode 100644 assets/admin/src/pages/users/UsersBadge.vue create mode 100644 assets/admin/src/pages/users/UsersBan.vue create mode 100644 assets/admin/src/pages/users/UsersFeatures.vue create mode 100644 assets/admin/src/pages/users/UsersInviteCodes.vue create mode 100644 assets/admin/src/pages/users/UsersLevel.vue create mode 100644 assets/admin/src/pages/users/UsersMessages.vue create mode 100644 assets/admin/src/pages/users/UsersReport.vue create mode 100644 assets/admin/src/pages/users/UsersSectionPage.vue create mode 100644 assets/admin/src/pages/users/UsersSignin.vue create mode 100644 assets/admin/src/pages/users/UsersVerify.vue create mode 100644 assets/admin/src/pages/users/UsersWechatTemplate.vue create mode 100644 assets/admin/src/pages/users/components/ReportDetailDrawer.vue create mode 100644 assets/admin/src/pages/users/components/VerifyReviewDrawer.vue create mode 100644 assets/admin/src/router/component-loaders/core.js create mode 100644 assets/admin/src/router/component-loaders/extensions.js create mode 100644 assets/admin/src/router/component-loaders/forum.js create mode 100644 assets/admin/src/router/component-loaders/index.js create mode 100644 assets/admin/src/router/component-loaders/payment.js create mode 100644 assets/admin/src/router/component-loaders/shop.js create mode 100644 assets/admin/src/router/component-loaders/tools.js create mode 100644 assets/admin/src/router/component-loaders/users.js create mode 100644 assets/admin/src/router/index.js create mode 100644 assets/admin/src/schema/index.js create mode 100644 assets/admin/src/schema/sections/extensions/audit.js create mode 100644 assets/admin/src/schema/sections/extensions/docs-mode.js create mode 100644 assets/admin/src/schema/sections/extensions/index.js create mode 100644 assets/admin/src/schema/sections/extensions/ip.js create mode 100644 assets/admin/src/schema/sections/extensions/push.js create mode 100644 assets/admin/src/schema/sections/forum/basic.js create mode 100644 assets/admin/src/schema/sections/forum/board-page.js create mode 100644 assets/admin/src/schema/sections/forum/credits.js create mode 100644 assets/admin/src/schema/sections/forum/homepage.js create mode 100644 assets/admin/src/schema/sections/forum/index.js create mode 100644 assets/admin/src/schema/sections/forum/names.js create mode 100644 assets/admin/src/schema/sections/forum/other.js create mode 100644 assets/admin/src/schema/sections/forum/permissions.js create mode 100644 assets/admin/src/schema/sections/forum/post-page.js create mode 100644 assets/admin/src/schema/sections/forum/reply.js create mode 100644 assets/admin/src/schema/sections/forum/upload.js create mode 100644 assets/admin/src/schema/sections/global/code-highlight.js create mode 100644 assets/admin/src/schema/sections/global/common.js create mode 100644 assets/admin/src/schema/sections/global/custom-code.js create mode 100644 assets/admin/src/schema/sections/global/email.js create mode 100644 assets/admin/src/schema/sections/global/floating.js create mode 100644 assets/admin/src/schema/sections/global/i18n.js create mode 100644 assets/admin/src/schema/sections/global/index.js create mode 100644 assets/admin/src/schema/sections/global/layout.js create mode 100644 assets/admin/src/schema/sections/global/logo.js create mode 100644 assets/admin/src/schema/sections/global/search.js create mode 100644 assets/admin/src/schema/sections/global/seo.js create mode 100644 assets/admin/src/schema/sections/global/system-notice.js create mode 100644 assets/admin/src/schema/sections/global/tabbar.js create mode 100644 assets/admin/src/schema/sections/page-display/index.js create mode 100644 assets/admin/src/schema/sections/payment/affiliate.js create mode 100644 assets/admin/src/schema/sections/payment/cardPass.js create mode 100644 assets/admin/src/schema/sections/payment/gateway.js create mode 100644 assets/admin/src/schema/sections/payment/index.js create mode 100644 assets/admin/src/schema/sections/payment/order.js create mode 100644 assets/admin/src/schema/sections/payment/paid.js create mode 100644 assets/admin/src/schema/sections/payment/recharge.js create mode 100644 assets/admin/src/schema/sections/payment/revenue.js create mode 100644 assets/admin/src/schema/sections/payment/vip.js create mode 100644 assets/admin/src/schema/sections/payment/withdrawal.js create mode 100644 assets/admin/src/schema/sections/permissions/index.js create mode 100644 assets/admin/src/schema/sections/post-list/comment.js create mode 100644 assets/admin/src/schema/sections/post-list/feature.js create mode 100644 assets/admin/src/schema/sections/post-list/filter.js create mode 100644 assets/admin/src/schema/sections/post-list/index.js create mode 100644 assets/admin/src/schema/sections/post-list/list.js create mode 100644 assets/admin/src/schema/sections/post-list/page.js create mode 100644 assets/admin/src/schema/sections/post-list/pay.js create mode 100644 assets/admin/src/schema/sections/post-list/share.js create mode 100644 assets/admin/src/schema/sections/post-list/submit.js create mode 100644 assets/admin/src/schema/sections/post-list/thumb.js create mode 100644 assets/admin/src/schema/sections/shop/basic.js create mode 100644 assets/admin/src/schema/sections/shop/contact.js create mode 100644 assets/admin/src/schema/sections/shop/index.js create mode 100644 assets/admin/src/schema/sections/shop/list.js create mode 100644 assets/admin/src/schema/sections/shop/product-defaults.js create mode 100644 assets/admin/src/schema/sections/shop/shipping-config.js create mode 100644 assets/admin/src/schema/sections/shop/single.js create mode 100644 assets/admin/src/schema/sections/shop/timing.js create mode 100644 assets/admin/src/schema/sections/tools/backup.js create mode 100644 assets/admin/src/schema/sections/tools/email-rollback.js create mode 100644 assets/admin/src/schema/sections/tools/health.js create mode 100644 assets/admin/src/schema/sections/tools/import-export.js create mode 100644 assets/admin/src/schema/sections/tools/index.js create mode 100644 assets/admin/src/schema/sections/tools/migration.js create mode 100644 assets/admin/src/schema/sections/users/auth.js create mode 100644 assets/admin/src/schema/sections/users/badge.js create mode 100644 assets/admin/src/schema/sections/users/credits.js create mode 100644 assets/admin/src/schema/sections/users/features.js create mode 100644 assets/admin/src/schema/sections/users/index.js create mode 100644 assets/admin/src/schema/sections/users/invite.js create mode 100644 assets/admin/src/schema/sections/users/level.js create mode 100644 assets/admin/src/schema/sections/users/notifications.js create mode 100644 assets/admin/src/schema/sections/users/oauth.js create mode 100644 assets/admin/src/schema/sections/users/report.js create mode 100644 assets/admin/src/schema/sections/users/signin.js create mode 100644 assets/admin/src/schema/sections/users/sms.js create mode 100644 assets/admin/src/schema/sections/users/verify.js create mode 100644 assets/admin/src/schema/sections/users/wechat.js create mode 100644 assets/admin/src/stores/admin-ui.js create mode 100644 assets/admin/src/style-target.js create mode 100644 assets/admin/src/style.css create mode 100644 assets/admin/src/utils/home-module-schema.js create mode 100644 assets/admin/src/utils/media.js create mode 100644 assets/admin/tsconfig.json create mode 100644 assets/admin/vite.config.ts create mode 100644 author.php create mode 100644 comments.php create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 footer.php create mode 100644 front-page.php create mode 100644 functions.php create mode 100644 go.php create mode 100644 header.php create mode 100644 home.php create mode 100644 image.php create mode 100644 inc/bootstrap.php create mode 100644 inc/classes/admin/class-aksrt-admin-ai-controller.php create mode 100644 inc/classes/admin/class-aksrt-admin-comment-controller.php create mode 100644 inc/classes/admin/class-aksrt-admin-controller-base.php create mode 100644 inc/classes/admin/class-aksrt-admin-extension-controller.php create mode 100644 inc/classes/admin/class-aksrt-admin-forum-controller.php create mode 100644 inc/classes/admin/class-aksrt-admin-global-controller.php create mode 100644 inc/classes/admin/class-aksrt-admin-import-export-controller.php create mode 100644 inc/classes/admin/class-aksrt-admin-message-controller.php create mode 100644 inc/classes/admin/class-aksrt-admin-page-display-controller.php create mode 100644 inc/classes/admin/class-aksrt-admin-payment-controller.php create mode 100644 inc/classes/admin/class-aksrt-admin-permissions-controller.php create mode 100644 inc/classes/admin/class-aksrt-admin-post-list-controller.php create mode 100644 inc/classes/admin/class-aksrt-admin-schema-provider.php create mode 100644 inc/classes/admin/class-aksrt-admin-shop-controller-coupon-gateway-operations.php create mode 100644 inc/classes/admin/class-aksrt-admin-shop-controller-frontend-flow-operations.php create mode 100644 inc/classes/admin/class-aksrt-admin-shop-controller-fulfillment-operations.php create mode 100644 inc/classes/admin/class-aksrt-admin-shop-controller-order-operations.php create mode 100644 inc/classes/admin/class-aksrt-admin-shop-controller-product-admin-columns.php create mode 100644 inc/classes/admin/class-aksrt-admin-shop-controller-product-operations.php create mode 100644 inc/classes/admin/class-aksrt-admin-shop-controller-variant-export-operations.php create mode 100644 inc/classes/admin/class-aksrt-admin-shop-controller.php create mode 100644 inc/classes/admin/class-aksrt-admin-tools-controller.php create mode 100644 inc/classes/admin/class-aksrt-admin-user-controller-auth-phone-operations.php create mode 100644 inc/classes/admin/class-aksrt-admin-user-controller-follow-operations.php create mode 100644 inc/classes/admin/class-aksrt-admin-user-controller-invite-operations.php create mode 100644 inc/classes/admin/class-aksrt-admin-user-controller-report-operations.php create mode 100644 inc/classes/admin/class-aksrt-admin-user-controller-restriction-operations.php create mode 100644 inc/classes/admin/class-aksrt-admin-user-controller-verify-integrations.php create mode 100644 inc/classes/admin/class-aksrt-admin-user-controller.php create mode 100644 inc/classes/admin/forum-ctrl-traits/trait-aksrt-admin-forum-controller-part-1.php create mode 100644 inc/classes/admin/forum-ctrl-traits/trait-aksrt-admin-forum-controller-part-2.php create mode 100644 inc/classes/admin/global-ctrl-traits/trait-aksrt-admin-global-controller-part-1.php create mode 100644 inc/classes/admin/global-ctrl-traits/trait-aksrt-admin-global-controller-part-2.php create mode 100644 inc/classes/admin/import-export-traits/trait-aksrt-admin-import-export-controller-part-1.php create mode 100644 inc/classes/admin/import-export-traits/trait-aksrt-admin-import-export-controller-part-2.php create mode 100644 inc/classes/admin/schema/class-aksrt-admin-schema-extensions-audit.php create mode 100644 inc/classes/admin/schema/class-aksrt-admin-schema-extensions-docs-mode.php create mode 100644 inc/classes/admin/schema/class-aksrt-admin-schema-extensions-ip.php create mode 100644 inc/classes/admin/schema/class-aksrt-admin-schema-extensions-push.php create mode 100644 inc/classes/admin/schema/class-aksrt-admin-schema-extensions-security.php create mode 100644 inc/classes/admin/schema/class-aksrt-admin-schema-extensions.php create mode 100644 inc/classes/admin/schema/class-aksrt-admin-schema-forum-basic.php create mode 100644 inc/classes/admin/schema/class-aksrt-admin-schema-forum-board-page.php create mode 100644 inc/classes/admin/schema/class-aksrt-admin-schema-forum-credits.php create mode 100644 inc/classes/admin/schema/class-aksrt-admin-schema-forum-homepage.php create mode 100644 inc/classes/admin/schema/class-aksrt-admin-schema-forum-names.php create mode 100644 inc/classes/admin/schema/class-aksrt-admin-schema-forum-other.php create mode 100644 inc/classes/admin/schema/class-aksrt-admin-schema-forum-permissions.php create mode 100644 inc/classes/admin/schema/class-aksrt-admin-schema-forum-post-page.php create mode 100644 inc/classes/admin/schema/class-aksrt-admin-schema-forum-reply.php create mode 100644 inc/classes/admin/schema/class-aksrt-admin-schema-forum-upload.php create mode 100644 inc/classes/admin/schema/class-aksrt-admin-schema-forum.php create mode 100644 inc/classes/admin/schema/class-aksrt-admin-schema-global.php create mode 100644 inc/classes/admin/schema/class-aksrt-admin-schema-page-display.php create mode 100644 inc/classes/admin/schema/class-aksrt-admin-schema-payment.php create mode 100644 inc/classes/admin/schema/class-aksrt-admin-schema-permissions.php create mode 100644 inc/classes/admin/schema/class-aksrt-admin-schema-post-list.php create mode 100644 inc/classes/admin/schema/class-aksrt-admin-schema-shop-basic.php create mode 100644 inc/classes/admin/schema/class-aksrt-admin-schema-shop-contact.php create mode 100644 inc/classes/admin/schema/class-aksrt-admin-schema-shop-list.php create mode 100644 inc/classes/admin/schema/class-aksrt-admin-schema-shop-product-defaults.php create mode 100644 inc/classes/admin/schema/class-aksrt-admin-schema-shop-shipping-config.php create mode 100644 inc/classes/admin/schema/class-aksrt-admin-schema-shop-single.php create mode 100644 inc/classes/admin/schema/class-aksrt-admin-schema-shop-timing.php create mode 100644 inc/classes/admin/schema/class-aksrt-admin-schema-shop.php create mode 100644 inc/classes/admin/schema/class-aksrt-admin-schema-tools-backup.php create mode 100644 inc/classes/admin/schema/class-aksrt-admin-schema-tools-email-rollback.php create mode 100644 inc/classes/admin/schema/class-aksrt-admin-schema-tools-health.php create mode 100644 inc/classes/admin/schema/class-aksrt-admin-schema-tools-import-export.php create mode 100644 inc/classes/admin/schema/class-aksrt-admin-schema-tools-migration.php create mode 100644 inc/classes/admin/schema/class-aksrt-admin-schema-tools.php create mode 100644 inc/classes/admin/schema/class-aksrt-admin-schema-users.php create mode 100644 inc/classes/auth-traits/trait-aksrt-auth-service-part-1a.php create mode 100644 inc/classes/auth-traits/trait-aksrt-auth-service-part-1b.php create mode 100644 inc/classes/auth/class-aksrt-auth-email-verification.php create mode 100644 inc/classes/class-aksrt-admin.php create mode 100644 inc/classes/class-aksrt-after-sale.php create mode 100644 inc/classes/class-aksrt-ai-prompt.php create mode 100644 inc/classes/class-aksrt-ai-seo.php create mode 100644 inc/classes/class-aksrt-ai.php create mode 100644 inc/classes/class-aksrt-api-audit.php create mode 100644 inc/classes/class-aksrt-auth-router.php create mode 100644 inc/classes/class-aksrt-auth-service.php create mode 100644 inc/classes/class-aksrt-captcha.php create mode 100644 inc/classes/class-aksrt-category-rewrite.php create mode 100644 inc/classes/class-aksrt-charge-card.php create mode 100644 inc/classes/class-aksrt-comment-actions.php create mode 100644 inc/classes/class-aksrt-content-audit.php create mode 100644 inc/classes/class-aksrt-content-visibility.php create mode 100644 inc/classes/class-aksrt-coupon.php create mode 100644 inc/classes/class-aksrt-credits-earn.php create mode 100644 inc/classes/class-aksrt-credits.php create mode 100644 inc/classes/class-aksrt-db-migrator.php create mode 100644 inc/classes/class-aksrt-discount.php create mode 100644 inc/classes/class-aksrt-docs-presenter.php create mode 100644 inc/classes/class-aksrt-download-state.php create mode 100644 inc/classes/class-aksrt-download.php create mode 100644 inc/classes/class-aksrt-email-rollback.php create mode 100644 inc/classes/class-aksrt-follow.php create mode 100644 inc/classes/class-aksrt-forum.php create mode 100644 inc/classes/class-aksrt-front-submit.php create mode 100644 inc/classes/class-aksrt-go-redirect.php create mode 100644 inc/classes/class-aksrt-home-modules.php create mode 100644 inc/classes/class-aksrt-invite.php create mode 100644 inc/classes/class-aksrt-ip-geo.php create mode 100644 inc/classes/class-aksrt-meilisearch.php create mode 100644 inc/classes/class-aksrt-message.php create mode 100644 inc/classes/class-aksrt-moderation.php create mode 100644 inc/classes/class-aksrt-oauth-proxy-client.php create mode 100644 inc/classes/class-aksrt-oauth-proxy-server.php create mode 100644 inc/classes/class-aksrt-oauth.php create mode 100644 inc/classes/class-aksrt-order-repository.php create mode 100644 inc/classes/class-aksrt-order.php create mode 100644 inc/classes/class-aksrt-payment-gateway.php create mode 100644 inc/classes/class-aksrt-post-meta-box.php create mode 100644 inc/classes/class-aksrt-private-message.php create mode 100644 inc/classes/class-aksrt-product-review.php create mode 100644 inc/classes/class-aksrt-rebate.php create mode 100644 inc/classes/class-aksrt-report-center.php create mode 100644 inc/classes/class-aksrt-resource-meta-box.php create mode 100644 inc/classes/class-aksrt-revenue.php create mode 100644 inc/classes/class-aksrt-scripts.php create mode 100644 inc/classes/class-aksrt-search-push.php create mode 100644 inc/classes/class-aksrt-seo-head.php create mode 100644 inc/classes/class-aksrt-settings-migration.php create mode 100644 inc/classes/class-aksrt-settings.php create mode 100644 inc/classes/class-aksrt-setup.php create mode 100644 inc/classes/class-aksrt-shipping.php create mode 100644 inc/classes/class-aksrt-shop-term-meta.php create mode 100644 inc/classes/class-aksrt-shop.php create mode 100644 inc/classes/class-aksrt-signin.php create mode 100644 inc/classes/class-aksrt-site-notice.php create mode 100644 inc/classes/class-aksrt-sms-provider-aliyun.php create mode 100644 inc/classes/class-aksrt-sms-provider-custom.php create mode 100644 inc/classes/class-aksrt-sms-provider-factory.php create mode 100644 inc/classes/class-aksrt-sms-provider-huawei.php create mode 100644 inc/classes/class-aksrt-sms-provider-tencent.php create mode 100644 inc/classes/class-aksrt-sms-provider.php create mode 100644 inc/classes/class-aksrt-sms-signature-helper.php create mode 100644 inc/classes/class-aksrt-sms.php create mode 100644 inc/classes/class-aksrt-stats-tracker.php create mode 100644 inc/classes/class-aksrt-templates.php create mode 100644 inc/classes/class-aksrt-term-meta.php create mode 100644 inc/classes/class-aksrt-update-checker.php create mode 100644 inc/classes/class-aksrt-user-center.php create mode 100644 inc/classes/class-aksrt-user-credits-admin.php create mode 100644 inc/classes/class-aksrt-user-home.php create mode 100644 inc/classes/class-aksrt-user-notifications.php create mode 100644 inc/classes/class-aksrt-user-restriction.php create mode 100644 inc/classes/class-aksrt-user.php create mode 100644 inc/classes/class-aksrt-wechat-notify.php create mode 100644 inc/classes/class-aksrt-widgets-batch1.php create mode 100644 inc/classes/class-aksrt-widgets-new.php create mode 100644 inc/classes/class-aksrt-widgets-original.php create mode 100644 inc/classes/class-aksrt-widgets.php create mode 100644 inc/classes/class-aksrt-withdrawal.php create mode 100644 inc/classes/db-traits/trait-aksrt-db-migrator-part-1.php create mode 100644 inc/classes/db-traits/trait-aksrt-db-migrator-part-2.php create mode 100644 inc/classes/db-traits/trait-aksrt-db-migrator-part-3.php create mode 100644 inc/classes/forum/board-service-traits/trait-aksrt-forum-board-service-part-1.php create mode 100644 inc/classes/forum/board-service-traits/trait-aksrt-forum-board-service-part-2.php create mode 100644 inc/classes/forum/board-service-traits/trait-aksrt-forum-board-service-part-3.php create mode 100644 inc/classes/forum/class-aksrt-forum-board-service.php create mode 100644 inc/classes/forum/class-aksrt-forum-comment-service.php create mode 100644 inc/classes/forum/class-aksrt-forum-draft-service.php create mode 100644 inc/classes/forum/class-aksrt-forum-moderation-service.php create mode 100644 inc/classes/forum/class-aksrt-forum-permission-service.php create mode 100644 inc/classes/forum/class-aksrt-forum-score-service.php create mode 100644 inc/classes/forum/class-aksrt-forum-topic-service.php create mode 100644 inc/classes/forum/class-aksrt-forum-view-builder.php create mode 100644 inc/classes/forum/topic-service-traits/trait-aksrt-forum-topic-service-part-1.php create mode 100644 inc/classes/forum/topic-service-traits/trait-aksrt-forum-topic-service-part-2.php create mode 100644 inc/classes/forum/topic-service-traits/trait-aksrt-forum-topic-service-part-3.php create mode 100644 inc/classes/forum/topic-service-traits/trait-aksrt-forum-topic-service-part-4.php create mode 100644 inc/classes/forum/view-builder-traits/trait-aksrt-forum-view-builder-part-1.php create mode 100644 inc/classes/forum/view-builder-traits/trait-aksrt-forum-view-builder-part-2.php create mode 100644 inc/classes/frontend/class-aksrt-custom-code-renderer.php create mode 100644 inc/classes/frontend/class-aksrt-inline-style-builder.php create mode 100644 inc/classes/frontend/class-aksrt-loading-overlay-renderer.php create mode 100644 inc/classes/gateways/class-aksrt-alipay-gateway.php create mode 100644 inc/classes/gateways/class-aksrt-paypal-gateway.php create mode 100644 inc/classes/gateways/class-aksrt-stripe-gateway.php create mode 100644 inc/classes/gateways/class-aksrt-wechatpay-gateway.php create mode 100644 inc/classes/moderation-traits/trait-aksrt-moderation-part-1.php create mode 100644 inc/classes/moderation-traits/trait-aksrt-moderation-part-2.php create mode 100644 inc/classes/oauth-traits/trait-aksrt-oauth-part-1.php create mode 100644 inc/classes/oauth-traits/trait-aksrt-oauth-part-2.php create mode 100644 inc/classes/scripts-traits/trait-aksrt-scripts-part-1b.php create mode 100644 inc/classes/scripts-traits/trait-aksrt-scripts-part-2.php create mode 100644 inc/classes/settings/class-aksrt-settings-capabilities.php create mode 100644 inc/classes/settings/class-aksrt-settings-category-integration-groups.php create mode 100644 inc/classes/settings/class-aksrt-settings-category-runtime.php create mode 100644 inc/classes/settings/class-aksrt-settings-credits-payment-groups.php create mode 100644 inc/classes/settings/class-aksrt-settings-credits-runtime.php create mode 100644 inc/classes/settings/class-aksrt-settings-display-page-groups.php create mode 100644 inc/classes/settings/class-aksrt-settings-display-runtime.php create mode 100644 inc/classes/settings/class-aksrt-settings-domain-groups.php create mode 100644 inc/classes/settings/class-aksrt-settings-extension-runtime.php create mode 100644 inc/classes/settings/class-aksrt-settings-forum-shop.php create mode 100644 inc/classes/settings/class-aksrt-settings-home-cms.php create mode 100644 inc/classes/settings/class-aksrt-settings-integration.php create mode 100644 inc/classes/settings/class-aksrt-settings-mail.php create mode 100644 inc/classes/settings/class-aksrt-settings-post-display.php create mode 100644 inc/classes/settings/class-aksrt-settings-theme-search.php create mode 100644 inc/classes/settings/class-aksrt-settings-user-runtime.php create mode 100644 inc/classes/settings/trait-aksrt-settings-auth-reader.php create mode 100644 inc/classes/settings/trait-aksrt-settings-homepage-reader.php create mode 100644 inc/classes/setup-traits/trait-aksrt-setup-part-1.php create mode 100644 inc/classes/setup-traits/trait-aksrt-setup-part-2.php create mode 100644 inc/classes/shipping-traits/trait-aksrt-shipping-part-1.php create mode 100644 inc/classes/shipping-traits/trait-aksrt-shipping-part-2.php create mode 100644 inc/classes/shop/class-aksrt-shop-address-service.php create mode 100644 inc/classes/shop/class-aksrt-shop-cart-service.php create mode 100644 inc/classes/shop/class-aksrt-shop-checkout-service.php create mode 100644 inc/classes/shop/class-aksrt-shop-product-service.php create mode 100644 inc/classes/shop/shop-traits/trait-aksrt-shop-part-1.php create mode 100644 inc/classes/shop/shop-traits/trait-aksrt-shop-part-2.php create mode 100644 inc/classes/shop/shop-traits/trait-aksrt-shop-part-3.php create mode 100644 inc/classes/widgets/class-aksrt-announcement-widget.php create mode 100644 inc/classes/widgets/class-aksrt-cover-cards-widget.php create mode 100644 inc/classes/widgets/class-aksrt-forum-widgets.php create mode 100644 inc/classes/widgets/class-aksrt-home-widgets.php create mode 100644 inc/classes/widgets/class-aksrt-lang-switch-widget.php create mode 100644 inc/classes/widgets/class-aksrt-shop-widgets.php create mode 100644 inc/classes/widgets/class-aksrt-user-card-widget.php create mode 100644 inc/classes/widgets/class-aksrt-user-ranking-widget.php create mode 100644 inc/classes/widgets/class-aksrt-video-player-widget.php create mode 100644 inc/classes/widgets/class-aksrt-yiyan-widget.php create mode 100644 inc/core-hooks.php create mode 100644 inc/core-hooks/part-1.php create mode 100644 inc/core-hooks/part-2.php create mode 100644 inc/core-hooks/part-3.php create mode 100644 inc/helpers/aksrt-credits.php create mode 100644 inc/helpers/aksrt-domain-model.php create mode 100644 inc/helpers/aksrt-flow.php create mode 100644 inc/helpers/aksrt-frontend-tags.php create mode 100644 inc/helpers/aksrt-homepage.php create mode 100644 inc/helpers/aksrt-i18n-switch.php create mode 100644 inc/helpers/aksrt-icons.php create mode 100644 inc/helpers/aksrt-pay.php create mode 100644 inc/helpers/aksrt-posts-renderer.php create mode 100644 inc/helpers/aksrt-resource.php create mode 100644 inc/helpers/aksrt-shortcodes.php create mode 100644 inc/helpers/aksrt-slider.php create mode 100644 inc/helpers/aksrt-template-tags.php create mode 100644 inc/helpers/aksrt-transactions.php create mode 100644 inc/helpers/aksrt-user-dashboard.php create mode 100644 inc/helpers/aksrt-vip.php create mode 100644 inc/helpers/class-aksrt-nav-walker.php create mode 100644 inc/helpers/menu-decoration.php create mode 100644 index.php create mode 100644 page.php create mode 100644 screenshot.png create mode 100644 search.php create mode 100644 sidebar.php create mode 100644 single-aksrt_resource.php create mode 100644 single.php create mode 100644 slim-forum.php create mode 100644 style.css create mode 100644 templates/forum/forum-board.php create mode 100644 templates/forum/forum-compose.php create mode 100644 templates/forum/forum-edit.php create mode 100644 templates/forum/forum-plate-cat.php create mode 100644 templates/forum/forum-plate.php create mode 100644 templates/forum/forum-tag.php create mode 100644 templates/forum/forum-topic.php create mode 100644 templates/pages/page-archives.php create mode 100644 templates/pages/page-bind-account.php create mode 100644 templates/pages/page-cart.php create mode 100644 templates/pages/page-categories.php create mode 100644 templates/pages/page-documentnav.php create mode 100644 templates/pages/page-download.php create mode 100644 templates/pages/page-forums.php create mode 100644 templates/pages/page-friends.php create mode 100644 templates/pages/page-login.php create mode 100644 templates/pages/page-messages.php create mode 100644 templates/pages/page-newposts.php create mode 100644 templates/pages/page-orders.php create mode 100644 templates/pages/page-posts-navs.php create mode 100644 templates/pages/page-product-edit.php create mode 100644 templates/pages/page-product.php create mode 100644 templates/pages/page-profile.php create mode 100644 templates/pages/page-register.php create mode 100644 templates/pages/page-reset-password.php create mode 100644 templates/pages/page-revenue.php create mode 100644 templates/pages/page-sign.php create mode 100644 templates/pages/page-special.php create mode 100644 templates/pages/page-submit.php create mode 100644 templates/pages/page-user-home.php create mode 100644 templates/pages/page-verify-email.php create mode 100644 templates/parts/content-empty.php create mode 100644 templates/parts/content-gallery.php create mode 100644 templates/parts/content-post-card.php create mode 100644 templates/parts/content-profile-comment-item.php create mode 100644 templates/parts/content-profile-list-item.php create mode 100644 templates/parts/docs/doc-chapter-nav.php create mode 100644 templates/parts/docs/doc-index-card.php create mode 100644 templates/parts/docs/doc-sidebar.php create mode 100644 templates/parts/docs/doc-toc.php create mode 100644 templates/parts/docs/doc-tree.php create mode 100644 templates/parts/download/download-hero.php create mode 100644 templates/parts/download/download-permission-card.php create mode 100644 templates/parts/home-layout.php create mode 100644 templates/parts/home-modules/module-banner.php create mode 100644 templates/parts/home-modules/module-button_group.php create mode 100644 templates/parts/home-modules/module-category.php create mode 100644 templates/parts/home-modules/module-category_recommend_cards.php create mode 100644 templates/parts/home-modules/module-data_grid.php create mode 100644 templates/parts/home-modules/module-faq.php create mode 100644 templates/parts/home-modules/module-friend_links.php create mode 100644 templates/parts/home-modules/module-hero.php create mode 100644 templates/parts/home-modules/module-highlight_cards.php create mode 100644 templates/parts/home-modules/module-hotspot.php create mode 100644 templates/parts/home-modules/module-html.php create mode 100644 templates/parts/home-modules/module-more.php create mode 100644 templates/parts/home-modules/module-partner_marquee.php create mode 100644 templates/parts/home-modules/module-product_showcase.php create mode 100644 templates/parts/home-modules/module-ranking.php create mode 100644 templates/parts/home-modules/module-recent.php create mode 100644 templates/parts/home-modules/module-spacing.php create mode 100644 templates/parts/home-modules/module-tabs.php create mode 100644 templates/parts/home-modules/module-tag_cloud.php create mode 100644 templates/parts/home-modules/module-text_title.php create mode 100644 templates/parts/home-modules/module-user.php create mode 100644 templates/parts/login-prompt.php create mode 100644 templates/parts/modal-auth.php create mode 100644 templates/parts/page-header.php create mode 100644 templates/parts/profile/part-1.php create mode 100644 templates/parts/profile/part-2.php create mode 100644 templates/parts/profile/part-3.php create mode 100644 templates/parts/profile/part-4.php create mode 100644 templates/parts/resource-download-box.php create mode 100644 templates/parts/resource-meta-box.php create mode 100644 templates/parts/resource-related.php create mode 100644 templates/parts/single-author-bar.php create mode 100644 templates/parts/special/special-cover.php create mode 100644 templates/parts/special/special-grid.php create mode 100644 templates/parts/user-center/README.md create mode 100644 templates/parts/user-center/uc-comments.php create mode 100644 templates/parts/user-center/uc-credits.php create mode 100644 templates/parts/user-center/uc-dashboard.php create mode 100644 templates/parts/user-center/uc-favorites.php create mode 100644 templates/parts/user-center/uc-posts.php create mode 100644 templates/parts/user-home/README.md create mode 100644 templates/parts/user-home/uh-comments.php create mode 100644 templates/parts/user-home/uh-hero.php create mode 100644 templates/parts/user-home/uh-posts.php create mode 100644 templates/parts/user-home/uh-sidebar.php create mode 100644 templates/parts/user-shared-helpers.php create mode 100644 templates/parts/user/author-hero.php create mode 100644 templates/shop/product-cat.php create mode 100644 templates/shop/product-tag.php create mode 100644 templates/shortcodes/archive.php create mode 100644 templates/shortcodes/profile.php create mode 100644 uninstall.php diff --git a/404.php b/404.php new file mode 100644 index 00000000..d7e03501 --- /dev/null +++ b/404.php @@ -0,0 +1,107 @@ + __('页面未找到', 'aksrt'), + 'page_404_search' => true, + 'page_404_popular' => true, + ]; +$search_placeholder = function_exists('aksrt_get_search_placeholder') ? aksrt_get_search_placeholder() : __('开启精彩搜索', 'aksrt'); +$hot_keywords = function_exists('aksrt_get_search_hot_keywords') ? aksrt_get_search_hot_keywords() : []; + +$hot_query = new WP_Query([ + 'post_type' => 'post', + 'posts_per_page' => 5, + 'post_status' => 'publish', + 'orderby' => 'comment_count', + 'order' => 'DESC', + 'no_found_rows' => true, + 'ignore_sticky_posts' => true, +]); + +$top_cats = get_terms([ + 'taxonomy' => 'category', + 'orderby' => 'count', + 'order' => 'DESC', + 'number' => 6, + 'hide_empty' => true, +]); +?> + +
+
+
+
+ +

404

+

+

+ +

+
+ +
+ + + + + + +
+ + +
+
+ + +
+

+ +
+ + + have_posts()) : ?> +
+

+
    + have_posts()) : $hot_query->the_post(); ?> +
  • + + + + +
  • + +
+
+ +
+ + __('最新', 'aksrt'), + 'downloads' => __('热门下载', 'aksrt'), + 'views' => __('阅读最多', 'aksrt'), +]; + +$price_options = [ + 'all' => __('全部', 'aksrt'), + 'free' => __('免费', 'aksrt'), + 'paid' => __('付费', 'aksrt'), +]; + +// 给筛选链接保留其它已有 query +$preserve = []; +if ($current_orderby !== 'date') $preserve['orderby'] = $current_orderby; +if ($current_price !== 'all') $preserve['price'] = $current_price; +if ($price_min !== '' && $price_min > 0) $preserve['price_min'] = $price_min; +if ($price_max !== '') $preserve['price_max'] = $price_max; + +$has_filter = $current_orderby !== 'date' || $current_price !== 'all' || $price_min !== '' || $price_max !== ''; +?> + +
+
+
+ + + + +

+

+ found_posts))); + ?> +

+
+
+ +
+
+
+ + + +
+ +
+ + + +
+

+ +

+ +
+ +
+
+
+
+ + 'card', 'card_style' => 'standard']; +$render_config = [ + 'context' => 'archive', + 'mode' => (string) ($list_settings['render_mode'] ?? 'card'), + 'card_style' => (string) ($list_settings['card_style'] ?? 'standard'), +]; + +// Per-category card mode override: if visiting a category in card_cat_ids, force card mode +if (is_category() && !empty($list_settings['card_cat_ids'])) { + $current_cat_id = (int) get_queried_object_id(); + if (in_array($current_cat_id, $list_settings['card_cat_ids'], true)) { + $render_config['mode'] = 'card'; + } +} + +// Per-category multi-thumb override +if (is_category() && class_exists('AKSRT_Settings')) { + $thumb_settings = AKSRT_Settings::get_post_thumb_settings(); + if (!empty($thumb_settings['mult_thumb_cat_ids'])) { + $current_cat_id = (int) get_queried_object_id(); + if (in_array($current_cat_id, $thumb_settings['mult_thumb_cat_ids'], true)) { + $render_config['multi_thumb_enabled'] = true; + $render_config['multi_thumb_limit'] = (int) ($thumb_settings['multi_count'] ?? 4); + } + } +} + +$has_sidebar = function_exists('aksrt_is_show_sidebar') ? aksrt_is_show_sidebar() : false; +$archive_description = trim(strip_tags((string) get_the_archive_description())); +$archive_summary = ''; + +if (is_author()) { + $author = get_queried_object(); + if ($author instanceof WP_User) { + $archive_summary = sprintf( + /* translators: %s: author display name */ + __('作者:%s', 'aksrt'), + $author->display_name + ); + } +} elseif ($archive_description !== '') { + $archive_summary = $archive_description; +} elseif (isset($wp_query->found_posts)) { + $archive_summary = sprintf( + esc_html(_n('共 %d 篇内容', '共 %d 篇内容', (int) $wp_query->found_posts, 'aksrt')), + (int) $wp_query->found_posts + ); +} +?> + +
+
+
+ + + + + + 'archive', 'render_config' => $render_config]); ?> + +
+

+
+ +
+ + + + +
+
+ + { + console.error('[AKSRT] 样式提取失败:', err) + process.exit(1) +}) diff --git a/assets/admin/index.html b/assets/admin/index.html new file mode 100644 index 00000000..7418cf13 --- /dev/null +++ b/assets/admin/index.html @@ -0,0 +1,12 @@ + + + + + + AKSRT 主题设置 + + +
+ + + diff --git a/assets/admin/package-lock.json b/assets/admin/package-lock.json new file mode 100644 index 00000000..25de0a6e --- /dev/null +++ b/assets/admin/package-lock.json @@ -0,0 +1,1853 @@ +{ + "name": "aksrt-admin", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "aksrt-admin", + "version": "1.0.0", + "dependencies": { + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-html": "^6.4.11", + "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/theme-one-dark": "^6.1.3", + "codemirror": "^6.0.2", + "naive-ui": "^2.40.0", + "pinia": "^2.2.0", + "vue": "^3.5.0", + "vue-router": "^4.4.0", + "vuedraggable": "^4.1.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.1.0", + "vite": "^6.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz", + "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-css": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.11", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", + "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.12" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.3", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz", + "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz", + "integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", + "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.1", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.1.tgz", + "integrity": "sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.6.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@css-render/plugin-bem": { + "version": "0.15.14", + "resolved": "https://registry.npmjs.org/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz", + "integrity": "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==", + "license": "MIT", + "peerDependencies": { + "css-render": "~0.15.14" + } + }, + "node_modules/@css-render/vue3-ssr": { + "version": "0.15.14", + "resolved": "https://registry.npmjs.org/@css-render/vue3-ssr/-/vue3-ssr-0.15.14.tgz", + "integrity": "sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.0.11" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@juggle/resize-observer": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz", + "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==", + "license": "Apache-2.0" + }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/css": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.3.tgz", + "integrity": "sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/html": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", + "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.33.tgz", + "integrity": "sha512-3PZLQwFw4Za3TC8t0FvTy3wI16Kt+pmwcgNZca4Pj9iWL2E72a/gZlpBtAJvEdDMdCxdG/qq0C7PN0bsJuv0Rw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.33", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.33.tgz", + "integrity": "sha512-PXq0yrfCLzzL07rbXO4awtXY1Z06LG2eu6Adg3RJFa/j3Cii217XxxLXG22N330gw7GmALCY0Z8RgXEviwgpjA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.33", + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.33.tgz", + "integrity": "sha512-UTUvRO9cY+rROrx/pvN9P5Z7FgA6QGfokUCfhQE4EnmUj3rVnK+CHI0LsEO1pg+I7//iRYMUfcNcCPe7tg0CoA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.33", + "@vue/compiler-dom": "3.5.33", + "@vue/compiler-ssr": "3.5.33", + "@vue/shared": "3.5.33", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.10", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.33.tgz", + "integrity": "sha512-IErjYdnj1qIupG5xxiVIYiiRvDhGWV4zuh/RCrwfYpuL+HWQzeU6lCk/nF9r7olWMnjKxCAkOctT2qFWFkzb1A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.33", + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.33.tgz", + "integrity": "sha512-p8UfIqyIhb0rYGlSgSBV+lPhF2iUSBcRy7enhTmPqKWadHy9kcOFYF1AejYBP9P+avnd3OBbD49DU4pLWX/94A==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.33.tgz", + "integrity": "sha512-UpFF45RI9//a7rvq7RdOQblb4tup7hHG9QsmIrxkFQLzQ7R8/iNQ5LE15NhLZ1/WcHMU2b47u6P33CPUelHyIQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.33", + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.33.tgz", + "integrity": "sha512-IOxMsAOwquhfITgmOgaPYl7/j8gKUxUFoflRc+u4LxyD3+783xne8vNta1PONVCvCV9A0w7hkyEepINDqfO0tw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.33", + "@vue/runtime-core": "3.5.33", + "@vue/shared": "3.5.33", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.33.tgz", + "integrity": "sha512-0xylq/8/h44lVG0pZFknv1XIdEgymq2E9n59uTWJBG+dIgiT0TMCSsxrN7nO16Z0MU0MPjFcguBbZV8Itk52Hw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.33", + "@vue/shared": "3.5.33" + }, + "peerDependencies": { + "vue": "3.5.33" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.33.tgz", + "integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==", + "license": "MIT" + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, + "node_modules/css-render": { + "version": "0.15.14", + "resolved": "https://registry.npmjs.org/css-render/-/css-render-0.15.14.tgz", + "integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "~0.8.0", + "csstype": "~3.0.5" + } + }, + "node_modules/css-render/node_modules/csstype": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz", + "integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-fns-tz": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz", + "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==", + "license": "MIT", + "peerDependencies": { + "date-fns": "^3.0.0 || ^4.0.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/evtd": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/evtd/-/evtd-0.2.4.tgz", + "integrity": "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/naive-ui": { + "version": "2.44.1", + "resolved": "https://registry.npmjs.org/naive-ui/-/naive-ui-2.44.1.tgz", + "integrity": "sha512-reo8Esw0p58liZwbUutC7meW24Xbn3EwNv91zReWKm2W4JPu+zfgJRn/F7aO0BFmvN+h2brA2M5lRvYqLq4kuA==", + "license": "MIT", + "dependencies": { + "@css-render/plugin-bem": "^0.15.14", + "@css-render/vue3-ssr": "^0.15.14", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "async-validator": "^4.2.5", + "css-render": "^0.15.14", + "csstype": "^3.1.3", + "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", + "evtd": "^0.2.4", + "highlight.js": "^11.8.0", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "seemly": "^0.3.10", + "treemate": "^0.3.11", + "vdirs": "^0.1.8", + "vooks": "^0.2.12", + "vueuc": "^0.4.65" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.13", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", + "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/seemly": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/seemly/-/seemly-0.3.10.tgz", + "integrity": "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==", + "license": "MIT" + }, + "node_modules/sortablejs": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz", + "integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/treemate": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/treemate/-/treemate-0.3.11.tgz", + "integrity": "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==", + "license": "MIT" + }, + "node_modules/vdirs": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/vdirs/-/vdirs-0.1.8.tgz", + "integrity": "sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw==", + "license": "MIT", + "dependencies": { + "evtd": "^0.2.2" + }, + "peerDependencies": { + "vue": "^3.0.11" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vooks": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/vooks/-/vooks-0.2.12.tgz", + "integrity": "sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==", + "license": "MIT", + "dependencies": { + "evtd": "^0.2.2" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/vue": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.33.tgz", + "integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.33", + "@vue/compiler-sfc": "3.5.33", + "@vue/runtime-dom": "3.5.33", + "@vue/server-renderer": "3.5.33", + "@vue/shared": "3.5.33" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vuedraggable": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz", + "integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==", + "license": "MIT", + "dependencies": { + "sortablejs": "1.14.0" + }, + "peerDependencies": { + "vue": "^3.0.1" + } + }, + "node_modules/vueuc": { + "version": "0.4.65", + "resolved": "https://registry.npmjs.org/vueuc/-/vueuc-0.4.65.tgz", + "integrity": "sha512-lXuMl+8gsBmruudfxnMF9HW4be8rFziylXFu1VHVNbLVhRTXXV4njvpRuJapD/8q+oFEMSfQMH16E/85VoWRyQ==", + "license": "MIT", + "dependencies": { + "@css-render/vue3-ssr": "^0.15.10", + "@juggle/resize-observer": "^3.3.1", + "css-render": "^0.15.10", + "evtd": "^0.2.4", + "seemly": "^0.3.6", + "vdirs": "^0.1.4", + "vooks": "^0.2.4" + }, + "peerDependencies": { + "vue": "^3.0.11" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + } + } +} diff --git a/assets/admin/package.json b/assets/admin/package.json new file mode 100644 index 00000000..eb06652b --- /dev/null +++ b/assets/admin/package.json @@ -0,0 +1 @@ +{"name":"aksrt-admin","version":"1.0.0","private":true,"type":"module","scripts":{"dev":"vite","build":"vite build && node extract-naive-styles.mjs","preview":"vite preview"},"dependencies":{"@codemirror/lang-css":"^6.3.1","@codemirror/lang-html":"^6.4.11","@codemirror/lang-javascript":"^6.2.5","@codemirror/theme-one-dark":"^6.1.3","codemirror":"^6.0.2","naive-ui":"^2.40.0","pinia":"^2.2.0","vue":"^3.5.0","vue-router":"^4.4.0","vuedraggable":"^4.1.0"},"devDependencies":{"@vitejs/plugin-vue":"^5.1.0","vite":"^6.0.0"}} \ No newline at end of file diff --git a/assets/admin/src/App.vue b/assets/admin/src/App.vue new file mode 100644 index 00000000..086d0d19 --- /dev/null +++ b/assets/admin/src/App.vue @@ -0,0 +1,271 @@ + + + + + diff --git a/assets/admin/src/api/index.js b/assets/admin/src/api/index.js new file mode 100644 index 00000000..baf5051d --- /dev/null +++ b/assets/admin/src/api/index.js @@ -0,0 +1,120 @@ +/** + * AKSRT Admin API Client + * + * Communicates with the WordPress REST API at /wp-json/aksrt/v1. + * Methods are organized into domain modules while the client keeps the shared + * request pipeline and schema-domain normalization contract. + */ + +import { apiMethods } from './modules/index.js' + +const API_ROOT = window.aksrtAdminData?.apiUrl || '/wp-json/aksrt/v1' +const NONCE = window.aksrtAdminData?.nonce || '' + +function normalizeDomainKey(domain) { + return String(domain || 'global').trim().replace(/-/g, '_') +} + +class ApiClient { + constructor() { + this.baseUrl = API_ROOT + } + + normalizeDomainKey(domain) { + return normalizeDomainKey(domain) + } + + async request(endpointOrConfig, options = {}) { + // Dual-signature support: + // request('/forum/overview') + // request({ url: '/aksrt/v1/forum/topics', method: 'GET', params: { limit: 20 } }) + // request({ url: '/aksrt/v1/verify/review', method: 'POST', data: { user_id: 1 } }) + let endpoint, method, headers, params, body, rest + + if (typeof endpointOrConfig === 'object' && endpointOrConfig !== null) { + const cfg = endpointOrConfig + endpoint = cfg.url || cfg.endpoint || '' + method = cfg.method || 'GET' + params = cfg.params + body = cfg.data + headers = cfg.headers + rest = { ...cfg } + delete rest.url; delete rest.endpoint; delete rest.method + delete rest.params; delete rest.data; delete rest.headers + } else { + endpoint = endpointOrConfig + method = options.method || 'GET' + params = options.params + body = options.body !== undefined ? options.body : options.data + headers = options.headers + rest = { ...options } + delete rest.method; delete rest.params; delete rest.body + delete rest.data; delete rest.headers + } + + if (endpoint.startsWith('/wp-json/aksrt/v1')) { + endpoint = endpoint.replace('/wp-json/aksrt/v1', '') || '/' + } else if (endpoint.startsWith('/aksrt/v1')) { + endpoint = endpoint.replace('/aksrt/v1', '') || '/' + } + + let url = `${this.baseUrl}${endpoint}` + + if (params && typeof params === 'object') { + const qs = new URLSearchParams(params).toString() + if (qs) url += (url.includes('?') ? '&' : '?') + qs + } + + let finalBody = body + if (body !== undefined && typeof body === 'object' && !(body instanceof FormData)) { + finalBody = JSON.stringify(body) + } + + const config = { + method, + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': NONCE, + ...headers, + }, + ...rest, + } + if (finalBody !== undefined && method !== 'GET') { + config.body = finalBody + } + + try { + const response = await fetch(url, config) + + const text = await response.text() + let data = {} + try { + data = text ? JSON.parse(text) : {} + } catch { + throw new Error( + `REST 返回非 JSON,endpoint=${endpoint},status=${response.status},body=${text.slice(0, 160)}` + ) + } + + if (!response.ok || data.success === false) { + const msg = data.message || data.data?.message || `HTTP ${response.status}` + + if (response.status === 403 || response.status === 401) { + console.warn(`[API Auth] ${endpoint}: ${msg} — 可能需要刷新页面重新登录`) + } + + throw new Error(msg) + } + + return data + } catch (error) { + console.error(`[API Error] ${endpoint}:`, error) + throw error + } + } +} + +Object.assign(ApiClient.prototype, apiMethods) + +export { normalizeDomainKey } +export default new ApiClient() diff --git a/assets/admin/src/api/modules/extensions.js b/assets/admin/src/api/modules/extensions.js new file mode 100644 index 00000000..36f34893 --- /dev/null +++ b/assets/admin/src/api/modules/extensions.js @@ -0,0 +1,8 @@ +export const extensionsApiMethods = { + /* ================================================================ + * 扩展&增强 (Extensions) + * ================================================================ */ + + testIpGeo(ip) { return this.request('/extensions/ip-test', { method: 'POST', body: JSON.stringify({ ip }) }) }, + testContentAudit(text) { return this.request('/extensions/audit-test', { method: 'POST', body: JSON.stringify({ text }) }) }, +} diff --git a/assets/admin/src/api/modules/global.js b/assets/admin/src/api/modules/global.js new file mode 100644 index 00000000..f4095d35 --- /dev/null +++ b/assets/admin/src/api/modules/global.js @@ -0,0 +1,28 @@ +export const globalApiMethods = { + /* ================================================================ + * 全局&功能 (Global & Functions) + * ================================================================ */ + + getMailSettings() { return this.request('/settings/mail') }, + saveMailSettings(data) { return this.request('/settings/mail', { method: 'POST', body: JSON.stringify(data) }) }, + testMail(to) { return this.request('/mail/test', { method: 'POST', body: JSON.stringify({ to }) }) }, + + getFooterSettings() { return this.request('/settings/footer') }, + saveFooterSettings(data) { return this.request('/settings/footer', { method: 'POST', body: JSON.stringify(data) }) }, + + getFooterTabbarSettings() { return this.request('/settings/footer-tabbar') }, + saveFooterTabbarSettings(data) { return this.request('/settings/footer-tabbar', { method: 'POST', body: JSON.stringify(data) }) }, + reorderFooterTabbar(ids) { return this.request('/settings/footer-tabbar/reorder', { method: 'POST', body: JSON.stringify({ ids }) }) }, + + getFloatingButtons() { return this.request('/settings/floating-buttons') }, + saveFloatingButtons(data) { return this.request('/settings/floating-buttons', { method: 'POST', body: JSON.stringify(data) }) }, + reorderFloatingButtons(ids) { return this.request('/settings/floating-buttons/reorder', { method: 'POST', body: JSON.stringify({ ids }) }) }, + + getFriendLinks() { return this.request('/friend-links') }, + addFriendLink(data) { return this.request('/friend-links', { method: 'POST', body: JSON.stringify(data) }) }, + deleteFriendLink(id) { return this.request(`/friend-links/${id}`, { method: 'DELETE' }) }, + reorderFriendLinks(ids) { return this.request('/friend-links/reorder', { method: 'POST', body: JSON.stringify({ ids }) }) }, + + getCmsFeatures() { return this.request('/settings/cms-features') }, + saveCmsFeatures(cms_features) { return this.request('/settings/cms-features', { method: 'POST', body: JSON.stringify({ cms_features }) }) }, +} diff --git a/assets/admin/src/api/modules/index.js b/assets/admin/src/api/modules/index.js new file mode 100644 index 00000000..0c07bee0 --- /dev/null +++ b/assets/admin/src/api/modules/index.js @@ -0,0 +1,19 @@ +import { globalApiMethods } from './global.js' +import { pageDisplayApiMethods } from './page-display.js' +import { permissionsApiMethods } from './permissions.js' +import { usersApiMethods } from './users.js' +import { paymentApiMethods } from './payment.js' +import { extensionsApiMethods } from './extensions.js' +import { toolsApiMethods } from './tools.js' +import { schemaApiMethods } from './schema.js' + +export const apiMethods = { + ...globalApiMethods, + ...pageDisplayApiMethods, + ...permissionsApiMethods, + ...usersApiMethods, + ...paymentApiMethods, + ...extensionsApiMethods, + ...toolsApiMethods, + ...schemaApiMethods, +} diff --git a/assets/admin/src/api/modules/page-display.js b/assets/admin/src/api/modules/page-display.js new file mode 100644 index 00000000..998b912e --- /dev/null +++ b/assets/admin/src/api/modules/page-display.js @@ -0,0 +1,30 @@ +export const pageDisplayApiMethods = { + /* ================================================================ + * 页面&显示 (Page Display) + * ================================================================ */ + + getHomepageSettings() { return this.request('/settings/homepage') }, + saveHomepageSettings(data) { return this.request('/settings/homepage', { method: 'POST', body: JSON.stringify(data) }) }, + getHomepageCategories() { return this.request('/homepage/categories') }, + + getCategoryRecommendSettings() { return this.request('/settings/category-recommend') }, + saveCategoryRecommendSettings(data) { return this.request('/settings/category-recommend', { method: 'POST', body: JSON.stringify(data) }) }, + getCategories() { return this.request('/categories') }, + updateCategoryMeta(id, data) { return this.request(`/categories/${id}/meta`, { method: 'POST', body: JSON.stringify(data) }) }, + searchUsers(search = '', number = 20) { return this.request(`/users/search?search=${encodeURIComponent(search)}&number=${number}`) }, + + getBanners(kind = 'hero') { return this.request(`/banners?kind=${encodeURIComponent(kind)}`) }, + createBanner(data) { return this.request('/banners', { method: 'POST', body: JSON.stringify(data) }) }, + updateBanner(id, data) { return this.request(`/banners/${id}`, { method: 'PUT', body: JSON.stringify(data) }) }, + deleteBanner(id) { return this.request(`/banners/${id}`, { method: 'DELETE' }) }, + + getHeroes() { return this.getBanners('hero') }, + createHero(data) { return this.createBanner({ ...data, kind: 'hero' }) }, + updateHero(id, data) { return this.updateBanner(id, { ...data, kind: 'hero' }) }, + deleteHero(id) { return this.deleteBanner(id) }, + + getHomepageBanners() { return this.getBanners('banner') }, + createHomepageBanner(data) { return this.createBanner({ ...data, kind: 'banner' }) }, + updateHomepageBanner(id, data) { return this.updateBanner(id, { ...data, kind: 'banner' }) }, + deleteHomepageBanner(id) { return this.deleteBanner(id) }, +} diff --git a/assets/admin/src/api/modules/payment.js b/assets/admin/src/api/modules/payment.js new file mode 100644 index 00000000..0707c187 --- /dev/null +++ b/assets/admin/src/api/modules/payment.js @@ -0,0 +1,7 @@ +export const paymentApiMethods = { + /* ================================================================ + * 支付&付费 (Payment) + * ================================================================ */ + + getPaymentOverview() { return this.request('/payment/overview') }, +} diff --git a/assets/admin/src/api/modules/permissions.js b/assets/admin/src/api/modules/permissions.js new file mode 100644 index 00000000..b6b1fa19 --- /dev/null +++ b/assets/admin/src/api/modules/permissions.js @@ -0,0 +1,17 @@ +export const permissionsApiMethods = { + /* ================================================================ + * 功能&权限 (Permissions) + * ================================================================ */ + + getPermissionsAttachmentSettings() { return this.getSchemaPage('permissions', 'attachment') }, + savePermissionsAttachmentSettings(data) { return this.saveSchemaPage('permissions', 'attachment', data) }, + + getPermissionsLoginSettings() { return this.getSchemaPage('permissions', 'login') }, + savePermissionsLoginSettings(data) { return this.saveSchemaPage('permissions', 'login', data) }, + + getPermissionsModerationSettings() { return this.getSchemaPage('permissions', 'moderation') }, + savePermissionsModerationSettings(data) { return this.saveSchemaPage('permissions', 'moderation', data) }, + + getCapabilities() { return this.request('/permissions/capabilities') }, + saveCapabilities(user_caps) { return this.request('/permissions/capabilities', { method: 'POST', body: JSON.stringify({ user_caps }) }) }, +} diff --git a/assets/admin/src/api/modules/schema.js b/assets/admin/src/api/modules/schema.js new file mode 100644 index 00000000..98789349 --- /dev/null +++ b/assets/admin/src/api/modules/schema.js @@ -0,0 +1,17 @@ +export const schemaApiMethods = { + getSchema(domain) { return this.request(`/settings/schema/${domain}`) }, + getSchemaPage(domain, section) { + return this.request(`/settings/schema/${this.normalizeDomainKey(domain)}/${encodeURIComponent(section)}`) + }, + saveSchemaPage(domain, section, data, version = '1.0.0') { + return this.request(`/settings/schema/${this.normalizeDomainKey(domain)}/${encodeURIComponent(section)}`, { + method: 'POST', + body: JSON.stringify({ + domain: this.normalizeDomainKey(domain), + section, + data, + version, + }), + }) + }, +} diff --git a/assets/admin/src/api/modules/tools.js b/assets/admin/src/api/modules/tools.js new file mode 100644 index 00000000..b575a512 --- /dev/null +++ b/assets/admin/src/api/modules/tools.js @@ -0,0 +1,28 @@ +export const toolsApiMethods = { + /* ================================================================ + * 扩展&增强 / 备份&导入 (Extensions & Tools) + * ================================================================ */ + + getHealthEmailVerification() { return this.request('/health/email-verification') }, + getUpdateInfo() { return this.request('/tools/update-info') }, + detectUpdate() { return this.request('/tools/update-check', { method: 'POST', body: JSON.stringify({}) }) }, + dismissUpdate(version) { return this.request('/tools/update-dismiss', { method: 'POST', body: JSON.stringify({ version }) }) }, + + getEmailRollbackUsers() { return this.request('/email-rollback/users') }, + executeEmailRollback(id) { return this.request(`/email-rollback/users/${id}/rollback`, { method: 'POST' }) }, + + /* ================================================================ + * Settings Management (Import / Export / Reset / Health / Schema) + * ================================================================ */ + + exportSettings() { return this.request('/settings/export') }, + importSettings(data) { return this.request('/settings/import', { method: 'POST', body: JSON.stringify(data) }) }, + listSettingBackups() { return this.request('/settings/backups') }, + createSettingBackup(data = {}) { return this.request('/settings/backups', { method: 'POST', body: JSON.stringify(data) }) }, + restoreSettingBackup(id) { return this.request(`/settings/backups/${id}/restore`, { method: 'POST' }) }, + deleteSettingBackup(id) { return this.request(`/settings/backups/${id}`, { method: 'DELETE' }) }, + resetDomain(domain) { return this.request(`/settings/reset/${domain}`, { method: 'POST' }) }, + resetAllSettings() { return this.request('/settings/reset-all', { method: 'POST' }) }, + getHealthCheck() { return this.request('/settings/health') }, + getMigrationStatus() { return this.request('/settings/migration-status') }, +} diff --git a/assets/admin/src/api/modules/users.js b/assets/admin/src/api/modules/users.js new file mode 100644 index 00000000..897e9b96 --- /dev/null +++ b/assets/admin/src/api/modules/users.js @@ -0,0 +1,36 @@ +export const usersApiMethods = { + /* ================================================================ + * 用户&互动 (User & Interaction) + * ================================================================ */ + + getAuthSettings() { return this.request('/settings/auth') }, + saveAuthSettings(data) { return this.request('/settings/auth', { method: 'POST', body: JSON.stringify(data) }) }, + + getOAuthSettings() { return this.request('/settings/oauth') }, + saveOAuthSettings(data) { return this.request('/settings/oauth', { method: 'POST', body: JSON.stringify(data) }) }, + + getNotificationSettings() { return this.request('/settings/notifications') }, + saveNotificationSettings(data) { return this.request('/settings/notifications', { method: 'POST', body: JSON.stringify(data) }) }, + getNotificationOverview(params = {}) { + const qs = new URLSearchParams(params).toString() + return this.request('/notifications/overview' + (qs ? `?${qs}` : '')) + }, + markNotificationRead(id) { return this.request('/notifications/mark-read', { method: 'POST', body: JSON.stringify({ id }) }) }, + markAllNotificationsRead() { return this.request('/notifications/mark-read', { method: 'POST', body: JSON.stringify({ mark_all: true }) }) }, + deleteNotification(id) { return this.request(`/notifications/${id}`, { method: 'DELETE' }) }, + runNotificationCleanup() { return this.request('/notifications/cleanup', { method: 'POST', body: JSON.stringify({}) }) }, + + getCreditSettings() { return this.request('/credits/settings') }, + saveCreditSettings(data) { return this.request('/credits/settings', { method: 'POST', body: JSON.stringify(data) }) }, + listCreditUsers(params = {}) { + const qs = new URLSearchParams(params).toString() + return this.request('/credits/users' + (qs ? `?${qs}` : '')) + }, + adjustUserCredits(id, delta, reason) { + return this.request(`/credits/users/${id}/adjust`, { method: 'POST', body: JSON.stringify({ delta, reason }) }) + }, + getCreditLog(params = {}) { + const qs = new URLSearchParams(params).toString() + return this.request('/credits/log' + (qs ? `?${qs}` : '')) + }, +} diff --git a/assets/admin/src/components/DeferredPage.vue b/assets/admin/src/components/DeferredPage.vue new file mode 100644 index 00000000..10f5e1d6 --- /dev/null +++ b/assets/admin/src/components/DeferredPage.vue @@ -0,0 +1,162 @@ + + + + + diff --git a/assets/admin/src/components/EmptyState.vue b/assets/admin/src/components/EmptyState.vue new file mode 100644 index 00000000..f092d1c4 --- /dev/null +++ b/assets/admin/src/components/EmptyState.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/assets/admin/src/components/MobileTopBar.vue b/assets/admin/src/components/MobileTopBar.vue new file mode 100644 index 00000000..09d0c699 --- /dev/null +++ b/assets/admin/src/components/MobileTopBar.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/assets/admin/src/components/OAuthProviderForm.vue b/assets/admin/src/components/OAuthProviderForm.vue new file mode 100644 index 00000000..db6f1b32 --- /dev/null +++ b/assets/admin/src/components/OAuthProviderForm.vue @@ -0,0 +1,164 @@ + + + + + diff --git a/assets/admin/src/components/PageHeader.vue b/assets/admin/src/components/PageHeader.vue new file mode 100644 index 00000000..dcd8e6b9 --- /dev/null +++ b/assets/admin/src/components/PageHeader.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/assets/admin/src/components/captcha/AliyunConfig.vue b/assets/admin/src/components/captcha/AliyunConfig.vue new file mode 100644 index 00000000..f216b1ac --- /dev/null +++ b/assets/admin/src/components/captcha/AliyunConfig.vue @@ -0,0 +1,66 @@ + + + diff --git a/assets/admin/src/components/captcha/CloudflareConfig.vue b/assets/admin/src/components/captcha/CloudflareConfig.vue new file mode 100644 index 00000000..38b3bf9f --- /dev/null +++ b/assets/admin/src/components/captcha/CloudflareConfig.vue @@ -0,0 +1,44 @@ + + + diff --git a/assets/admin/src/components/captcha/GeetestConfig.vue b/assets/admin/src/components/captcha/GeetestConfig.vue new file mode 100644 index 00000000..caf96c8f --- /dev/null +++ b/assets/admin/src/components/captcha/GeetestConfig.vue @@ -0,0 +1,25 @@ + + + diff --git a/assets/admin/src/components/captcha/TencentConfig.vue b/assets/admin/src/components/captcha/TencentConfig.vue new file mode 100644 index 00000000..9effbfb5 --- /dev/null +++ b/assets/admin/src/components/captcha/TencentConfig.vue @@ -0,0 +1,42 @@ + + + diff --git a/assets/admin/src/components/form/AksArrayTable.vue b/assets/admin/src/components/form/AksArrayTable.vue new file mode 100644 index 00000000..d60de64f --- /dev/null +++ b/assets/admin/src/components/form/AksArrayTable.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/assets/admin/src/components/form/AksCodeEditor.vue b/assets/admin/src/components/form/AksCodeEditor.vue new file mode 100644 index 00000000..07d99bf1 --- /dev/null +++ b/assets/admin/src/components/form/AksCodeEditor.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/assets/admin/src/components/form/AksField.vue b/assets/admin/src/components/form/AksField.vue new file mode 100644 index 00000000..7454d889 --- /dev/null +++ b/assets/admin/src/components/form/AksField.vue @@ -0,0 +1,824 @@ + + + + + diff --git a/assets/admin/src/components/form/AksFieldGroup.vue b/assets/admin/src/components/form/AksFieldGroup.vue new file mode 100644 index 00000000..40db841b --- /dev/null +++ b/assets/admin/src/components/form/AksFieldGroup.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/assets/admin/src/components/form/AksFormRenderer.vue b/assets/admin/src/components/form/AksFormRenderer.vue new file mode 100644 index 00000000..20916813 --- /dev/null +++ b/assets/admin/src/components/form/AksFormRenderer.vue @@ -0,0 +1,365 @@ + + + + + diff --git a/assets/admin/src/components/form/AksMediaField.vue b/assets/admin/src/components/form/AksMediaField.vue new file mode 100644 index 00000000..b51bdb41 --- /dev/null +++ b/assets/admin/src/components/form/AksMediaField.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/assets/admin/src/components/form/AksRepeaterField.vue b/assets/admin/src/components/form/AksRepeaterField.vue new file mode 100644 index 00000000..ad61ae36 --- /dev/null +++ b/assets/admin/src/components/form/AksRepeaterField.vue @@ -0,0 +1,185 @@ + + + + + diff --git a/assets/admin/src/components/form/FaIcon.vue b/assets/admin/src/components/form/FaIcon.vue new file mode 100644 index 00000000..21deaab1 --- /dev/null +++ b/assets/admin/src/components/form/FaIcon.vue @@ -0,0 +1,67 @@ + + + diff --git a/assets/admin/src/components/form/FaIconPicker.vue b/assets/admin/src/components/form/FaIconPicker.vue new file mode 100644 index 00000000..16803e18 --- /dev/null +++ b/assets/admin/src/components/form/FaIconPicker.vue @@ -0,0 +1,221 @@ + + + + + diff --git a/assets/admin/src/components/homepage/ModuleCanvas.vue b/assets/admin/src/components/homepage/ModuleCanvas.vue new file mode 100644 index 00000000..e9863814 --- /dev/null +++ b/assets/admin/src/components/homepage/ModuleCanvas.vue @@ -0,0 +1,313 @@ + + + + + + + diff --git a/assets/admin/src/components/homepage/ModuleInspector.vue b/assets/admin/src/components/homepage/ModuleInspector.vue new file mode 100644 index 00000000..88528079 --- /dev/null +++ b/assets/admin/src/components/homepage/ModuleInspector.vue @@ -0,0 +1,399 @@ + + + + + diff --git a/assets/admin/src/components/homepage/ModulePresetPicker.vue b/assets/admin/src/components/homepage/ModulePresetPicker.vue new file mode 100644 index 00000000..e15f1249 --- /dev/null +++ b/assets/admin/src/components/homepage/ModulePresetPicker.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/assets/admin/src/composables/useDirtyState.js b/assets/admin/src/composables/useDirtyState.js new file mode 100644 index 00000000..e3d11f76 --- /dev/null +++ b/assets/admin/src/composables/useDirtyState.js @@ -0,0 +1,50 @@ +import { ref, watch, computed } from 'vue' + +/** + * useDirtyState — tracks whether a form has unsaved changes. + * + * Usage: + * const { isDirty, markDirty, markClean, confirmLeave } = useDirtyState() + * + * Call markDirty() whenever form data changes from its initial state. + * Call markClean() after a successful save. + * confirmLeave() returns a boolean — false means the user cancelled navigation. + */ +export function useDirtyState() { + const dirty = ref(false) + const saveCount = ref(0) + + function markDirty() { + dirty.value = true + } + + function markClean() { + dirty.value = false + saveCount.value++ + } + + /** Reset dirty without incrementing save count (e.g. on initial load) */ + function reset() { + dirty.value = false + } + + /** + * Confirm navigation away from dirty form. + * Returns true if safe to leave, false if user cancelled. + */ + function confirmLeave() { + if (!dirty.value) return true + return window.confirm('您有未保存的更改,确定要离开此页面吗?') + } + + const isDirty = computed(() => dirty.value) + + return { + isDirty, + markDirty, + markClean, + reset, + confirmLeave, + saveCount, + } +} diff --git a/assets/admin/src/composables/useSectionForm.js b/assets/admin/src/composables/useSectionForm.js new file mode 100644 index 00000000..9d5a3774 --- /dev/null +++ b/assets/admin/src/composables/useSectionForm.js @@ -0,0 +1,140 @@ +import { ref, watch, onMounted, onBeforeUnmount, computed } from 'vue' +import { useDirtyState } from './useDirtyState' + +/** + * useSectionForm — standard composable for schema-driven admin settings pages. + * + * Encapsulates: data loading, dirty tracking, save/reload logic, and loading states. + * + * Usage: + * const { + * formData, loading, saving, loadError, + * dirty, load, save, reset, + * } = useSectionForm({ + * fetch: () => api.getBasicSettings(), + * submit: (data) => api.saveBasicSettings(data), + * defaults: { comment_enabled: true, lazy_load: true }, + * }) + * + * @param {Object} options + * @param {Function} options.fetch — async function returning current settings + * @param {Function} options.submit — async function accepting form data to save + * @param {Object} options.defaults — default values merged under fetched data + * @param {Function} [options.onLoaded] — callback after data is loaded + * @param {Function} [options.onSaved] — callback after successful save + * @param {Function} [options.transform] — transform fetched data before applying + */ +export function useSectionForm(options) { + const { + fetch: fetchData, + submit: submitData, + defaults = {}, + onLoaded, + onSaved, + transform, + } = options + + const formData = ref({ ...defaults }) + const initialSnapshot = ref('') + const loading = ref(false) + const saving = ref(false) + const loadError = ref(null) + + const { isDirty, markDirty, markClean, reset: resetDirty, confirmLeave } = useDirtyState() + + /** Snapshot the current form state for dirty comparison */ + function snapshot() { + initialSnapshot.value = JSON.stringify(formData.value) + } + + /** Check if form data differs from snapshot */ + function checkDirty() { + const current = JSON.stringify(formData.value) + if (current !== initialSnapshot.value) { + markDirty() + } else { + resetDirty() + } + } + + /** Deep watch form data for dirty tracking */ + let watcherHandle = null + + function startWatching() { + if (watcherHandle) watcherHandle() + watcherHandle = watch(formData, checkDirty, { deep: true }) + } + + /** Load settings from API */ + async function load() { + loading.value = true + loadError.value = null + try { + const data = await fetchData() + const raw = data?.data ?? data ?? {} + const merged = { ...defaults, ...(transform ? transform(raw) : raw) } + formData.value = merged + snapshot() + startWatching() + onLoaded?.(merged) + } catch (err) { + loadError.value = err?.message || '加载失败' + console.error('[useSectionForm] load error:', err) + } finally { + loading.value = false + } + } + + /** Save current form data */ + async function save() { + saving.value = true + try { + await submitData(formData.value) + snapshot() + markClean() + onSaved?.(formData.value) + } catch (err) { + console.error('[useSectionForm] save error:', err) + throw err // let caller handle the error display + } finally { + saving.value = false + } + } + + /** Reload form data from API (discards changes) */ + async function reload() { + if (watcherHandle) watcherHandle() + resetDirty() + await load() + } + + /** Reset form to defaults */ + function resetToDefaults() { + if (watcherHandle) watcherHandle() + formData.value = { ...defaults } + snapshot() + startWatching() + resetDirty() + } + + // Auto-load on mount + onMounted(load) + + // Cleanup watcher + onBeforeUnmount(() => { + if (watcherHandle) watcherHandle() + }) + + return { + formData, + loading, + saving, + loadError, + dirty: isDirty, + load, + save, + reload, + resetToDefaults, + confirmLeave, + } +} diff --git a/assets/admin/src/constants/admin-sections.js b/assets/admin/src/constants/admin-sections.js new file mode 100644 index 00000000..d4a8d1a4 --- /dev/null +++ b/assets/admin/src/constants/admin-sections.js @@ -0,0 +1,10 @@ +/** + * Admin sections — central menu and route registry. + * + * Sidebar menu, route metadata, schema domain mapping, and deferred-page + * planning details are all registered in domain modules to avoid drift between + * the navigation tree and router definitions while keeping each file focused. + */ + +export { ADMIN_SECTIONS, getAllAdminPages } from './sections/index.js' +export { renderFaIcon, customPage, schemaPage, deferredPage } from './sections/helpers.js' diff --git a/assets/admin/src/constants/sections/docs.js b/assets/admin/src/constants/sections/docs.js new file mode 100644 index 00000000..fb6af1dd --- /dev/null +++ b/assets/admin/src/constants/sections/docs.js @@ -0,0 +1,20 @@ +import { renderFaIcon, customPage } from './helpers' + +export const docsSection = { + key: 'tools-compat', + label: '兼容&工具', + icon: renderFaIcon('fas fa-toolbox'), + route: '/tools', + hidden: true, + children: [ + customPage({ + key: 'docs-dashboard', + name: 'DocsDashboard', + label: '文档中心', + route: '/docs', + component: 'DocsPage', + title: '文档中心', + desc: '更新日志、文档链接和发布说明。', + }), + ], +} diff --git a/assets/admin/src/constants/sections/extensions.js b/assets/admin/src/constants/sections/extensions.js new file mode 100644 index 00000000..2419a861 --- /dev/null +++ b/assets/admin/src/constants/sections/extensions.js @@ -0,0 +1,80 @@ +import { renderFaIcon, schemaPage, customPage } from './helpers' + +export const extensionsSection = { + key: 'extensions', + label: '扩展&增强', + icon: renderFaIcon('fas fa-puzzle-piece'), + route: '/extensions', + children: [ + schemaPage({ + key: 'extensions-ip', + name: 'ExtensionsIp', + label: 'IP归属地', + route: '/extensions/ip', + title: 'IP归属地', + desc: '配置 IP 数据源、缓存策略和前台归属地展示。', + canonicalDomain: 'extensions', + section: 'ip', + }), + schemaPage({ + key: 'extensions-audit', + name: 'ExtensionsAudit', + label: 'API内容审核', + route: '/extensions/audit', + title: 'API内容审核', + desc: '配置第三方内容审核 API 接入、审核范围和失败回退策略。', + canonicalDomain: 'extensions', + section: 'audit', + }), + schemaPage({ + key: 'extensions-push', + name: 'ExtensionsPush', + label: '百度推送', + route: '/extensions/push', + title: '百度推送', + desc: '配置搜索引擎主动推送、批量提交和状态追踪。', + canonicalDomain: 'extensions', + section: 'push', + }), + schemaPage({ + key: 'extensions-docs-mode', + name: 'ExtensionsDocsMode', + label: '文档模式', + route: '/extensions/docs-mode', + title: '文档模式', + desc: '文档专题内容模式与页面增强。', + canonicalDomain: 'extensions', + section: 'docs-mode', + }), + customPage({ + key: 'extensions-security', + name: 'ExtensionsSecurity', + label: '网站安全', + route: '/extensions/security', + component: 'ExtensionSecurity', + title: '网站安全', + desc: '扩展安全策略和第三方安全集成。', + }), + customPage({ + key: 'extensions-tools', + name: 'ExtensionsTools', + label: '系统工具', + route: '/extensions/tools', + component: 'ExtensionTools', + title: '系统工具', + desc: '额外运维工具与扩展能力。', + }), + customPage({ + key: 'extensions-ai', + name: 'ExtensionAI', + label: 'AI 子系统', + route: '/extensions/ai', + component: 'ExtensionAI', + status: 'available', + title: 'AI 子系统', + desc: '配置 AI 内容辅助能力。', + canonicalDomain: 'extensions', + section: 'ai', + }), + ], +} diff --git a/assets/admin/src/constants/sections/forum.js b/assets/admin/src/constants/sections/forum.js new file mode 100644 index 00000000..764265d9 --- /dev/null +++ b/assets/admin/src/constants/sections/forum.js @@ -0,0 +1,165 @@ +import { renderFaIcon, schemaPage, customPage } from './helpers' + +export const forumSection = { + key: 'forum', + label: '社区&论坛', + icon: renderFaIcon('fas fa-comments'), + route: '/forum', + children: [ + schemaPage({ + key: 'forum-settings-basic', + name: 'ForumSettingsBasic', + label: '基础设置', + route: '/forum/settings', + title: '论坛基础设置', + desc: '论坛总开关、管理员和名称定义。', + canonicalDomain: 'forum', + section: 'basic', + }), + schemaPage({ + key: 'forum-settings-names', + name: 'ForumSettingsNames', + label: '名称定义', + route: '/forum/settings/names', + title: '名称定义', + desc: '自定义论坛各实体的显示名称。', + canonicalDomain: 'forum', + section: 'names', + }), + schemaPage({ + key: 'forum-settings-homepage', + name: 'ForumSettingsHomepage', + label: '首页设置', + route: '/forum/settings/homepage', + title: '论坛首页设置', + desc: '首页 Tab 引擎、快速发帖入口和移动端交互配置。', + canonicalDomain: 'forum', + section: 'homepage', + }), + schemaPage({ + key: 'forum-settings-board-page', + name: 'ForumSettingsBoardPage', + label: '版块页面', + route: '/forum/settings/board-page', + title: '版块页面设置', + desc: '版块页布局、Tab 栏目和移动端交互配置。', + canonicalDomain: 'forum', + section: 'board_page', + }), + schemaPage({ + key: 'forum-settings-post-page', + name: 'ForumSettingsPostPage', + label: '帖子页面', + route: '/forum/settings/post-page', + title: '帖子页面设置', + desc: '面包屑、评分、高度限制和封面配置。', + canonicalDomain: 'forum', + section: 'post_page', + }), + schemaPage({ + key: 'forum-settings-reply', + name: 'ForumSettingsReply', + label: '回复评论', + route: '/forum/settings/reply', + title: '回复评论设置', + desc: '回复翻页方式、工具栏功能和快捷回复配置。', + canonicalDomain: 'forum', + section: 'reply', + }), + schemaPage({ + key: 'forum-settings-permissions', + name: 'ForumSettingsPermissions', + label: '权限配置', + route: '/forum/settings/permissions', + title: '论坛权限配置', + desc: '发帖限制、帖子付费和版块付费关注。', + canonicalDomain: 'forum', + section: 'permissions', + }), + schemaPage({ + key: 'forum-settings-other', + name: 'ForumSettingsOther', + label: '其它设置', + route: '/forum/settings/other', + title: '论坛其它设置', + desc: '投票、版主申请说明、标题字数和 URL 别名。', + canonicalDomain: 'forum', + section: 'other', + }), + schemaPage({ + key: 'forum-settings-upload', + name: 'ForumSettingsUpload', + label: '上传权限', + route: '/forum/settings/upload', + title: '上传权限设置', + desc: '图片、视频和文件上传大小与格式限制。', + canonicalDomain: 'forum', + section: 'upload', + }), + schemaPage({ + key: 'forum-settings-credits', + name: 'ForumSettingsCredits', + label: '积分设置', + route: '/forum/settings/credits', + title: '论坛积分设置', + desc: '论坛行为积分奖励矩阵和每日上限。', + canonicalDomain: 'forum', + section: 'credits', + }), + customPage({ + key: 'forum-boards', + name: 'ForumBoards', + label: '板块管理', + route: '/forum/boards', + component: 'ForumBoards', + title: '板块管理', + desc: '创建、编辑和排序论坛板块。', + canonicalDomain: 'forum', + section: 'boards', + }), + customPage({ + key: 'forum-topics', + name: 'ForumTopics', + label: '主题治理', + route: '/forum/topics', + component: 'ForumTopics', + title: '主题治理', + desc: '集中处理主题审核、可见性、版块移动和治理状态。', + canonicalDomain: 'forum', + section: 'topics', + }), + customPage({ + key: 'forum-moderator-applications', + name: 'ForumModeratorApplications', + label: '版主申请', + route: '/forum/moderator-applications', + component: 'ForumModeratorApplications', + title: '版主申请', + desc: '处理前台版主申请和审核结果反馈。', + canonicalDomain: 'forum', + section: 'moderator-applications', + }), + customPage({ + key: 'forum-moderators', + name: 'ForumModerators', + label: '版主管理', + route: '/forum/moderators', + component: 'ForumModerators', + title: '版主管理', + desc: '为每个论坛版块分配和管理版主。', + canonicalDomain: 'forum', + section: 'moderators', + }), + customPage({ + key: 'forum-dashboard', + name: 'ForumDashboard', + label: '论坛概览', + route: '/forum', + component: 'ForumDashboard', + title: '论坛概览', + desc: '社区讨论、帖子管理、版块配置等论坛功能概览。', + canonicalDomain: 'forum', + section: 'dashboard', + }), + ], +} diff --git a/assets/admin/src/constants/sections/global.js b/assets/admin/src/constants/sections/global.js new file mode 100644 index 00000000..3e39dcc2 --- /dev/null +++ b/assets/admin/src/constants/sections/global.js @@ -0,0 +1,108 @@ +import { renderFaIcon, schemaPage, customPage } from './helpers' + +export const globalSection = { + key: 'global', + label: '全局&功能', + icon: renderFaIcon('fas fa-globe'), + route: '/global', + children: [ + schemaPage({ + key: 'global-common', + name: 'GlobalCommon', + label: '常用功能', + route: '/global/common', + title: '常用功能', + desc: '评论、性能、头像源与备案等全局基础配置。', + canonicalDomain: 'global', + section: 'common', + }), + schemaPage({ + key: 'global-logo', + name: 'GlobalLogo', + label: 'LOGO图像', + route: '/global/logo', + title: 'LOGO图像', + desc: '管理站点 Logo、站点图标和品牌标识。', + canonicalDomain: 'global', + section: 'logo', + }), + schemaPage({ + key: 'global-seo', + name: 'GlobalSeo', + label: 'SEO优化', + route: '/global/seo', + title: 'SEO优化', + desc: '站点标题、描述、关键词、Sitemap 等搜索引擎优化设置。', + canonicalDomain: 'global', + section: 'seo', + }), + schemaPage({ + key: 'global-layout', + name: 'GlobalLayout', + label: '显示&布局', + route: '/global/layout', + title: '显示&布局', + desc: '全局布局风格、颜色方案、字体设置。', + canonicalDomain: 'global', + section: 'layout', + }), + schemaPage({ + key: 'global-search', + name: 'GlobalSearch', + label: '搜索功能', + route: '/global/search', + title: '搜索功能', + desc: '搜索页面样式、搜索建议、热门搜索配置。', + canonicalDomain: 'global', + section: 'search', + }), + customPage({ + key: 'global-tabbar', + name: 'GlobalTabbar', + label: '手机底部Tab', + route: '/global/tabbar', + title: '手机底部Tab', + desc: '移动端底部导航栏配置。', + component: 'FooterTabbarSettings', + }), + customPage({ + key: 'global-floating', + name: 'GlobalFloating', + label: '右侧悬浮按钮', + route: '/global/floating', + title: '右侧悬浮按钮', + desc: '页面右侧浮动按钮的显示与配置。', + component: 'FloatingButtonsSettings', + }), + schemaPage({ + key: 'global-i18n', + name: 'GlobalI18n', + label: '多语言翻译', + route: '/global/i18n', + title: '多语言翻译', + desc: '配置站点语言、语言切换器和翻译管理。', + canonicalDomain: 'global', + section: 'i18n', + }), + schemaPage({ + key: 'global-email', + name: 'GlobalEmail', + label: 'Email邮件', + route: '/global/email', + title: 'Email邮件', + desc: 'SMTP 发信配置、邮件模板与测试。', + canonicalDomain: 'global', + section: 'email', + }), + schemaPage({ + key: 'global-custom-code', + name: 'GlobalCustomCode', + label: '自定义代码', + route: '/global/custom-code', + title: '自定义代码', + desc: '自定义 CSS、JS 代码注入。', + canonicalDomain: 'global', + section: 'custom-code', + }), + ], +} diff --git a/assets/admin/src/constants/sections/helpers.js b/assets/admin/src/constants/sections/helpers.js new file mode 100644 index 00000000..3c22f3f1 --- /dev/null +++ b/assets/admin/src/constants/sections/helpers.js @@ -0,0 +1,39 @@ +import { h } from 'vue' + +export function renderFaIcon(faClass) { + return () => h('i', { class: faClass, style: 'font-size:18px;width:18px;text-align:center' }) +} + +function sectionPage(config) { + return { + status: 'available', + component: 'SchemaPage', + canonicalDomain: '', + section: '', + phase: '', + dependsOn: [], + reason: '', + features: [], + iconClass: '', + ...config, + } +} + +export function customPage(config) { + return sectionPage(config) +} + +export function schemaPage(config) { + return sectionPage({ + component: 'SchemaPage', + ...config, + }) +} + +export function deferredPage(config) { + return sectionPage({ + component: 'DeferredPage', + status: 'deferred', + ...config, + }) +} diff --git a/assets/admin/src/constants/sections/index.js b/assets/admin/src/constants/sections/index.js new file mode 100644 index 00000000..239c67df --- /dev/null +++ b/assets/admin/src/constants/sections/index.js @@ -0,0 +1,66 @@ +import { globalSection } from './global' +import { pageDisplaySection } from './page-display' +import { postListSection } from './post-list' +import { permissionsSection } from './permissions' +import { usersSection } from './users' +import { paymentSection } from './payment' +import { shopSection } from './shop' +import { forumSection } from './forum' +import { extensionsSection } from './extensions' +import { themeInfoSection } from './theme-info' +import { docsSection } from './docs' +import { toolsSection } from './tools' + +const primarySections = [ + globalSection, + pageDisplaySection, + postListSection, + permissionsSection, + usersSection, + paymentSection, + shopSection, + forumSection, + extensionsSection, +] + +const compatChildren = [ + ...(toolsSection.children || []), + ...(themeInfoSection.children || []), + ...(docsSection.children || []), +] + +export const compatToolsSection = { + key: 'tools-compat', + label: '兼容&工具', + icon: toolsSection.icon, + route: '/tools', + children: compatChildren, +} + +export const ADMIN_SECTIONS = [ + ...primarySections, + compatToolsSection, +] + +export function getAllAdminPages() { + const pages = [] + + for (const section of ADMIN_SECTIONS) { + if (section.placeholder) { + pages.push({ + sectionKey: section.key, + ...section, + }) + continue + } + + for (const child of section.children || []) { + pages.push({ + sectionKey: section.key, + ...child, + }) + } + } + + return pages +} diff --git a/assets/admin/src/constants/sections/page-display.js b/assets/admin/src/constants/sections/page-display.js new file mode 100644 index 00000000..7a679f75 --- /dev/null +++ b/assets/admin/src/constants/sections/page-display.js @@ -0,0 +1,118 @@ +import { renderFaIcon, schemaPage, customPage } from './helpers' + +export const pageDisplaySection = { + key: 'page-display', + label: '页面&显示', + icon: renderFaIcon('fas fa-desktop'), + route: '/page-display', + children: [ + schemaPage({ + key: 'page-display-header-nav', + name: 'PageDisplayHeaderNav', + label: '顶部导航栏', + route: '/page-display/header-nav', + title: '顶部导航栏', + desc: '导航栏布局、菜单位置、LOGO 与搜索入口配置。', + canonicalDomain: 'page_display', + section: 'header-nav', + }), + schemaPage({ + key: 'page-display-background', + name: 'PageDisplayBackground', + label: '全局背景', + route: '/page-display/background', + title: '全局背景', + desc: '配置站点全局背景图、背景色、暗色模式背景和遮罩。', + canonicalDomain: 'page_display', + section: 'background', + }), + schemaPage({ + key: 'page-display-menu-decoration', + name: 'PageDisplayMenuDecoration', + label: '菜单装饰', + route: '/page-display/menu-decoration', + title: '菜单装饰', + desc: '配置导航菜单的图标、徽章、下拉样式和图文卡片子菜单。', + canonicalDomain: 'page_display', + section: 'menu-decoration', + }), + schemaPage({ + key: 'page-display-header-components', + name: 'PageDisplayHeaderComponents', + label: '顶部多功能组件', + route: '/page-display/header-components', + title: '顶部多功能组件', + desc: '顶部轮播、图标卡片、搜索条、公告条等组件配置。', + canonicalDomain: 'page_display', + section: 'header-components', + }), + customPage({ + key: 'page-display-footer', + name: 'PageDisplayFooter', + label: '底部页脚', + route: '/page-display/footer', + title: '底部页脚', + desc: '页脚布局、友链、版权信息配置。', + component: 'FooterSettings', + }), + customPage({ + key: 'page-display-homepage', + name: 'PageDisplayHomepage', + label: '首页配置', + route: '/page-display/homepage', + title: '首页配置', + desc: '首页模块、布局、推荐位配置。', + component: 'HomepageSettings', + }), + schemaPage({ + key: 'page-display-category', + name: 'PageDisplayCategory', + label: '分类页面', + route: '/page-display/category', + title: '分类页面', + desc: '分类归档页的头图、简介、列表模式、侧栏配置。', + canonicalDomain: 'page_display', + section: 'category', + }), + schemaPage({ + key: 'page-display-tag', + name: 'PageDisplayTag', + label: '标签页面', + route: '/page-display/tag', + title: '标签页面', + desc: '标签归档页的展示样式与配置。', + canonicalDomain: 'page_display', + section: 'tag', + }), + schemaPage({ + key: 'page-display-special', + name: 'PageDisplaySpecial', + label: '专题页面', + route: '/page-display/special', + title: '专题页面', + desc: '配置专题聚合页的封面、摘要、布局、聚合规则和子页面展示策略。', + canonicalDomain: 'page_display', + section: 'special', + }), + schemaPage({ + key: 'page-display-user-home', + name: 'PageDisplayUserHome', + label: '用户主页', + route: '/page-display/user-home', + title: '用户主页', + desc: '用户公开主页的布局与展示配置。', + canonicalDomain: 'page_display', + section: 'user-home', + }), + schemaPage({ + key: 'page-display-other', + name: 'PageDisplayOther', + label: '其它页面', + route: '/page-display/other', + title: '其它页面', + desc: '404 页面、搜索结果页等其它页面配置。', + canonicalDomain: 'page_display', + section: 'other', + }), + ], +} diff --git a/assets/admin/src/constants/sections/payment.js b/assets/admin/src/constants/sections/payment.js new file mode 100644 index 00000000..2c8a4de0 --- /dev/null +++ b/assets/admin/src/constants/sections/payment.js @@ -0,0 +1,100 @@ +import { renderFaIcon, schemaPage, customPage } from './helpers' + +export const paymentSection = { + key: 'payment', + label: '支付&付费', + icon: renderFaIcon('fas fa-wallet'), + route: '/payment', + children: [ + customPage({ + key: 'payment-gateway', + name: 'PaymentGateway', + label: '收款接口', + route: '/payment/gateway', + component: 'PaymentGateway', + status: 'available', + title: '收款接口', + desc: '支付网关适配器、测试网关和手动确认配置。', + canonicalDomain: 'payment', + section: 'gateway', + }), + customPage({ + key: 'payment-paid', + name: 'PaymentPaid', + label: '下载/付费内容中心', + route: '/payment/paid', + title: '下载/付费内容中心', + desc: '资源下载门槛、付费内容策略和人工交付说明。', + component: 'ResourceDownload', + }), + schemaPage({ + key: 'payment-recharge', + name: 'PaymentRecharge', + label: '余额充值', + route: '/payment/recharge', + status: 'available', + title: '余额充值', + desc: '余额充值策略、记录和手动确认配置。', + canonicalDomain: 'payment', + section: 'recharge', + }), + schemaPage({ + key: 'payment-vip', + name: 'PaymentVip', + label: 'VIP会员', + route: '/payment/vip', + status: 'available', + title: 'VIP会员', + desc: '会员套餐、会员特权和免扣策略。', + canonicalDomain: 'payment', + section: 'vip', + }), + customPage({ + key: 'payment-revenue', + name: 'PaymentRevenue', + label: '创作分成', + route: '/payment/revenue', + component: 'PaymentRevenue', + status: 'available', + title: '创作分成', + desc: '创作者收益分配和结算记录。', + canonicalDomain: 'payment', + section: 'revenue', + }), + schemaPage({ + key: 'payment-affiliate', + name: 'PaymentAffiliate', + label: '推广返佣', + route: '/payment/affiliate', + status: 'available', + title: '推广返佣', + desc: '返佣规则、邀请链路和结算记录。', + canonicalDomain: 'payment', + section: 'affiliate', + }), + customPage({ + key: 'payment-charge-card', + name: 'PaymentChargeCard', + label: '充值卡', + route: '/payment/charge-card', + component: 'PaymentChargeCard', + status: 'available', + title: '充值卡', + desc: '创建和管理充值卡。', + canonicalDomain: 'payment', + section: 'charge-card', + }), + customPage({ + key: 'payment-rebate', + name: 'PaymentRebate', + label: '返佣管理', + route: '/payment/rebate', + component: 'PaymentRebate', + status: 'available', + title: '返佣管理', + desc: '管理订单返佣和推广返佣记录。', + canonicalDomain: 'payment', + section: 'rebate', + }), + ], +} diff --git a/assets/admin/src/constants/sections/permissions.js b/assets/admin/src/constants/sections/permissions.js new file mode 100644 index 00000000..a555a1bd --- /dev/null +++ b/assets/admin/src/constants/sections/permissions.js @@ -0,0 +1,74 @@ +import { renderFaIcon, customPage, schemaPage } from './helpers' + +export const permissionsSection = { + key: 'permissions', + label: '功能&权限', + icon: renderFaIcon('fas fa-shield-alt'), + route: '/permissions', + children: [ + customPage({ + key: 'permissions-comment', + name: 'PermissionsComment', + label: '评论权限', + route: '/permissions/comment', + component: 'CommentPermissions', + title: '评论权限', + desc: '游客评论、频率限制、敏感词和审核策略。', + canonicalDomain: 'permissions', + section: 'comment', + }), + customPage({ + key: 'permissions-attachment', + name: 'PermissionsAttachment', + label: '附件权限', + route: '/permissions/attachment', + component: 'AttachmentPermissions', + title: '附件权限', + desc: '上传角色、文件类型、大小限制和审核状态。', + canonicalDomain: 'permissions', + section: 'attachment', + }), + customPage({ + key: 'permissions-download', + name: 'PermissionsDownload', + label: '下载权限', + route: '/permissions/download', + component: 'DownloadPermissions', + title: '下载权限', + desc: '登录要求、积分要求、角色要求和 VIP 特权。', + canonicalDomain: 'permissions', + section: 'download', + }), + schemaPage({ + key: 'permissions-login', + name: 'PermissionsLogin', + label: '登录权限', + route: '/permissions/login', + title: '登录权限', + desc: '登录验证、人机验证、注册限制配置。', + canonicalDomain: 'permissions', + section: 'login', + }), + schemaPage({ + key: 'permissions-moderation', + name: 'PermissionsModeration', + label: '审核和风控', + route: '/permissions/moderation', + title: '审核和风控', + desc: '评论审核、反垃圾、限流与风控配置。', + canonicalDomain: 'permissions', + section: 'moderation', + }), + customPage({ + key: 'permissions-capabilities', + name: 'PermissionsCapabilities', + label: '基本权限', + route: '/permissions/capabilities', + component: 'PermissionsCapabilities', + title: '基本权限 / 能力矩阵', + desc: '统一管理用户治理、投稿和评论的角色能力矩阵。', + canonicalDomain: 'permissions', + section: 'capabilities', + }), + ], +} diff --git a/assets/admin/src/constants/sections/post-list.js b/assets/admin/src/constants/sections/post-list.js new file mode 100644 index 00000000..ac70e174 --- /dev/null +++ b/assets/admin/src/constants/sections/post-list.js @@ -0,0 +1,92 @@ +import { renderFaIcon, schemaPage, customPage } from './helpers' + +export const postListSection = { + key: 'post-list', + label: '文章&列表', + icon: renderFaIcon('fas fa-file-alt'), + route: '/post-list', + children: [ + schemaPage({ + key: 'post-list-list', + name: 'PostListList', + label: '文章列表', + route: '/post-list/list', + title: '文章列表', + desc: '列表样式、元信息开关、分页方式配置。', + canonicalDomain: 'post_list', + section: 'list', + }), + schemaPage({ + key: 'post-list-thumb', + name: 'PostListThumb', + label: '列表缩略图', + route: '/post-list/thumb', + title: '列表缩略图', + desc: '缩略图策略、封面回退、多图模式配置。', + canonicalDomain: 'post_list', + section: 'thumb', + }), + schemaPage({ + key: 'post-list-page', + name: 'PostListPage', + label: '文章页面', + route: '/post-list/page', + component: 'PostPageSettings', + title: '文章页面', + desc: '文章页布局、目录树、灯箱、版权区配置。', + canonicalDomain: 'post_list', + section: 'page', + }), + schemaPage({ + key: 'post-list-filter', + name: 'PostListFilter', + label: '高级筛选', + route: '/post-list/filter', + title: '高级筛选', + desc: '筛选栏、排序方式、分类筛选配置。', + canonicalDomain: 'post_list', + section: 'filter', + }), + schemaPage({ + key: 'post-list-feature', + name: 'PostListFeature', + label: '文章功能', + route: '/post-list/feature', + title: '文章功能', + desc: '收藏、分享、阅读进度、相关推荐等功能开关。', + canonicalDomain: 'post_list', + section: 'feature', + }), + schemaPage({ + key: 'post-list-share', + name: 'PostListShare', + label: '分享设置', + route: '/post-list/share', + title: '分享设置', + desc: '配置微信分享凭据、海报分享和社交分享选项。', + canonicalDomain: 'post_list', + section: 'share', + }), + schemaPage({ + key: 'post-list-comment', + name: 'PostListComment', + label: '评论设置', + route: '/post-list/comment', + component: 'CommentSettings', + title: '评论设置', + desc: '评论排序、楼层展示、登录限制、回复限制等配置。', + canonicalDomain: 'post_list', + section: 'comment', + }), + schemaPage({ + key: 'post-list-submit', + name: 'PostListSubmit', + label: '前台投稿', + route: '/post-list/submit', + title: '前台投稿', + desc: '配置前台投稿表单字段、审核流程、附件权限和封面策略。', + canonicalDomain: 'post_list', + section: 'submit', + }), + ], +} diff --git a/assets/admin/src/constants/sections/shop.js b/assets/admin/src/constants/sections/shop.js new file mode 100644 index 00000000..59a36cc5 --- /dev/null +++ b/assets/admin/src/constants/sections/shop.js @@ -0,0 +1,157 @@ +import { renderFaIcon, schemaPage, customPage } from './helpers' + +export const shopSection = { + key: 'shop', + label: '商城&商品', + icon: renderFaIcon('fas fa-shopping-cart'), + route: '/shop', + children: [ + customPage({ + key: 'shop-products', + name: 'ShopProducts', + label: '商品管理', + route: '/shop/products', + component: 'ShopProducts', + title: '商品管理', + desc: '管理所有商品的上架、编辑和库存。', + canonicalDomain: 'shop', + section: 'products', + }), + customPage({ + key: 'shop-orders', + name: 'ShopOrders', + label: '订单管理', + route: '/shop/orders', + component: 'ShopOrders', + title: '订单管理', + desc: '查看和管理所有交易订单。', + canonicalDomain: 'shop', + section: 'orders', + }), + customPage({ + key: 'shop-withdrawals', + name: 'ShopWithdrawals', + label: '提现管理', + route: '/shop/withdrawals', + component: 'ShopWithdrawals', + title: '提现管理', + desc: '审核和管理创作者提现申请。', + canonicalDomain: 'shop', + section: 'withdrawals', + }), + customPage({ + key: 'shop-coupons', + name: 'ShopCoupons', + label: '优惠券', + route: '/shop/coupons', + component: 'ShopCoupons', + title: '优惠券', + desc: '创建和管理优惠券。', + canonicalDomain: 'shop', + section: 'coupons', + }), + customPage({ + key: 'shop-after-sale', + name: 'ShopAfterSale', + label: '售后管理', + route: '/shop/after-sale', + component: 'ShopAfterSale', + title: '售后管理', + desc: '管理退款、换货等售后工单。', + canonicalDomain: 'shop', + section: 'after-sale', + }), + customPage({ + key: 'shop-shipping', + name: 'ShopShipping', + label: '物流管理', + route: '/shop/shipping', + component: 'ShopShipping', + title: '物流管理', + desc: '管理订单发货、物流单号和发货状态。', + canonicalDomain: 'shop', + section: 'shipping', + }), + schemaPage({ + key: 'shop-basic', + name: 'ShopBasic', + label: '商城基础', + route: '/shop/basic', + title: '商城基础', + desc: '商城总开关、首页链接、面包屑和 URL 别名设置。', + canonicalDomain: 'shop', + section: 'basic', + }), + schemaPage({ + key: 'shop-list', + name: 'ShopList', + label: '商品列表', + route: '/shop/list', + title: '商品列表', + desc: '商品列表排序、分页、卡片样式和销量显示配置。', + canonicalDomain: 'shop', + section: 'list', + }), + schemaPage({ + key: 'shop-contact', + name: 'ShopContact', + label: '客服配置', + route: '/shop/contact', + title: '客服配置', + desc: '商品详情页客服按钮和联系方式配置。', + canonicalDomain: 'shop', + section: 'contact', + }), + schemaPage({ + key: 'shop-single', + name: 'ShopSingle', + label: '商品详情', + route: '/shop/single', + title: '商品详情', + desc: '商品详情页布局、相关推荐和底部内容设置。', + canonicalDomain: 'shop', + section: 'single', + }), + schemaPage({ + key: 'shop-product-defaults', + name: 'ShopProductDefaults', + label: '商品默认参数', + route: '/shop/product-defaults', + title: '商品默认参数', + desc: '主题级商品参数默认值,作为参数继承链的根。', + canonicalDomain: 'shop', + section: 'product-defaults', + }), + schemaPage({ + key: 'shop-shipping-config', + name: 'ShopShippingConfig', + label: '物流配置', + route: '/shop/shipping-config', + title: '物流配置', + desc: '快递查询接口、物流公司列表和收货地址设置。', + canonicalDomain: 'shop', + section: 'shipping-config', + }), + schemaPage({ + key: 'shop-timing', + name: 'ShopTiming', + label: '时效与售后原因', + route: '/shop/timing', + title: '时效与售后原因', + desc: '确认收货、评价、退货时效和售后原因配置。', + canonicalDomain: 'shop', + section: 'timing', + }), + customPage({ + key: 'shop-dashboard', + name: 'ShopDashboard', + label: '商城概览', + route: '/shop', + component: 'ShopDashboard', + title: '商城概览', + desc: '商品管理、订单系统、库存管理等电商功能概览。', + canonicalDomain: 'shop', + section: 'dashboard', + }), + ], +} diff --git a/assets/admin/src/constants/sections/theme-info.js b/assets/admin/src/constants/sections/theme-info.js new file mode 100644 index 00000000..f7b70eaa --- /dev/null +++ b/assets/admin/src/constants/sections/theme-info.js @@ -0,0 +1,29 @@ +import { renderFaIcon, customPage } from './helpers' + +export const themeInfoSection = { + key: 'tools-compat', + label: '兼容&工具', + icon: renderFaIcon('fas fa-toolbox'), + route: '/tools', + hidden: true, + children: [ + customPage({ + key: 'theme-info-dashboard', + name: 'ThemeInfoDashboard', + label: '主题信息', + route: '/theme-info', + component: 'ThemeInfoPage', + title: '主题信息', + desc: '主题版本、授权信息和帮助入口。', + }), + customPage({ + key: 'theme-authorization', + name: 'ThemeAuthorization', + label: '主题授权', + route: '/theme-info/authorization', + component: 'ThemeAuthorizationPage', + title: '主题授权', + desc: '当前版本的授权策略与产品决策说明。', + }), + ], +} diff --git a/assets/admin/src/constants/sections/tools.js b/assets/admin/src/constants/sections/tools.js new file mode 100644 index 00000000..041cdd87 --- /dev/null +++ b/assets/admin/src/constants/sections/tools.js @@ -0,0 +1,67 @@ +import { renderFaIcon, customPage, schemaPage } from './helpers' + +export const toolsSection = { + key: 'tools-compat', + label: '兼容&工具', + icon: renderFaIcon('fas fa-toolbox'), + route: '/tools', + children: [ + customPage({ + key: 'tools-import-export', + name: 'ToolsImportExport', + label: '导入导出', + route: '/tools/import-export', + title: '导入导出', + desc: '导出当前配置、执行 dry-run 导入并按域重置。', + component: 'ToolsImportExport', + }), + schemaPage({ + key: 'tools-backup', + name: 'ToolsBackup', + label: '数据备份', + route: '/tools/backup', + title: '数据备份', + desc: '管理设置快照、创建备份并执行恢复或删除。', + canonicalDomain: 'tools', + section: 'backup', + }), + customPage({ + key: 'tools-health', + name: 'ToolsHealth', + label: '健康检查', + route: '/tools/health', + title: '健康检查', + desc: '查看主题环境、数据表、邮箱验证迁移和系统诊断状态。', + component: 'ToolsHealth', + }), + customPage({ + key: 'tools-migration', + name: 'ToolsMigration', + label: '迁移状态', + route: '/tools/migration', + title: '迁移状态', + desc: '查看数据库 schema 版本、设置迁移版本和迁移日志。', + component: 'ToolsMigration', + }), + customPage({ + key: 'tools-email-rollback', + name: 'ToolsEmailRollback', + label: '邮件回滚', + route: '/tools/email-rollback', + title: '邮件回滚', + desc: '查看 14 天邮箱变更窗口内的账号并执行回滚。', + component: 'ToolsEmailRollback', + }), + customPage({ + key: 'tools-update', + name: 'ToolsUpdate', + label: '更新中心', + route: '/tools/update', + title: '更新中心', + desc: '查看版本状态、检测更新并浏览更新日志与教程入口。', + component: 'ToolsUpdate', + canonicalDomain: 'tools', + section: 'update', + }), + ], +} diff --git a/assets/admin/src/constants/sections/users.js b/assets/admin/src/constants/sections/users.js new file mode 100644 index 00000000..babd6929 --- /dev/null +++ b/assets/admin/src/constants/sections/users.js @@ -0,0 +1,188 @@ +import { renderFaIcon, schemaPage, customPage } from './helpers' + +export const usersSection = { + key: 'users', + label: '用户&互动', + icon: renderFaIcon('fas fa-users'), + route: '/users', + children: [ + schemaPage({ + key: 'users-auth', + name: 'UsersAuth', + label: '注册登录', + route: '/users/auth', + title: '注册登录', + desc: '注册方式、登录限制和密码策略配置。', + canonicalDomain: 'users', + section: 'auth', + }), + schemaPage({ + key: 'users-oauth', + name: 'UsersOauth', + label: '第三方登录', + route: '/users/oauth', + title: '第三方登录', + desc: 'GitHub、Gitee、QQ、Google 等第三方登录配置。', + canonicalDomain: 'users', + section: 'oauth', + }), + customPage({ + key: 'users-features', + name: 'UsersFeatures', + label: '用户功能', + route: '/users/features', + component: 'UsersFeatures', + title: '用户功能', + desc: '用户中心与个人主页模块配置。', + canonicalDomain: 'users', + section: 'features', + }), + customPage({ + key: 'users-signin', + name: 'UsersSignin', + label: '签到奖励', + route: '/users/signin', + component: 'UsersSignin', + title: '签到奖励', + desc: '签到规则、连续签到奖励和签到页面配置。', + canonicalDomain: 'users', + section: 'signin', + }), + customPage({ + key: 'users-credits', + name: 'UsersCredits', + label: '用户积分', + route: '/users/credits', + title: '用户积分', + desc: '积分规则、积分日志和用户积分管理。', + component: 'CmsCredits', + canonicalDomain: 'users', + section: 'credits', + }), + customPage({ + key: 'users-verify', + name: 'UsersVerify', + label: '身份认证', + route: '/users/verify', + component: 'UsersVerify', + status: 'available', + title: '身份认证', + desc: '身份认证字段、审核流和展示标签。', + canonicalDomain: 'users', + section: 'verify', + }), + customPage({ + key: 'users-level', + name: 'UsersLevel', + label: '用户等级', + route: '/users/level', + component: 'UsersLevel', + status: 'available', + title: '用户等级', + desc: '等级规则、升级条件和权益映射。', + canonicalDomain: 'users', + section: 'level', + }), + customPage({ + key: 'users-badge', + name: 'UsersBadge', + label: '用户徽章', + route: '/users/badge', + component: 'UsersBadge', + status: 'available', + title: '用户徽章', + desc: '徽章规则、授予条件和前台展示。', + canonicalDomain: 'users', + section: 'badge', + }), + schemaPage({ + key: 'users-notifications', + name: 'UsersNotifications', + label: '消息通知', + route: '/users/notifications', + title: '消息通知', + desc: '站内通知、邮件通知和通知策略配置。', + canonicalDomain: 'users', + section: 'notifications', + }), + customPage({ + key: 'users-invite-codes', + name: 'UsersInviteCodes', + label: '邀请码运营', + route: '/users/invite-codes', + component: 'UsersInviteCodes', + title: '邀请码运营', + desc: '管理邀请码列表、生成新邀请码和查看邀请统计。', + canonicalDomain: 'users', + section: 'invite-codes', + }), + schemaPage({ + key: 'users-sms', + name: 'UsersSms', + label: '短信接口', + route: '/users/sms', + title: '短信接口', + desc: '配置短信验证码和短信通知服务的供应商、模板和限流策略。', + canonicalDomain: 'users', + section: 'sms', + }), + schemaPage({ + key: 'users-wechat', + name: 'UsersWechat', + label: '微信通知', + route: '/users/wechat', + title: '微信通知', + desc: '配置微信模板消息、订阅/解绑和站外通知策略。', + canonicalDomain: 'users', + section: 'wechat', + }), + customPage({ + key: 'users-wechat-template', + name: 'UsersWechatTemplate', + label: '微信模板消息', + route: '/users/wechat-template', + component: 'UsersWechatTemplate', + status: 'available', + title: '微信模板消息', + desc: '配置微信公众号模板消息推送。', + canonicalDomain: 'users', + section: 'wechat-template', + }), + customPage({ + key: 'users-messages', + name: 'UsersMessages', + label: '消息中心', + route: '/users/messages', + component: 'UsersMessages', + status: 'available', + title: '消息中心', + desc: '管理站内通知、系统消息和用户私信记录。', + canonicalDomain: 'users', + section: 'messages', + }), + customPage({ + key: 'users-ban', + name: 'UsersBan', + label: '禁封管理', + route: '/users/ban', + component: 'UsersBan', + status: 'available', + title: '禁封管理', + desc: '查看和管理用户封禁、限制状态。', + canonicalDomain: 'users', + section: 'ban', + }), + customPage({ + key: 'users-report', + name: 'UsersReport', + label: '禁封/举报', + route: '/users/report', + component: 'UsersReport', + status: 'available', + title: '禁封/举报', + desc: '举报受理、用户封禁和治理台账。', + canonicalDomain: 'users', + section: 'report', + }), + ], +} diff --git a/assets/admin/src/constants/settings-defaults.js b/assets/admin/src/constants/settings-defaults.js new file mode 100644 index 00000000..9c4b5b3c --- /dev/null +++ b/assets/admin/src/constants/settings-defaults.js @@ -0,0 +1,28 @@ +import { ALL_SCHEMAS, CANONICAL_SCHEMA_DOMAINS, normalizeSchemaDomain } from '@/schema/index.js' + +function cloneDefaults(defaults = {}) { + return JSON.parse(JSON.stringify(defaults)) +} + +export function getSectionDefaults(domain, section) { + const domainKey = normalizeSchemaDomain(domain) + const sectionSchema = ALL_SCHEMAS[domainKey]?.[section] + return cloneDefaults(sectionSchema?.defaults || {}) +} + +export function getDomainDefaults(domain) { + const domainKey = normalizeSchemaDomain(domain) + const sections = Object.values(ALL_SCHEMAS[domainKey] || {}) + + return sections.reduce((defaults, schema) => { + return { + ...defaults, + ...cloneDefaults(schema?.defaults || {}), + } + }, {}) +} + +export const SETTINGS_DEFAULTS_BY_DOMAIN = CANONICAL_SCHEMA_DOMAINS.reduce((defaults, domain) => { + defaults[domain] = getDomainDefaults(domain) + return defaults +}, {}) diff --git a/assets/admin/src/main.js b/assets/admin/src/main.js new file mode 100644 index 00000000..a8c872aa --- /dev/null +++ b/assets/admin/src/main.js @@ -0,0 +1,101 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import NaiveUI from 'naive-ui' +import App from './App.vue' +import router from './router' +import './style.css' +import { styleMountTarget } from './style-target' + +const app = createApp(App) +app.use(createPinia()) +app.use(router) +app.use(NaiveUI) + +// 全局错误处理 +app.config.errorHandler = (err, vm, info) => { + console.error('[AKSRT] 全局错误:', err, info) +} + +// 兜底:立即显示加载状态(防止白屏) +function showFallback(el) { + el.innerHTML = '
' + + '

AKSRT 正在加载...

' + + '

如果长时间停留在此页面,请按 F12 查看控制台错误

' + + '
' +} +showFallback(document.getElementById('aksrt-admin-app') || document.body.appendChild( + Object.assign(document.createElement('div'), { id: 'aksrt-admin-app' }) +)) + +/** + * 获取或创建一个干净的挂载容器 + * 核心问题:WordPress 后台的 jQuery/脚本会在页面加载后持续操作 DOM, + * 导致 PHP 输出的
可能被外部修改或其兄弟节点被移除, + * Vue patch 时遍历到 null 节点 → nextSibling 报错。 + * + * 解决方案:在挂载前一刻,动态创建一个全新的容器元素替换旧的, + * 确保该节点及其父级在 Vue 接管前不被任何外部脚本污染。 + */ +function getCleanContainer() { + const existing = document.getElementById('aksrt-admin-app') + if (!existing) { + // 极端情况:PHP 没有输出容器,手动创建 + const fresh = document.createElement('div') + fresh.id = 'aksrt-admin-app' + document.body.appendChild(fresh) + return fresh + } + + // 创建全新容器替换旧容器 + // 这确保新容器没有 WP 脚本留下的任何副作用(事件监听、属性等) + const parent = existing.parentNode + if (!parent) { + // 旧容器已脱离文档树,重新创建 + const fresh = document.createElement('div') + fresh.id = 'aksrt-admin-app' + document.body.appendChild(fresh) + return fresh + } + + const fresh = document.createElement('div') + fresh.id = 'aksrt-admin-app' + + // 将旧容器的位置信息转移给新容器(保留 fallback 内容) + fresh.innerHTML = existing.innerHTML || '' + parent.replaceChild(fresh, existing) + + return fresh +} + +/** + * 显示错误信息 + */ +function showError(container, err) { + container.innerHTML = '
' + + '

页面加载失败

' + + '

' + (err.message || String(err)) + '

' + + '' + + '
' +} + +// 等待 router 就绪后延迟挂载 +router.isReady().then(() => { + // 三重 rAF:确保 load-scripts.php 加载的所有 jQuery 插件完成初始化和 DOM 操作 + requestAnimationFrame(() => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + try { + const container = getCleanContainer() + // 关键:在挂载前设置样式注入目标,确保 NaiveUI 组件渲染时目标已就绪 + styleMountTarget.value = container + app.mount(container) + } catch (err) { + console.error('[AKSRT] Vue 挂载失败:', err) + showError(getCleanContainer(), err) + } + }) + }) + }) +}).catch((err) => { + console.error('[AKSRT] Router 初始化失败:', err) +}) diff --git a/assets/admin/src/pages/CmsCredits.vue b/assets/admin/src/pages/CmsCredits.vue new file mode 100644 index 00000000..4fdcfd9a --- /dev/null +++ b/assets/admin/src/pages/CmsCredits.vue @@ -0,0 +1,165 @@ + + + diff --git a/assets/admin/src/pages/ResourceDownload.vue b/assets/admin/src/pages/ResourceDownload.vue new file mode 100644 index 00000000..1474ee15 --- /dev/null +++ b/assets/admin/src/pages/ResourceDownload.vue @@ -0,0 +1,238 @@ + + + diff --git a/assets/admin/src/pages/SchemaPage.vue b/assets/admin/src/pages/SchemaPage.vue new file mode 100644 index 00000000..8ded7c11 --- /dev/null +++ b/assets/admin/src/pages/SchemaPage.vue @@ -0,0 +1,508 @@ + + + + + diff --git a/assets/admin/src/pages/SiteSettings.vue b/assets/admin/src/pages/SiteSettings.vue new file mode 100644 index 00000000..f9f170b0 --- /dev/null +++ b/assets/admin/src/pages/SiteSettings.vue @@ -0,0 +1,211 @@ + + + diff --git a/assets/admin/src/pages/content/BannerManager.vue b/assets/admin/src/pages/content/BannerManager.vue new file mode 100644 index 00000000..b11820cc --- /dev/null +++ b/assets/admin/src/pages/content/BannerManager.vue @@ -0,0 +1,392 @@ + + + + + diff --git a/assets/admin/src/pages/content/FloatingButtonsSettings.vue b/assets/admin/src/pages/content/FloatingButtonsSettings.vue new file mode 100644 index 00000000..bae1c629 --- /dev/null +++ b/assets/admin/src/pages/content/FloatingButtonsSettings.vue @@ -0,0 +1,1003 @@ + + + + + diff --git a/assets/admin/src/pages/content/FooterSettings.vue b/assets/admin/src/pages/content/FooterSettings.vue new file mode 100644 index 00000000..da5caa0e --- /dev/null +++ b/assets/admin/src/pages/content/FooterSettings.vue @@ -0,0 +1,988 @@ + + + + + diff --git a/assets/admin/src/pages/content/FooterTabbarSettings.vue b/assets/admin/src/pages/content/FooterTabbarSettings.vue new file mode 100644 index 00000000..041ca205 --- /dev/null +++ b/assets/admin/src/pages/content/FooterTabbarSettings.vue @@ -0,0 +1,790 @@ + + + + + diff --git a/assets/admin/src/pages/content/HeroManager.vue b/assets/admin/src/pages/content/HeroManager.vue new file mode 100644 index 00000000..cb3951bb --- /dev/null +++ b/assets/admin/src/pages/content/HeroManager.vue @@ -0,0 +1,548 @@ + + + + + diff --git a/assets/admin/src/pages/content/HomepageSettings.vue b/assets/admin/src/pages/content/HomepageSettings.vue new file mode 100644 index 00000000..7139feff --- /dev/null +++ b/assets/admin/src/pages/content/HomepageSettings.vue @@ -0,0 +1,617 @@ + + + + + diff --git a/assets/admin/src/pages/content/NotificationCenterSettings.vue b/assets/admin/src/pages/content/NotificationCenterSettings.vue new file mode 100644 index 00000000..e108dfcf --- /dev/null +++ b/assets/admin/src/pages/content/NotificationCenterSettings.vue @@ -0,0 +1,155 @@ + + + diff --git a/assets/admin/src/pages/docs/DocsPage.vue b/assets/admin/src/pages/docs/DocsPage.vue new file mode 100644 index 00000000..b82000a8 --- /dev/null +++ b/assets/admin/src/pages/docs/DocsPage.vue @@ -0,0 +1,354 @@ + + + + + diff --git a/assets/admin/src/pages/extensions/ExtensionAI.vue b/assets/admin/src/pages/extensions/ExtensionAI.vue new file mode 100644 index 00000000..c248ba34 --- /dev/null +++ b/assets/admin/src/pages/extensions/ExtensionAI.vue @@ -0,0 +1,314 @@ + + + + + diff --git a/assets/admin/src/pages/extensions/ExtensionDocsMode.vue b/assets/admin/src/pages/extensions/ExtensionDocsMode.vue new file mode 100644 index 00000000..781d216c --- /dev/null +++ b/assets/admin/src/pages/extensions/ExtensionDocsMode.vue @@ -0,0 +1,311 @@ + + + + + diff --git a/assets/admin/src/pages/extensions/ExtensionSecurity.vue b/assets/admin/src/pages/extensions/ExtensionSecurity.vue new file mode 100644 index 00000000..657d2c2e --- /dev/null +++ b/assets/admin/src/pages/extensions/ExtensionSecurity.vue @@ -0,0 +1,293 @@ + + + + + diff --git a/assets/admin/src/pages/extensions/ExtensionTools.vue b/assets/admin/src/pages/extensions/ExtensionTools.vue new file mode 100644 index 00000000..427f37e0 --- /dev/null +++ b/assets/admin/src/pages/extensions/ExtensionTools.vue @@ -0,0 +1,271 @@ + + + + + diff --git a/assets/admin/src/pages/extensions/ExtensionsSectionPage.vue b/assets/admin/src/pages/extensions/ExtensionsSectionPage.vue new file mode 100644 index 00000000..e31e033c --- /dev/null +++ b/assets/admin/src/pages/extensions/ExtensionsSectionPage.vue @@ -0,0 +1,39 @@ + + + diff --git a/assets/admin/src/pages/forum/ForumBoards.vue b/assets/admin/src/pages/forum/ForumBoards.vue new file mode 100644 index 00000000..7852b522 --- /dev/null +++ b/assets/admin/src/pages/forum/ForumBoards.vue @@ -0,0 +1,195 @@ + + + + + diff --git a/assets/admin/src/pages/forum/ForumDashboard.vue b/assets/admin/src/pages/forum/ForumDashboard.vue new file mode 100644 index 00000000..a553b716 --- /dev/null +++ b/assets/admin/src/pages/forum/ForumDashboard.vue @@ -0,0 +1,223 @@ + + + + + diff --git a/assets/admin/src/pages/forum/ForumModeratorApplications.vue b/assets/admin/src/pages/forum/ForumModeratorApplications.vue new file mode 100644 index 00000000..4fd9adce --- /dev/null +++ b/assets/admin/src/pages/forum/ForumModeratorApplications.vue @@ -0,0 +1,201 @@ + + + + + diff --git a/assets/admin/src/pages/forum/ForumModerators.vue b/assets/admin/src/pages/forum/ForumModerators.vue new file mode 100644 index 00000000..174b68e0 --- /dev/null +++ b/assets/admin/src/pages/forum/ForumModerators.vue @@ -0,0 +1,363 @@ + + + + + diff --git a/assets/admin/src/pages/forum/ForumTopics.vue b/assets/admin/src/pages/forum/ForumTopics.vue new file mode 100644 index 00000000..8c1f9522 --- /dev/null +++ b/assets/admin/src/pages/forum/ForumTopics.vue @@ -0,0 +1,186 @@ + + + + + diff --git a/assets/admin/src/pages/forum/components/TopicModerationDrawer.vue b/assets/admin/src/pages/forum/components/TopicModerationDrawer.vue new file mode 100644 index 00000000..55a8cfb4 --- /dev/null +++ b/assets/admin/src/pages/forum/components/TopicModerationDrawer.vue @@ -0,0 +1,284 @@ + + + + + diff --git a/assets/admin/src/pages/payment/PaymentAffiliate.vue b/assets/admin/src/pages/payment/PaymentAffiliate.vue new file mode 100644 index 00000000..3dd61e53 --- /dev/null +++ b/assets/admin/src/pages/payment/PaymentAffiliate.vue @@ -0,0 +1,16 @@ + + + diff --git a/assets/admin/src/pages/payment/PaymentChargeCard.vue b/assets/admin/src/pages/payment/PaymentChargeCard.vue new file mode 100644 index 00000000..965df4f2 --- /dev/null +++ b/assets/admin/src/pages/payment/PaymentChargeCard.vue @@ -0,0 +1,453 @@ + + + + + diff --git a/assets/admin/src/pages/payment/PaymentGateway.vue b/assets/admin/src/pages/payment/PaymentGateway.vue new file mode 100644 index 00000000..dbfafefa --- /dev/null +++ b/assets/admin/src/pages/payment/PaymentGateway.vue @@ -0,0 +1,179 @@ + + + + + diff --git a/assets/admin/src/pages/payment/PaymentRebate.vue b/assets/admin/src/pages/payment/PaymentRebate.vue new file mode 100644 index 00000000..63703886 --- /dev/null +++ b/assets/admin/src/pages/payment/PaymentRebate.vue @@ -0,0 +1,316 @@ + + + + + diff --git a/assets/admin/src/pages/payment/PaymentRecharge.vue b/assets/admin/src/pages/payment/PaymentRecharge.vue new file mode 100644 index 00000000..f9c3eaa0 --- /dev/null +++ b/assets/admin/src/pages/payment/PaymentRecharge.vue @@ -0,0 +1,16 @@ + + + diff --git a/assets/admin/src/pages/payment/PaymentRevenue.vue b/assets/admin/src/pages/payment/PaymentRevenue.vue new file mode 100644 index 00000000..87b75c5c --- /dev/null +++ b/assets/admin/src/pages/payment/PaymentRevenue.vue @@ -0,0 +1,249 @@ + + + + + diff --git a/assets/admin/src/pages/payment/PaymentSectionPage.vue b/assets/admin/src/pages/payment/PaymentSectionPage.vue new file mode 100644 index 00000000..83cbbd75 --- /dev/null +++ b/assets/admin/src/pages/payment/PaymentSectionPage.vue @@ -0,0 +1,103 @@ + + + diff --git a/assets/admin/src/pages/payment/PaymentVip.vue b/assets/admin/src/pages/payment/PaymentVip.vue new file mode 100644 index 00000000..9f0f09a6 --- /dev/null +++ b/assets/admin/src/pages/payment/PaymentVip.vue @@ -0,0 +1,16 @@ + + + diff --git a/assets/admin/src/pages/payment/components/ChargeCardDetailDrawer.vue b/assets/admin/src/pages/payment/components/ChargeCardDetailDrawer.vue new file mode 100644 index 00000000..552ade6d --- /dev/null +++ b/assets/admin/src/pages/payment/components/ChargeCardDetailDrawer.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/assets/admin/src/pages/payment/components/RebateDetailDrawer.vue b/assets/admin/src/pages/payment/components/RebateDetailDrawer.vue new file mode 100644 index 00000000..a61b2596 --- /dev/null +++ b/assets/admin/src/pages/payment/components/RebateDetailDrawer.vue @@ -0,0 +1,175 @@ + + + + + diff --git a/assets/admin/src/pages/permissions/AttachmentPermissions.vue b/assets/admin/src/pages/permissions/AttachmentPermissions.vue new file mode 100644 index 00000000..5f55cce3 --- /dev/null +++ b/assets/admin/src/pages/permissions/AttachmentPermissions.vue @@ -0,0 +1,283 @@ + + + + + diff --git a/assets/admin/src/pages/permissions/CommentPermissions.vue b/assets/admin/src/pages/permissions/CommentPermissions.vue new file mode 100644 index 00000000..7ca00a62 --- /dev/null +++ b/assets/admin/src/pages/permissions/CommentPermissions.vue @@ -0,0 +1,150 @@ + + + diff --git a/assets/admin/src/pages/permissions/DownloadPermissions.vue b/assets/admin/src/pages/permissions/DownloadPermissions.vue new file mode 100644 index 00000000..638cbae0 --- /dev/null +++ b/assets/admin/src/pages/permissions/DownloadPermissions.vue @@ -0,0 +1,161 @@ + + + diff --git a/assets/admin/src/pages/permissions/PermissionsCapabilities.vue b/assets/admin/src/pages/permissions/PermissionsCapabilities.vue new file mode 100644 index 00000000..a7bfcda3 --- /dev/null +++ b/assets/admin/src/pages/permissions/PermissionsCapabilities.vue @@ -0,0 +1,245 @@ + + + + + diff --git a/assets/admin/src/pages/post-list/CommentSettings.vue b/assets/admin/src/pages/post-list/CommentSettings.vue new file mode 100644 index 00000000..815ec391 --- /dev/null +++ b/assets/admin/src/pages/post-list/CommentSettings.vue @@ -0,0 +1,7 @@ + + + diff --git a/assets/admin/src/pages/post-list/PostPageSettings.vue b/assets/admin/src/pages/post-list/PostPageSettings.vue new file mode 100644 index 00000000..8d901ac4 --- /dev/null +++ b/assets/admin/src/pages/post-list/PostPageSettings.vue @@ -0,0 +1,7 @@ + + + diff --git a/assets/admin/src/pages/shop/ShopAfterSale.vue b/assets/admin/src/pages/shop/ShopAfterSale.vue new file mode 100644 index 00000000..816da3f7 --- /dev/null +++ b/assets/admin/src/pages/shop/ShopAfterSale.vue @@ -0,0 +1,304 @@ + + + + + diff --git a/assets/admin/src/pages/shop/ShopCoupons.vue b/assets/admin/src/pages/shop/ShopCoupons.vue new file mode 100644 index 00000000..9b69ca6a --- /dev/null +++ b/assets/admin/src/pages/shop/ShopCoupons.vue @@ -0,0 +1,215 @@ + + + + + diff --git a/assets/admin/src/pages/shop/ShopDashboard.vue b/assets/admin/src/pages/shop/ShopDashboard.vue new file mode 100644 index 00000000..43a48f4b --- /dev/null +++ b/assets/admin/src/pages/shop/ShopDashboard.vue @@ -0,0 +1,180 @@ + + + + + diff --git a/assets/admin/src/pages/shop/ShopOrders.vue b/assets/admin/src/pages/shop/ShopOrders.vue new file mode 100644 index 00000000..7f14c2ba --- /dev/null +++ b/assets/admin/src/pages/shop/ShopOrders.vue @@ -0,0 +1,173 @@ + + + + + diff --git a/assets/admin/src/pages/shop/ShopProducts.vue b/assets/admin/src/pages/shop/ShopProducts.vue new file mode 100644 index 00000000..91c8040e --- /dev/null +++ b/assets/admin/src/pages/shop/ShopProducts.vue @@ -0,0 +1,147 @@ + + + + + diff --git a/assets/admin/src/pages/shop/ShopShipping.vue b/assets/admin/src/pages/shop/ShopShipping.vue new file mode 100644 index 00000000..58971363 --- /dev/null +++ b/assets/admin/src/pages/shop/ShopShipping.vue @@ -0,0 +1,364 @@ + + + + + diff --git a/assets/admin/src/pages/shop/ShopWithdrawals.vue b/assets/admin/src/pages/shop/ShopWithdrawals.vue new file mode 100644 index 00000000..d831e556 --- /dev/null +++ b/assets/admin/src/pages/shop/ShopWithdrawals.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/assets/admin/src/pages/shop/components/AfterSaleDetailDrawer.vue b/assets/admin/src/pages/shop/components/AfterSaleDetailDrawer.vue new file mode 100644 index 00000000..1d3b888a --- /dev/null +++ b/assets/admin/src/pages/shop/components/AfterSaleDetailDrawer.vue @@ -0,0 +1,283 @@ + + + + + diff --git a/assets/admin/src/pages/shop/components/OrderContextPanel.vue b/assets/admin/src/pages/shop/components/OrderContextPanel.vue new file mode 100644 index 00000000..b5846b1b --- /dev/null +++ b/assets/admin/src/pages/shop/components/OrderContextPanel.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/assets/admin/src/pages/shop/components/OrderDetailDrawer.vue b/assets/admin/src/pages/shop/components/OrderDetailDrawer.vue new file mode 100644 index 00000000..c3f169db --- /dev/null +++ b/assets/admin/src/pages/shop/components/OrderDetailDrawer.vue @@ -0,0 +1,440 @@ + + + + + diff --git a/assets/admin/src/pages/shop/components/ProductEditorDrawer.vue b/assets/admin/src/pages/shop/components/ProductEditorDrawer.vue new file mode 100644 index 00000000..23105d4f --- /dev/null +++ b/assets/admin/src/pages/shop/components/ProductEditorDrawer.vue @@ -0,0 +1,263 @@ + + + diff --git a/assets/admin/src/pages/shop/components/ShippingBatchActionDialog.vue b/assets/admin/src/pages/shop/components/ShippingBatchActionDialog.vue new file mode 100644 index 00000000..b94b8242 --- /dev/null +++ b/assets/admin/src/pages/shop/components/ShippingBatchActionDialog.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/assets/admin/src/pages/shop/components/ShippingDetailDrawer.vue b/assets/admin/src/pages/shop/components/ShippingDetailDrawer.vue new file mode 100644 index 00000000..1c135f56 --- /dev/null +++ b/assets/admin/src/pages/shop/components/ShippingDetailDrawer.vue @@ -0,0 +1,237 @@ + + + + + diff --git a/assets/admin/src/pages/shop/components/WithdrawalReviewModal.vue b/assets/admin/src/pages/shop/components/WithdrawalReviewModal.vue new file mode 100644 index 00000000..ca23ce13 --- /dev/null +++ b/assets/admin/src/pages/shop/components/WithdrawalReviewModal.vue @@ -0,0 +1,68 @@ + + + diff --git a/assets/admin/src/pages/theme/ThemeAuthorizationPage.vue b/assets/admin/src/pages/theme/ThemeAuthorizationPage.vue new file mode 100644 index 00000000..79ea0c6f --- /dev/null +++ b/assets/admin/src/pages/theme/ThemeAuthorizationPage.vue @@ -0,0 +1,42 @@ + + + diff --git a/assets/admin/src/pages/theme/ThemeInfoPage.vue b/assets/admin/src/pages/theme/ThemeInfoPage.vue new file mode 100644 index 00000000..3ee010f9 --- /dev/null +++ b/assets/admin/src/pages/theme/ThemeInfoPage.vue @@ -0,0 +1,274 @@ + + + + + diff --git a/assets/admin/src/pages/tools/ToolsBackup.vue b/assets/admin/src/pages/tools/ToolsBackup.vue new file mode 100644 index 00000000..489ee362 --- /dev/null +++ b/assets/admin/src/pages/tools/ToolsBackup.vue @@ -0,0 +1,279 @@ + + + + + diff --git a/assets/admin/src/pages/tools/ToolsEmailRollback.vue b/assets/admin/src/pages/tools/ToolsEmailRollback.vue new file mode 100644 index 00000000..974d00a0 --- /dev/null +++ b/assets/admin/src/pages/tools/ToolsEmailRollback.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/assets/admin/src/pages/tools/ToolsHealth.vue b/assets/admin/src/pages/tools/ToolsHealth.vue new file mode 100644 index 00000000..7662bf9c --- /dev/null +++ b/assets/admin/src/pages/tools/ToolsHealth.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/assets/admin/src/pages/tools/ToolsImportExport.vue b/assets/admin/src/pages/tools/ToolsImportExport.vue new file mode 100644 index 00000000..b9290a0f --- /dev/null +++ b/assets/admin/src/pages/tools/ToolsImportExport.vue @@ -0,0 +1,191 @@ + + + + + diff --git a/assets/admin/src/pages/tools/ToolsMigration.vue b/assets/admin/src/pages/tools/ToolsMigration.vue new file mode 100644 index 00000000..f8137f8c --- /dev/null +++ b/assets/admin/src/pages/tools/ToolsMigration.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/assets/admin/src/pages/tools/ToolsUpdate.vue b/assets/admin/src/pages/tools/ToolsUpdate.vue new file mode 100644 index 00000000..a315b7b0 --- /dev/null +++ b/assets/admin/src/pages/tools/ToolsUpdate.vue @@ -0,0 +1,201 @@ + + + + + diff --git a/assets/admin/src/pages/users/UsersBadge.vue b/assets/admin/src/pages/users/UsersBadge.vue new file mode 100644 index 00000000..2b2363bb --- /dev/null +++ b/assets/admin/src/pages/users/UsersBadge.vue @@ -0,0 +1,173 @@ + + + diff --git a/assets/admin/src/pages/users/UsersBan.vue b/assets/admin/src/pages/users/UsersBan.vue new file mode 100644 index 00000000..89c3caba --- /dev/null +++ b/assets/admin/src/pages/users/UsersBan.vue @@ -0,0 +1,210 @@ + + + diff --git a/assets/admin/src/pages/users/UsersFeatures.vue b/assets/admin/src/pages/users/UsersFeatures.vue new file mode 100644 index 00000000..c7c71592 --- /dev/null +++ b/assets/admin/src/pages/users/UsersFeatures.vue @@ -0,0 +1,195 @@ + + + diff --git a/assets/admin/src/pages/users/UsersInviteCodes.vue b/assets/admin/src/pages/users/UsersInviteCodes.vue new file mode 100644 index 00000000..e176c4ff --- /dev/null +++ b/assets/admin/src/pages/users/UsersInviteCodes.vue @@ -0,0 +1,512 @@ + + + + + diff --git a/assets/admin/src/pages/users/UsersLevel.vue b/assets/admin/src/pages/users/UsersLevel.vue new file mode 100644 index 00000000..fb8cfe0c --- /dev/null +++ b/assets/admin/src/pages/users/UsersLevel.vue @@ -0,0 +1,187 @@ + + + diff --git a/assets/admin/src/pages/users/UsersMessages.vue b/assets/admin/src/pages/users/UsersMessages.vue new file mode 100644 index 00000000..5aa866eb --- /dev/null +++ b/assets/admin/src/pages/users/UsersMessages.vue @@ -0,0 +1,308 @@ + + + + + diff --git a/assets/admin/src/pages/users/UsersReport.vue b/assets/admin/src/pages/users/UsersReport.vue new file mode 100644 index 00000000..89e01d1d --- /dev/null +++ b/assets/admin/src/pages/users/UsersReport.vue @@ -0,0 +1,357 @@ + + + + + diff --git a/assets/admin/src/pages/users/UsersSectionPage.vue b/assets/admin/src/pages/users/UsersSectionPage.vue new file mode 100644 index 00000000..c812af04 --- /dev/null +++ b/assets/admin/src/pages/users/UsersSectionPage.vue @@ -0,0 +1,40 @@ + + + diff --git a/assets/admin/src/pages/users/UsersSignin.vue b/assets/admin/src/pages/users/UsersSignin.vue new file mode 100644 index 00000000..4f8c0b9c --- /dev/null +++ b/assets/admin/src/pages/users/UsersSignin.vue @@ -0,0 +1,308 @@ + + + diff --git a/assets/admin/src/pages/users/UsersVerify.vue b/assets/admin/src/pages/users/UsersVerify.vue new file mode 100644 index 00000000..c133fb96 --- /dev/null +++ b/assets/admin/src/pages/users/UsersVerify.vue @@ -0,0 +1,338 @@ + + + + + diff --git a/assets/admin/src/pages/users/UsersWechatTemplate.vue b/assets/admin/src/pages/users/UsersWechatTemplate.vue new file mode 100644 index 00000000..ca5e5a7b --- /dev/null +++ b/assets/admin/src/pages/users/UsersWechatTemplate.vue @@ -0,0 +1,258 @@ + + + + + diff --git a/assets/admin/src/pages/users/components/ReportDetailDrawer.vue b/assets/admin/src/pages/users/components/ReportDetailDrawer.vue new file mode 100644 index 00000000..89232c3c --- /dev/null +++ b/assets/admin/src/pages/users/components/ReportDetailDrawer.vue @@ -0,0 +1,242 @@ + + + + + diff --git a/assets/admin/src/pages/users/components/VerifyReviewDrawer.vue b/assets/admin/src/pages/users/components/VerifyReviewDrawer.vue new file mode 100644 index 00000000..3b1939a2 --- /dev/null +++ b/assets/admin/src/pages/users/components/VerifyReviewDrawer.vue @@ -0,0 +1,180 @@ + + + + + diff --git a/assets/admin/src/router/component-loaders/core.js b/assets/admin/src/router/component-loaders/core.js new file mode 100644 index 00000000..5d52c129 --- /dev/null +++ b/assets/admin/src/router/component-loaders/core.js @@ -0,0 +1,19 @@ +export const corePageLoaders = { + SchemaPage: () => import('@/pages/SchemaPage.vue'), + DeferredPage: () => import('@/components/DeferredPage.vue'), + FloatingButtonsSettings: () => import('@/pages/content/FloatingButtonsSettings.vue'), + FooterTabbarSettings: () => import('@/pages/content/FooterTabbarSettings.vue'), + FooterSettings: () => import('@/pages/content/FooterSettings.vue'), + HomepageSettings: () => import('@/pages/content/HomepageSettings.vue'), + PostPageSettings: () => import('@/pages/post-list/PostPageSettings.vue'), + CommentSettings: () => import('@/pages/post-list/CommentSettings.vue'), + CommentPermissions: () => import('@/pages/permissions/CommentPermissions.vue'), + PermissionsCapabilities: () => import('@/pages/permissions/PermissionsCapabilities.vue'), + AttachmentPermissions: () => import('@/pages/permissions/AttachmentPermissions.vue'), + DownloadPermissions: () => import('@/pages/permissions/DownloadPermissions.vue'), + CmsCredits: () => import('@/pages/CmsCredits.vue'), + ResourceDownload: () => import('@/pages/ResourceDownload.vue'), + ThemeInfoPage: () => import('@/pages/theme/ThemeInfoPage.vue'), + ThemeAuthorizationPage: () => import('@/pages/theme/ThemeAuthorizationPage.vue'), + DocsPage: () => import('@/pages/docs/DocsPage.vue'), +} diff --git a/assets/admin/src/router/component-loaders/extensions.js b/assets/admin/src/router/component-loaders/extensions.js new file mode 100644 index 00000000..efa2ed99 --- /dev/null +++ b/assets/admin/src/router/component-loaders/extensions.js @@ -0,0 +1,5 @@ +export const extensionsPageLoaders = { + ExtensionTools: () => import('@/pages/extensions/ExtensionTools.vue'), + ExtensionSecurity: () => import('@/pages/extensions/ExtensionSecurity.vue'), + ExtensionAI: () => import('@/pages/extensions/ExtensionAI.vue'), +} diff --git a/assets/admin/src/router/component-loaders/forum.js b/assets/admin/src/router/component-loaders/forum.js new file mode 100644 index 00000000..733a8a59 --- /dev/null +++ b/assets/admin/src/router/component-loaders/forum.js @@ -0,0 +1,7 @@ +export const forumPageLoaders = { + ForumDashboard: () => import('@/pages/forum/ForumDashboard.vue'), + ForumBoards: () => import('@/pages/forum/ForumBoards.vue'), + ForumTopics: () => import('@/pages/forum/ForumTopics.vue'), + ForumModerators: () => import('@/pages/forum/ForumModerators.vue'), + ForumModeratorApplications: () => import('@/pages/forum/ForumModeratorApplications.vue'), +} diff --git a/assets/admin/src/router/component-loaders/index.js b/assets/admin/src/router/component-loaders/index.js new file mode 100644 index 00000000..d7aad84d --- /dev/null +++ b/assets/admin/src/router/component-loaders/index.js @@ -0,0 +1,17 @@ +import { corePageLoaders } from './core' +import { usersPageLoaders } from './users' +import { paymentPageLoaders } from './payment' +import { toolsPageLoaders } from './tools' +import { shopPageLoaders } from './shop' +import { forumPageLoaders } from './forum' +import { extensionsPageLoaders } from './extensions' + +export const componentLoaders = { + ...corePageLoaders, + ...usersPageLoaders, + ...paymentPageLoaders, + ...toolsPageLoaders, + ...shopPageLoaders, + ...forumPageLoaders, + ...extensionsPageLoaders, +} diff --git a/assets/admin/src/router/component-loaders/payment.js b/assets/admin/src/router/component-loaders/payment.js new file mode 100644 index 00000000..91d9c6f3 --- /dev/null +++ b/assets/admin/src/router/component-loaders/payment.js @@ -0,0 +1,6 @@ +export const paymentPageLoaders = { + PaymentRevenue: () => import('@/pages/payment/PaymentRevenue.vue'), + PaymentGateway: () => import('@/pages/payment/PaymentGateway.vue'), + PaymentChargeCard: () => import('@/pages/payment/PaymentChargeCard.vue'), + PaymentRebate: () => import('@/pages/payment/PaymentRebate.vue'), +} diff --git a/assets/admin/src/router/component-loaders/shop.js b/assets/admin/src/router/component-loaders/shop.js new file mode 100644 index 00000000..246eee49 --- /dev/null +++ b/assets/admin/src/router/component-loaders/shop.js @@ -0,0 +1,9 @@ +export const shopPageLoaders = { + ShopDashboard: () => import('@/pages/shop/ShopDashboard.vue'), + ShopProducts: () => import('@/pages/shop/ShopProducts.vue'), + ShopOrders: () => import('@/pages/shop/ShopOrders.vue'), + ShopWithdrawals: () => import('@/pages/shop/ShopWithdrawals.vue'), + ShopCoupons: () => import('@/pages/shop/ShopCoupons.vue'), + ShopAfterSale: () => import('@/pages/shop/ShopAfterSale.vue'), + ShopShipping: () => import('@/pages/shop/ShopShipping.vue'), +} diff --git a/assets/admin/src/router/component-loaders/tools.js b/assets/admin/src/router/component-loaders/tools.js new file mode 100644 index 00000000..b7d1fdbc --- /dev/null +++ b/assets/admin/src/router/component-loaders/tools.js @@ -0,0 +1,7 @@ +export const toolsPageLoaders = { + ToolsImportExport: () => import('@/pages/tools/ToolsImportExport.vue'), + ToolsHealth: () => import('@/pages/tools/ToolsHealth.vue'), + ToolsMigration: () => import('@/pages/tools/ToolsMigration.vue'), + ToolsEmailRollback: () => import('@/pages/tools/ToolsEmailRollback.vue'), + ToolsUpdate: () => import('@/pages/tools/ToolsUpdate.vue'), +} diff --git a/assets/admin/src/router/component-loaders/users.js b/assets/admin/src/router/component-loaders/users.js new file mode 100644 index 00000000..54b2583d --- /dev/null +++ b/assets/admin/src/router/component-loaders/users.js @@ -0,0 +1,12 @@ +export const usersPageLoaders = { + UsersFeatures: () => import('@/pages/users/UsersFeatures.vue'), + UsersSignin: () => import('@/pages/users/UsersSignin.vue'), + UsersInviteCodes: () => import('@/pages/users/UsersInviteCodes.vue'), + UsersVerify: () => import('@/pages/users/UsersVerify.vue'), + UsersLevel: () => import('@/pages/users/UsersLevel.vue'), + UsersBadge: () => import('@/pages/users/UsersBadge.vue'), + UsersReport: () => import('@/pages/users/UsersReport.vue'), + UsersMessages: () => import('@/pages/users/UsersMessages.vue'), + UsersBan: () => import('@/pages/users/UsersBan.vue'), + UsersWechatTemplate: () => import('@/pages/users/UsersWechatTemplate.vue'), +} diff --git a/assets/admin/src/router/index.js b/assets/admin/src/router/index.js new file mode 100644 index 00000000..0a197069 --- /dev/null +++ b/assets/admin/src/router/index.js @@ -0,0 +1,66 @@ +import { createRouter, createWebHashHistory } from 'vue-router' +import { getAllAdminPages } from '@/constants/admin-sections' +import { componentLoaders } from './component-loaders/index.js' + +function resolveComponent(page) { + if (componentLoaders[page.component]) { + return componentLoaders[page.component] + } + + // Log missing component for debugging — avoids silent fallback to DeferredPage + if (page.component && page.component !== 'DeferredPage') { + console.warn( + `[AKSRT Route] Component "${page.component}" not found in componentLoaders for route "${page.route}". Falling back to DeferredPage.` + ) + } + + return componentLoaders.DeferredPage +} + +function createRouteMeta(page) { + const meta = { + title: page.title, + section: page.sectionKey || '', + desc: page.desc || '', + status: page.status || 'available', + } + + if (page.canonicalDomain) { + meta.canonicalDomain = page.canonicalDomain + } + + if (page.section) { + meta.section = page.section + } + + if (page.component === 'DeferredPage') { + meta.deferred = true + meta.deferredProps = { + title: page.title, + description: page.desc || '', + icon: page.iconClass || 'fas fa-compass-drafting', + phase: page.phase || '', + status: page.status || 'deferred', + dependsOn: page.dependsOn || [], + reason: page.reason || '', + features: page.features || [], + } + } + + return meta +} + +const routes = [ + { path: '/', redirect: '/global/common' }, + ...getAllAdminPages().map((page) => ({ + path: page.route, + name: page.name, + component: resolveComponent(page), + meta: createRouteMeta(page), + })), +] + +export default createRouter({ + history: createWebHashHistory(), + routes, +}) diff --git a/assets/admin/src/schema/index.js b/assets/admin/src/schema/index.js new file mode 100644 index 00000000..64f20229 --- /dev/null +++ b/assets/admin/src/schema/index.js @@ -0,0 +1,114 @@ +/** + * Schema Registry — central index for all admin section schemas. + * + * Each section's schema defines the form fields, groups, API endpoints, + * and default values for a settings page. The AksFormRenderer component + * consumes these schemas to render settings pages automatically. + * + * Schema shape per section: + * { + * key: 'global.seo', + * title: 'SEO优化', + * icon: 'fas fa-search', + * description: '...', + * api: { get: () => api.getSeoSettings(), save: (data) => api.saveSeoSettings(data) }, + * defaults: { ... }, + * groups: [ + * { + * key: 'basic', + * title: '基础SEO', + * icon: 'fas fa-globe', + * fields: [ + * { key: 'site_title', label: '站点标题', type: 'input', default: '' }, + * ... + * ] + * } + * ] + * } + */ +import { globalSchemas } from './sections/global/index.js' +import { pageDisplaySchemas } from './sections/page-display/index.js' +import { postListSchemas } from './sections/post-list/index.js' +import { permissionsSchemas } from './sections/permissions/index.js' +import { usersSchemas } from './sections/users/index.js' +import { paymentSchemas } from './sections/payment/index.js' +import { extensionsSchemas } from './sections/extensions/index.js' +import { toolsSchemas } from './sections/tools/index.js' +import { shopSchemas } from './sections/shop/index.js' +import { forumSchemas } from './sections/forum/index.js' + +export function normalizeSchemaDomain(domain = '') { + const value = String(domain).trim() + if (!value) { + return 'global' + } + + return value.replace(/-/g, '_') +} + +export const CANONICAL_SCHEMA_DOMAINS = [ + 'global', + 'page_display', + 'post_list', + 'permissions', + 'users', + 'payment', + 'extensions', + 'tools', + 'shop', + 'forum', +] + +/** All schemas organized by domain */ +export const ALL_SCHEMAS = Object.freeze({ + global: globalSchemas, + page_display: pageDisplaySchemas, + post_list: postListSchemas, + permissions: permissionsSchemas, + users: usersSchemas, + payment: paymentSchemas, + extensions: extensionsSchemas, + tools: toolsSchemas, + shop: shopSchemas, + forum: forumSchemas, +}) + +/** + * Get a specific section schema by its key path. + * @param {string} domain - e.g. 'global' + * @param {string} section - e.g. 'seo' + * @returns {Object|null} + */ +export function getSchema(domain, section) { + const domainSchemas = ALL_SCHEMAS[normalizeSchemaDomain(domain)] + if (!domainSchemas) return null + return domainSchemas[section] || null +} + +/** + * Get all schemas for a domain. + * @param {string} domain + * @returns {Object} + */ +export function getDomainSchemas(domain) { + return ALL_SCHEMAS[normalizeSchemaDomain(domain)] || {} +} + +export function getSchemaSectionKeys(domain) { + return Object.keys(getDomainSchemas(domain)) +} + +export function getSchemaDefaults(domain, section = '') { + const domainKey = normalizeSchemaDomain(domain) + + if (section) { + return { ...(getSchema(domainKey, section)?.defaults || {}) } + } + + return Object.values(getDomainSchemas(domainKey)).reduce((defaults, schema) => { + return { + ...defaults, + ...(schema?.defaults || {}), + } + }, {}) +} diff --git a/assets/admin/src/schema/sections/extensions/audit.js b/assets/admin/src/schema/sections/extensions/audit.js new file mode 100644 index 00000000..c0df6ec9 --- /dev/null +++ b/assets/admin/src/schema/sections/extensions/audit.js @@ -0,0 +1,68 @@ +export const auditSchema = { + key: 'extensions.audit', + title: 'API内容审核', + icon: 'fas fa-shield-virus', + description: '配置第三方内容审核 API 接入、审核范围和失败回退策略。', + defaults: { + content_audit_enabled: false, + content_audit_provider: 'baidu', + content_audit_api_key: '', + content_audit_api_secret: '', + content_audit_text_enabled: true, + content_audit_image_enabled: false, + content_audit_on_failure: 'allow', + content_audit_log_enabled: true, + }, + groups: [ + { + key: 'provider', + title: '审核接口', + icon: 'fas fa-plug', + fields: [ + { key: 'content_audit_enabled', label: '启用内容审核', type: 'switch', default: false, help: '开启后将对接第三方内容审核 API' }, + { + key: 'content_audit_provider', + label: '审核服务商', + type: 'radio', + default: 'baidu', + options: [ + { label: '百度内容审核', value: 'baidu' }, + { label: '关闭审核接口', value: 'null' }, + ], + dependsOn: { key: 'content_audit_enabled', value: true }, + }, + { key: 'content_audit_api_key', label: 'API Key', type: 'input', default: '', dependsOn: { key: 'content_audit_enabled', value: true }, help: '百度智能云 API Key' }, + { key: 'content_audit_api_secret', label: 'Secret Key', type: 'input', default: '', dependsOn: { key: 'content_audit_enabled', value: true }, help: '百度智能云 Secret Key' }, + ], + }, + { + key: 'scope', + title: '审核范围', + icon: 'fas fa-check-double', + fields: [ + { key: 'content_audit_text_enabled', label: '文本审核', type: 'switch', default: true, dependsOn: { key: 'content_audit_enabled', value: true } }, + { key: 'content_audit_image_enabled', label: '图片审核', type: 'switch', default: false, dependsOn: { key: 'content_audit_enabled', value: true } }, + { key: 'audit_bbs_term', label: '审核论坛分类', type: 'switch', default: false, dependsOn: { key: 'content_audit_enabled', value: true } }, + ], + }, + { + key: 'fallback', + title: '失败策略', + icon: 'fas fa-exclamation-triangle', + fields: [ + { + key: 'content_audit_on_failure', + label: '审核失败时', + type: 'radio', + default: 'allow', + options: [ + { label: '放行', value: 'allow' }, + { label: '拦截', value: 'block' }, + ], + dependsOn: { key: 'content_audit_enabled', value: true }, + }, + { key: 'content_audit_log_enabled', label: '记录审核日志', type: 'switch', default: true, dependsOn: { key: 'content_audit_enabled', value: true } }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/extensions/docs-mode.js b/assets/admin/src/schema/sections/extensions/docs-mode.js new file mode 100644 index 00000000..5ca43a35 --- /dev/null +++ b/assets/admin/src/schema/sections/extensions/docs-mode.js @@ -0,0 +1,125 @@ +export const docsModeSchema = { + key: 'extensions.docs-mode', + title: '文档模式', + icon: 'fas fa-book-open', + description: '启用文档专题内容模式,配置文档导航页面和文档站式体验。', + defaults: { + docs_mode_enabled: false, + docs_mode_default_template: 'documentnav', + docs_mode_toc_enabled: true, + docs_mode_chapter_nav_enabled: true, + docs_mode_default_order: 'menu_order', + docs_mode_cats: [], + docs_mode_exclude: true, + docs_mode_toc_depth: 3, + docs_mode_chapter_title: '', + docs_mode_style: 'default', + docs_mode_icon: true, + docs_mode_count: true, + }, + groups: [ + { + key: 'basic', + title: '基础配置', + icon: 'fas fa-cog', + fields: [ + { key: 'docs_mode_enabled', label: '启用文档模式', type: 'switch', default: false, help: '开启后将激活文档导航页面模板和文档站式侧边栏' }, + { + key: 'docs_mode_default_template', + label: '文档首页模板', + type: 'select', + default: 'documentnav', + options: [ + { label: '文档导航页', value: 'documentnav' }, + { label: '专题聚合页', value: 'special' }, + ], + dependsOn: { key: 'docs_mode_enabled', value: true }, + }, + ], + }, + { + key: 'navigation', + title: '导航设置', + icon: 'fas fa-compass', + fields: [ + { key: 'docs_mode_toc_enabled', label: '显示目录树', type: 'switch', default: true, dependsOn: { key: 'docs_mode_enabled', value: true }, help: '在文档页面侧边栏显示章节目录树' }, + { key: 'docs_mode_chapter_nav_enabled', label: '显示章节导航', type: 'switch', default: true, dependsOn: { key: 'docs_mode_enabled', value: true }, help: '在文档底部显示上一章/下一章导航' }, + { + key: 'docs_mode_default_order', + label: '默认排序', + type: 'radio', + default: 'menu_order', + options: [ + { label: '按菜单顺序', value: 'menu_order' }, + { label: '按发布时间', value: 'date' }, + { label: '按标题排序', value: 'title' }, + ], + dependsOn: { key: 'docs_mode_enabled', value: true }, + }, + { + key: 'docs_mode_toc_depth', + label: '目录深度', + type: 'number', + default: 3, + min: 1, + max: 6, + dependsOn: { key: 'docs_mode_enabled', value: true }, + }, + { + key: 'docs_mode_chapter_title', + label: '章节标题', + type: 'input', + default: '', + dependsOn: { key: 'docs_mode_enabled', value: true }, + }, + ], + }, + { + key: 'filter', + title: '筛选设置', + icon: 'fas fa-filter', + fields: [ + { + key: 'docs_mode_cats', + label: '文档分类', + type: 'taxonomy_select', + taxonomy: 'category', + multiple: true, + clearable: true, + default: [], + placeholder: '选择用于文档模式的分类', + dependsOn: { key: 'docs_mode_enabled', value: true }, + }, + { + key: 'docs_mode_exclude', + label: '在首页排除此类内容', + type: 'switch', + default: true, + dependsOn: { key: 'docs_mode_enabled', value: true }, + help: '开启后,所选文档分类的内容不会出现在首页常规文章流中。', + }, + ], + }, + { + key: 'style', + title: '样式设置', + icon: 'fas fa-palette', + fields: [ + { + key: 'docs_mode_style', + label: '文档样式', + type: 'select', + default: 'default', + options: [ + { label: '默认', value: 'default' }, + { label: '简洁', value: 'simple' }, + { label: '卡片', value: 'card' }, + ], + dependsOn: { key: 'docs_mode_enabled', value: true }, + }, + { key: 'docs_mode_icon', label: '显示图标', type: 'switch', default: true, dependsOn: { key: 'docs_mode_enabled', value: true } }, + { key: 'docs_mode_count', label: '显示文章数', type: 'switch', default: true, dependsOn: { key: 'docs_mode_enabled', value: true } }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/extensions/index.js b/assets/admin/src/schema/sections/extensions/index.js new file mode 100644 index 00000000..2aa4850e --- /dev/null +++ b/assets/admin/src/schema/sections/extensions/index.js @@ -0,0 +1,16 @@ +/** + * Extensions & Enhancements domain schemas. + * Each key corresponds to a sub-page under the "扩展&增强" section. + */ + +import { ipSchema } from './ip.js' +import { auditSchema } from './audit.js' +import { pushSchema } from './push.js' +import { docsModeSchema } from './docs-mode.js' + +export const extensionsSchemas = { + ip: ipSchema, + audit: auditSchema, + push: pushSchema, + 'docs-mode': docsModeSchema, +} diff --git a/assets/admin/src/schema/sections/extensions/ip.js b/assets/admin/src/schema/sections/extensions/ip.js new file mode 100644 index 00000000..ab6ede35 --- /dev/null +++ b/assets/admin/src/schema/sections/extensions/ip.js @@ -0,0 +1,86 @@ +export const ipSchema = { + key: 'extensions.ip', + title: 'IP归属地', + icon: 'fas fa-map-marker-alt', + description: '配置 IP 数据源、缓存策略和前台归属地展示。', + defaults: { + ip_geo_enabled: false, + ip_addr_sdk: 'null', + ip_geo_cache_ttl: 86400, + ip_geo_display: ['comment'], + ip_geo_format: 'province_city', + ip_geo_fallback_text: '', + ip_addr_sdk_qq_appkey: '', + ip_addr_sdk_qq_secretkey: '', + ip_addr_sdk_amap_appkey: '', + ip_addr_sdk_amap_secretkey: '', + }, + groups: [ + { + key: 'basic', + title: '基础配置', + icon: 'fas fa-globe', + fields: [ + { key: 'ip_geo_enabled', label: '启用 IP 归属地', type: 'switch', default: false, help: '开启后将在评论、用户主页等位置显示 IP 归属地信息' }, + { + key: 'ip_addr_sdk', + label: 'IP 接口服务商', + type: 'radio', + default: 'null', + options: [ + { label: '轮流查询', value: 'polling' }, + { label: '腾讯位置服务', value: 'qq' }, + { label: '高德位置服务', value: 'amap' }, + { label: '太平洋公共接口', value: 'null' }, + ], + dependsOn: { key: 'ip_geo_enabled', value: true }, + help: '优先使用国内接口,轮流查询会按腾讯→高德→公共接口顺序回退。', + }, + { key: 'ip_geo_cache_ttl', label: '缓存时间 (秒)', type: 'number', default: 86400, min: 3600, max: 604800, dependsOn: { key: 'ip_geo_enabled', value: true }, help: 'IP 查询结果缓存时长,避免频繁请求外部接口' }, + ], + }, + { + key: 'providers', + title: '接口凭据', + icon: 'fas fa-key', + fields: [ + { key: 'ip_addr_sdk_qq_appkey', label: '腾讯 AppKey', type: 'input', default: '', dependsOn: { key: 'ip_addr_sdk', value: 'qq' } }, + { key: 'ip_addr_sdk_qq_secretkey', label: '腾讯 SecretKey', type: 'input', default: '', dependsOn: { key: 'ip_addr_sdk', value: 'qq' } }, + { key: 'ip_addr_sdk_amap_appkey', label: '高德 AppKey', type: 'input', default: '', dependsOn: { key: 'ip_addr_sdk', value: 'amap' } }, + { key: 'ip_addr_sdk_amap_secretkey', label: '高德 SecretKey', type: 'input', default: '', dependsOn: { key: 'ip_addr_sdk', value: 'amap' } }, + ], + }, + { + key: 'display', + title: '展示设置', + icon: 'fas fa-eye', + fields: [ + { + key: 'ip_geo_display', + label: '展示位置', + type: 'checkbox', + default: ['comment'], + options: [ + { label: '评论区', value: 'comment' }, + { label: '用户主页', value: 'user_home' }, + { label: '文章页', value: 'post' }, + ], + dependsOn: { key: 'ip_geo_enabled', value: true }, + }, + { + key: 'ip_geo_format', + label: '显示格式', + type: 'radio', + default: 'province_city', + options: [ + { label: '省份+城市', value: 'province_city' }, + { label: '仅省份', value: 'province' }, + { label: '国家+省份', value: 'country_province' }, + ], + dependsOn: { key: 'ip_geo_enabled', value: true }, + }, + { key: 'ip_geo_fallback_text', label: '查询失败时文案', type: 'input', default: '', placeholder: '留空则不显示', dependsOn: { key: 'ip_geo_enabled', value: true } }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/extensions/push.js b/assets/admin/src/schema/sections/extensions/push.js new file mode 100644 index 00000000..e847d2be --- /dev/null +++ b/assets/admin/src/schema/sections/extensions/push.js @@ -0,0 +1,64 @@ +export const pushSchema = { + key: 'extensions.push', + title: '百度推送', + icon: 'fas fa-paper-plane', + description: '配置搜索引擎主动推送、批量提交和状态追踪。', + defaults: { + baidu_push_enabled: false, + baidu_push_token: '', + baidu_push_auto: true, + baidu_push_on_publish: true, + baidu_push_on_update: false, + baidu_push_batch_size: 50, + baidu_push_log_enabled: true, + xzh_on: false, + xzh_appid: '', + xzh_render_tail: true, + xzh_jsonld_single: true, + xzh_jsonld_page: false, + xzh_jsonld_img: false, + }, + groups: [ + { + key: 'basic', + title: '推送配置', + icon: 'fas fa-rocket', + fields: [ + { key: 'baidu_push_enabled', label: '启用百度推送', type: 'switch', default: false, help: '开启后将自动向百度搜索推送页面链接' }, + { key: 'baidu_push_token', label: '推送 Token', type: 'input', default: '', dependsOn: { key: 'baidu_push_enabled', value: true }, help: '在百度搜索资源平台获取的推送准入 Token' }, + ], + }, + { + key: 'trigger', + title: '触发规则', + icon: 'fas fa-bolt', + fields: [ + { key: 'baidu_push_auto', label: '自动推送 (页面加载时)', type: 'switch', default: true, dependsOn: { key: 'baidu_push_enabled', value: true }, help: '在前台页面中嵌入百度自动推送 JS 代码' }, + { key: 'baidu_push_on_publish', label: '发布时主动推送', type: 'switch', default: true, dependsOn: { key: 'baidu_push_enabled', value: true } }, + { key: 'baidu_push_on_update', label: '更新时推送', type: 'switch', default: false, dependsOn: { key: 'baidu_push_enabled', value: true } }, + ], + }, + { + key: 'xzh', + title: '熊掌号 / JSON-LD', + icon: 'fas fa-paw', + fields: [ + { key: 'xzh_on', label: '启用熊掌号', type: 'switch', default: false }, + { key: 'xzh_appid', label: '百度 AppID', type: 'input', default: '', dependsOn: { key: 'xzh_on', value: true } }, + { key: 'xzh_render_tail', label: '文章底部显示熊掌号尾部', type: 'switch', default: true, dependsOn: { key: 'xzh_on', value: true } }, + { key: 'xzh_jsonld_single', label: '文章页 JSON-LD', type: 'switch', default: true, dependsOn: { key: 'xzh_on', value: true } }, + { key: 'xzh_jsonld_page', label: '页面 JSON-LD', type: 'switch', default: false, dependsOn: { key: 'xzh_on', value: true } }, + { key: 'xzh_jsonld_img', label: '不添加图片', type: 'switch', default: false, dependsOn: { key: 'xzh_on', value: true } }, + ], + }, + { + key: 'advanced', + title: '高级设置', + icon: 'fas fa-cog', + fields: [ + { key: 'baidu_push_batch_size', label: '批量推送条数', type: 'number', default: 50, min: 1, max: 500, dependsOn: { key: 'baidu_push_enabled', value: true } }, + { key: 'baidu_push_log_enabled', label: '记录推送日志', type: 'switch', default: true, dependsOn: { key: 'baidu_push_enabled', value: true } }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/forum/basic.js b/assets/admin/src/schema/sections/forum/basic.js new file mode 100644 index 00000000..c43a8f14 --- /dev/null +++ b/assets/admin/src/schema/sections/forum/basic.js @@ -0,0 +1,28 @@ +export const basicSchema = { + key: 'forum.basic', + title: '基础设置', + icon: 'fas fa-power-off', + description: '论坛总开关与管理员配置。', + defaults: { + bbs_s: true, + bbs_admin_users: [], + }, + groups: [ + { + key: 'switch', + title: '功能开关', + icon: 'fas fa-toggle-on', + fields: [ + { key: 'bbs_s', label: '启用论坛', type: 'switch', default: true, help: '关闭后论坛页面将返回 404' }, + ], + }, + { + key: 'admin', + title: '论坛管理', + icon: 'fas fa-user-shield', + fields: [ + { key: 'bbs_admin_users', label: '论坛管理员', type: 'user_select', multiple: true, default: [], help: '拥有跨版块全部治理权限,绕过 ban' }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/forum/board-page.js b/assets/admin/src/schema/sections/forum/board-page.js new file mode 100644 index 00000000..40133c99 --- /dev/null +++ b/assets/admin/src/schema/sections/forum/board-page.js @@ -0,0 +1,31 @@ +export const boardPageSchema = { + key: 'forum.board_page', + title: '版块页面', + icon: 'fas fa-th-large', + description: '版块页布局、Tab 栏目和移动端交互配置。', + defaults: { + bbs_plate_top_info_s: true, + bbs_plate_tab: [ + { label: '全部', sort: 'date' }, + { label: '推荐', sort: 'featured' }, + { label: '最新回复', sort: 'last_reply' }, + { label: '热门', sort: 'hot' }, + { label: '精华', sort: 'essence' }, + ], + bbs_plate_tab_active_index: 0, + bbs_plate_tab_swiper: true, + }, + groups: [ + { + key: 'layout', + title: '版块页布局', + icon: 'fas fa-layer-group', + fields: [ + { key: 'bbs_plate_top_info_s', label: '版块信息卡', type: 'switch', default: true, help: '版块页顶部显示版块详情卡片' }, + { key: 'bbs_plate_tab', label: '版块栏目', type: 'group', default: [{ label: '全部', sort: 'date' }, { label: '推荐', sort: 'featured' }, { label: '最新回复', sort: 'last_reply' }, { label: '热门', sort: 'hot' }, { label: '精华', sort: 'essence' }] }, + { key: 'bbs_plate_tab_active_index', label: '默认选中 Tab', type: 'number', default: 0, min: 0 }, + { key: 'bbs_plate_tab_swiper', label: '移动端滑动切换', type: 'switch', default: true }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/forum/credits.js b/assets/admin/src/schema/sections/forum/credits.js new file mode 100644 index 00000000..154fabdb --- /dev/null +++ b/assets/admin/src/schema/sections/forum/credits.js @@ -0,0 +1,35 @@ +export const creditsSchema = { + key: 'forum.credits', + title: '积分设置', + icon: 'fas fa-coins', + description: '论坛行为积分奖励矩阵和每日积分上限。', + defaults: { + bbs_credits_post_new: 5, + bbs_credits_plate_new: 10, + bbs_credits_comment_new: 2, + bbs_credits_comment_like: 1, + bbs_credits_score_extra: 3, + bbs_credits_essence: 10, + bbs_credits_posts_hot: 5, + bbs_credits_adopted: 20, + bbs_credits_daily_max: 50, + }, + groups: [ + { + key: 'earn', + title: '积分奖励', + icon: 'fas fa-gift', + fields: [ + { key: 'bbs_credits_post_new', label: '发帖奖励', type: 'number', default: 5, min: 0 }, + { key: 'bbs_credits_plate_new', label: '建版奖励', type: 'number', default: 10, min: 0 }, + { key: 'bbs_credits_comment_new', label: '回复奖励', type: 'number', default: 2, min: 0 }, + { key: 'bbs_credits_comment_like', label: '评论被赞', type: 'number', default: 1, min: 0 }, + { key: 'bbs_credits_score_extra', label: '被加分', type: 'number', default: 3, min: 0 }, + { key: 'bbs_credits_essence', label: '被设精华', type: 'number', default: 10, min: 0 }, + { key: 'bbs_credits_posts_hot', label: '成为热帖', type: 'number', default: 5, min: 0 }, + { key: 'bbs_credits_adopted', label: '被采纳', type: 'number', default: 20, min: 0 }, + { key: 'bbs_credits_daily_max', label: '每日积分上限', type: 'number', default: 50, min: 0, help: '0 = 不限' }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/forum/homepage.js b/assets/admin/src/schema/sections/forum/homepage.js new file mode 100644 index 00000000..e36b0c2a --- /dev/null +++ b/assets/admin/src/schema/sections/forum/homepage.js @@ -0,0 +1,29 @@ +export const homepageSchema = { + key: 'forum.homepage', + title: '首页设置', + icon: 'fas fa-home', + description: '论坛首页 Tab 引擎、快速发帖入口和移动端交互配置。', + defaults: { + bbs_home_tab: [ + { type: 'follow', label: '关注' }, + { type: 'synthesis', label: '综合' }, + { type: 'plate', label: '版块' }, + ], + bbs_home_tab_active_index: 1, + bbs_home_tab_quick_posts_s: true, + bbs_home_tab_swiper: true, + }, + groups: [ + { + key: 'tabs', + title: '首页 Tab', + icon: 'fas fa-columns', + fields: [ + { key: 'bbs_home_tab', label: '首页栏目', type: 'group', default: [{ type: 'follow', label: '关注' }, { type: 'synthesis', label: '综合' }, { type: 'plate', label: '版块' }], help: '可拖拽排序,支持 follow / synthesis / plate / tabs 类型' }, + { key: 'bbs_home_tab_active_index', label: '默认选中 Tab', type: 'number', default: 1, min: 0 }, + { key: 'bbs_home_tab_quick_posts_s', label: '快速发帖', type: 'switch', default: true, help: '首页顶部显示快速发帖入口' }, + { key: 'bbs_home_tab_swiper', label: '移动端滑动切换', type: 'switch', default: true }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/forum/index.js b/assets/admin/src/schema/sections/forum/index.js new file mode 100644 index 00000000..d0396a9d --- /dev/null +++ b/assets/admin/src/schema/sections/forum/index.js @@ -0,0 +1,31 @@ +/** + * Forum domain schemas. + * + * 12 sections matching PHP schema_forum(): + * basic / names / homepage / board_page / post_page / reply / + * permissions / other / upload / credits + */ + +import { basicSchema } from './basic.js' +import { namesSchema } from './names.js' +import { homepageSchema } from './homepage.js' +import { boardPageSchema } from './board-page.js' +import { postPageSchema } from './post-page.js' +import { replySchema } from './reply.js' +import { permissionsSchema } from './permissions.js' +import { otherSchema } from './other.js' +import { uploadSchema } from './upload.js' +import { creditsSchema } from './credits.js' + +export const forumSchemas = { + basic: basicSchema, + names: namesSchema, + homepage: homepageSchema, + board_page: boardPageSchema, + post_page: postPageSchema, + reply: replySchema, + permissions: permissionsSchema, + other: otherSchema, + upload: uploadSchema, + credits: creditsSchema, +} diff --git a/assets/admin/src/schema/sections/forum/names.js b/assets/admin/src/schema/sections/forum/names.js new file mode 100644 index 00000000..80f99d72 --- /dev/null +++ b/assets/admin/src/schema/sections/forum/names.js @@ -0,0 +1,41 @@ +export const namesSchema = { + key: 'forum.names', + title: '名称定义', + icon: 'fas fa-language', + description: '自定义论坛各实体的显示名称(12 词条)。', + defaults: { + bbs_forum_display_name: '论坛', + bbs_plate_display_name: '版块', + bbs_posts_display_name: '帖子', + bbs_topic_display_name: '话题', + bbs_tag_display_name: '标签', + bbs_comment_display_name: '回复', + bbs_question_badge_name: '提问', + bbs_question_ok_badge_name: '已解决', + bbs_cat_moderator_name: '分区版主', + bbs_plate_author_name: '超级版主', + bbs_plate_moderator_name: '版主', + bbs_plate_follow_name: '关注', + }, + groups: [ + { + key: 'display_names', + title: '名词自定义', + icon: 'fas fa-tags', + fields: [ + { key: 'bbs_forum_display_name', label: '论坛', type: 'input', default: '论坛' }, + { key: 'bbs_plate_display_name', label: '版块', type: 'input', default: '版块' }, + { key: 'bbs_posts_display_name', label: '帖子', type: 'input', default: '帖子' }, + { key: 'bbs_topic_display_name', label: '话题', type: 'input', default: '话题' }, + { key: 'bbs_tag_display_name', label: '标签', type: 'input', default: '标签' }, + { key: 'bbs_comment_display_name', label: '回复', type: 'input', default: '回复' }, + { key: 'bbs_question_badge_name', label: '提问徽章', type: 'input', default: '提问' }, + { key: 'bbs_question_ok_badge_name', label: '已解决徽章', type: 'input', default: '已解决' }, + { key: 'bbs_cat_moderator_name', label: '分区版主', type: 'input', default: '分区版主' }, + { key: 'bbs_plate_author_name', label: '超级版主', type: 'input', default: '超级版主' }, + { key: 'bbs_plate_moderator_name', label: '版主', type: 'input', default: '版主' }, + { key: 'bbs_plate_follow_name', label: '关注', type: 'input', default: '关注' }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/forum/other.js b/assets/admin/src/schema/sections/forum/other.js new file mode 100644 index 00000000..a0fa154a --- /dev/null +++ b/assets/admin/src/schema/sections/forum/other.js @@ -0,0 +1,64 @@ +export const otherSchema = { + key: 'forum.other', + title: '其它设置', + icon: 'fas fa-cog', + description: '投票、版主申请说明、标题字数限制和 URL 别名配置。', + defaults: { + bbs_vote_number_type: 'percent', + bbs_vote_max: 8, + bbs_apply_moderator_desc: '欢迎有热情的用户申请成为版主,协助管理社区秩序。', + bbs_term_thumb: '', + bbs_post_lists_hide_pay_s: false, + bbs_post_title_strlen_limit: 30, + bbs_t_placeholder: '请输入标题', + bbs_c_placeholder: '请输入内容', + bbs_rewrite_suffix_html_s: false, + bbs_plate_rewrite_slug: 'board', + bbs_posts_rewrite_slug: 'topic', + bbs_posts_edit_rewrite_slug: 'topic-edit', + bbs_posts_show_in_rest_s: false, + }, + groups: [ + { + key: 'vote', + title: '投票', + icon: 'fas fa-poll', + fields: [ + { key: 'bbs_vote_number_type', label: '投票显示', type: 'select', default: 'percent', options: [{ label: '百分比', value: 'percent' }, { label: '票数', value: 'number' }] }, + { key: 'bbs_vote_max', label: '选项上限', type: 'number', default: 8, min: 2, max: 20 }, + ], + }, + { + key: 'moderator_apply', + title: '版主申请', + icon: 'fas fa-user-plus', + fields: [ + { key: 'bbs_apply_moderator_desc', label: '申请说明', type: 'textarea', default: '欢迎有热情的用户申请成为版主,协助管理社区秩序。' }, + ], + }, + { + key: 'misc', + title: '杂项', + icon: 'fas fa-sliders-h', + fields: [ + { key: 'bbs_term_thumb', label: '标签默认缩略图', type: 'media', default: '' }, + { key: 'bbs_post_lists_hide_pay_s', label: '列表隐藏付费标记', type: 'switch', default: false }, + { key: 'bbs_post_title_strlen_limit', label: '标题字数限制', type: 'number', default: 30, min: 5, max: 100, help: '最小 5,最大 100' }, + { key: 'bbs_t_placeholder', label: '标题占位符', type: 'input', default: '请输入标题' }, + { key: 'bbs_c_placeholder', label: '内容占位符', type: 'input', default: '请输入内容' }, + ], + }, + { + key: 'url', + title: 'URL / 别名', + icon: 'fas fa-link', + fields: [ + { key: 'bbs_rewrite_suffix_html_s', label: '.html 后缀', type: 'switch', default: false }, + { key: 'bbs_plate_rewrite_slug', label: '版块别名', type: 'input', default: 'board' }, + { key: 'bbs_posts_rewrite_slug', label: '帖子别名', type: 'input', default: 'topic' }, + { key: 'bbs_posts_edit_rewrite_slug', label: '编辑别名', type: 'input', default: 'topic-edit' }, + { key: 'bbs_posts_show_in_rest_s', label: '古腾堡编辑器', type: 'switch', default: false, help: '开启后帖子可使用 Gutenberg' }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/forum/permissions.js b/assets/admin/src/schema/sections/forum/permissions.js new file mode 100644 index 00000000..96eb7713 --- /dev/null +++ b/assets/admin/src/schema/sections/forum/permissions.js @@ -0,0 +1,64 @@ +export const permissionsSchema = { + key: 'forum.permissions', + title: '权限配置', + icon: 'fas fa-lock', + description: '发帖限制、按钮显示、帖子付费和版块付费关注配置。', + defaults: { + bbs_posts_add_limit_opt_max: 10, + bbs_plate_add_limit_opt_max: 3, + bbs_show_new_plate: false, + bbs_show_new_topic: true, + bbs_show_apply_moderator: true, + bbs_post_pay_hide_type_s: false, + bbs_post_pay_vip_price_s: false, + bbs_post_pay_vip_1_discount: 80, + bbs_post_pay_vip_2_discount: 50, + bbs_post_pay_price_limit: 1000, + bbs_plate_follow_pay_s: false, + bbs_plate_follow_pay_price: 10, + bbs_plate_follow_pay_type: 'money', + }, + groups: [ + { + key: 'posting', + title: '发帖限制', + icon: 'fas fa-ban', + fields: [ + { key: 'bbs_posts_add_limit_opt_max', label: '每日发帖上限', type: 'number', default: 10, min: 0, help: '0 = 不限' }, + { key: 'bbs_plate_add_limit_opt_max', label: '建版数量上限', type: 'number', default: 3, min: 0 }, + ], + }, + { + key: 'display_btns', + title: '按钮显示', + icon: 'fas fa-eye', + fields: [ + { key: 'bbs_show_new_plate', label: '始终显示建版按钮', type: 'switch', default: false }, + { key: 'bbs_show_new_topic', label: '始终显示发帖按钮', type: 'switch', default: true }, + { key: 'bbs_show_apply_moderator', label: '始终显示申请版主', type: 'switch', default: true }, + ], + }, + { + key: 'paid_content', + title: '帖子付费', + icon: 'fas fa-money-bill-wave', + fields: [ + { key: 'bbs_post_pay_hide_type_s', label: '启用付费内容', type: 'switch', default: false }, + { key: 'bbs_post_pay_vip_price_s', label: 'VIP 特价', type: 'switch', default: false, dependsOn: { key: 'bbs_post_pay_hide_type_s', value: true } }, + { key: 'bbs_post_pay_vip_1_discount', label: '一级 VIP 折扣 (%)', type: 'number', default: 80, min: 0, max: 100, dependsOn: { key: 'bbs_post_pay_vip_price_s', value: true } }, + { key: 'bbs_post_pay_vip_2_discount', label: '二级 VIP 折扣 (%)', type: 'number', default: 50, min: 0, max: 100, dependsOn: { key: 'bbs_post_pay_vip_price_s', value: true } }, + { key: 'bbs_post_pay_price_limit', label: '最高定价', type: 'number', default: 1000, min: 1, dependsOn: { key: 'bbs_post_pay_hide_type_s', value: true } }, + ], + }, + { + key: 'plate_follow', + title: '版块付费关注', + icon: 'fas fa-heart', + fields: [ + { key: 'bbs_plate_follow_pay_s', label: '启用付费关注', type: 'switch', default: false }, + { key: 'bbs_plate_follow_pay_price', label: '关注价格', type: 'number', default: 10, min: 0, dependsOn: { key: 'bbs_plate_follow_pay_s', value: true } }, + { key: 'bbs_plate_follow_pay_type', label: '支付方式', type: 'select', default: 'money', options: [{ label: '现金', value: 'money' }, { label: '积分', value: 'points' }], dependsOn: { key: 'bbs_plate_follow_pay_s', value: true } }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/forum/post-page.js b/assets/admin/src/schema/sections/forum/post-page.js new file mode 100644 index 00000000..3cd1aa2a --- /dev/null +++ b/assets/admin/src/schema/sections/forum/post-page.js @@ -0,0 +1,60 @@ +export const postPageSchema = { + key: 'forum.post_page', + title: '帖子页面', + icon: 'fas fa-file-alt', + description: '帖子页面包屑、评分、高度限制、封面比例和视频静音配置。', + defaults: { + bbs_breadcrumbs_s: true, + bbs_breadcrumbs_home: true, + bbs_breadcrumbs_bbs_home: true, + bbs_breadcrumbs_bbs_home_name: '论坛', + bbs_breadcrumbs_plate_cat: true, + bbs_score_extra_max: 5, + bbs_score_deduct_max: 3, + bbs_posts_maxheight_s: false, + bbs_posts_maxheight: 800, + bbs_posts_cover_ratio: '16:9', + bbs_posts_cover_video_mute: true, + }, + groups: [ + { + key: 'breadcrumbs', + title: '面包屑', + icon: 'fas fa-angle-double-right', + fields: [ + { key: 'bbs_breadcrumbs_s', label: '显示面包屑', type: 'switch', default: true }, + { key: 'bbs_breadcrumbs_home', label: '显示首页', type: 'switch', default: true, dependsOn: { key: 'bbs_breadcrumbs_s', value: true } }, + { key: 'bbs_breadcrumbs_bbs_home', label: '显示论坛首页', type: 'switch', default: true, dependsOn: { key: 'bbs_breadcrumbs_s', value: true } }, + { key: 'bbs_breadcrumbs_bbs_home_name', label: '论坛首页名称', type: 'input', default: '论坛', dependsOn: { key: 'bbs_breadcrumbs_bbs_home', value: true } }, + { key: 'bbs_breadcrumbs_plate_cat', label: '显示分区', type: 'switch', default: true, dependsOn: { key: 'bbs_breadcrumbs_s', value: true } }, + ], + }, + { + key: 'score', + title: '评分设置', + icon: 'fas fa-star', + fields: [ + { key: 'bbs_score_extra_max', label: '加分上限', type: 'number', default: 5, min: 1, max: 50 }, + { key: 'bbs_score_deduct_max', label: '扣分上限', type: 'number', default: 3, min: 1, max: 50 }, + ], + }, + { + key: 'display', + title: '内容显示', + icon: 'fas fa-expand-arrows-alt', + fields: [ + { key: 'bbs_posts_maxheight_s', label: '内容高度限制', type: 'switch', default: false }, + { key: 'bbs_posts_maxheight', label: '最大高度 (px)', type: 'number', default: 800, min: 300, max: 3000, dependsOn: { key: 'bbs_posts_maxheight_s', value: true } }, + ], + }, + { + key: 'cover', + title: '封面设置', + icon: 'fas fa-image', + fields: [ + { key: 'bbs_posts_cover_ratio', label: '封面比例', type: 'select', default: '16:9', options: [{ label: '16:9', value: '16:9' }, { label: '4:3', value: '4:3' }, { label: '1:1', value: '1:1' }, { label: '3:4', value: '3:4' }] }, + { key: 'bbs_posts_cover_video_mute', label: '视频封面静音', type: 'switch', default: true }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/forum/reply.js b/assets/admin/src/schema/sections/forum/reply.js new file mode 100644 index 00000000..1f21d00b --- /dev/null +++ b/assets/admin/src/schema/sections/forum/reply.js @@ -0,0 +1,44 @@ +export const replySchema = { + key: 'forum.reply', + title: '回复评论', + icon: 'fas fa-reply-all', + description: '回复翻页、工具栏功能、表情、代码和快捷回复配置。', + defaults: { + bbs_reply_paginate_type: 'ajax', + bbs_reply_paginate_ias_s: false, + bbs_reply_paginate_ias_max: 3, + bbs_comment_smilie: true, + bbs_comment_code: true, + bbs_comment_img: true, + bbs_comment_upload_img: false, + bbs_comment_quick_s: true, + bbs_comment_quick_often: '不错,学习了\n感谢分享\n期待更新', + bbs_comment_placeholder: '写下你的回复…', + }, + groups: [ + { + key: 'paginate', + title: '翻页', + icon: 'fas fa-chevron-circle-right', + fields: [ + { key: 'bbs_reply_paginate_type', label: '翻页方式', type: 'select', default: 'ajax', options: [{ label: 'AJAX 加载', value: 'ajax' }, { label: '传统翻页', value: 'page' }] }, + { key: 'bbs_reply_paginate_ias_s', label: '无限滚动', type: 'switch', default: false }, + { key: 'bbs_reply_paginate_ias_max', label: '自动加载页数', type: 'number', default: 3, min: 0, max: 10, dependsOn: { key: 'bbs_reply_paginate_ias_s', value: true } }, + ], + }, + { + key: 'toolbar', + title: '回复工具栏', + icon: 'fas fa-toolbox', + fields: [ + { key: 'bbs_comment_smilie', label: '表情', type: 'switch', default: true }, + { key: 'bbs_comment_code', label: '代码', type: 'switch', default: true }, + { key: 'bbs_comment_img', label: '插图', type: 'switch', default: true }, + { key: 'bbs_comment_upload_img', label: '上传图片', type: 'switch', default: false }, + { key: 'bbs_comment_quick_s', label: '快捷回复', type: 'switch', default: true }, + { key: 'bbs_comment_quick_often', label: '预设短语', type: 'textarea', default: '不错,学习了\n感谢分享\n期待更新', help: '每行一条', dependsOn: { key: 'bbs_comment_quick_s', value: true } }, + { key: 'bbs_comment_placeholder', label: '回复占位符', type: 'input', default: '写下你的回复…' }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/forum/upload.js b/assets/admin/src/schema/sections/forum/upload.js new file mode 100644 index 00000000..df879eaa --- /dev/null +++ b/assets/admin/src/schema/sections/forum/upload.js @@ -0,0 +1,27 @@ +export const uploadSchema = { + key: 'forum.upload', + title: '上传权限', + icon: 'fas fa-cloud-upload-alt', + description: '论坛帖子图片、视频和文件的上传大小与格式限制。', + defaults: { + bbs_upload_img_size: 2048, + bbs_image_upload_multiple: 9, + bbs_upload_video_size: 20480, + bbs_upload_file_size: 10240, + bbs_upload_file_mimes: 'jpg,jpeg,png,gif,mp4,zip,rar,pdf', + }, + groups: [ + { + key: 'limits', + title: '上传限制', + icon: 'fas fa-weight-hanging', + fields: [ + { key: 'bbs_upload_img_size', label: '图片大小 (KB)', type: 'number', default: 2048, min: 100 }, + { key: 'bbs_image_upload_multiple', label: '批量上传数', type: 'number', default: 9, min: 1, max: 20 }, + { key: 'bbs_upload_video_size', label: '视频大小 (KB)', type: 'number', default: 20480, min: 1024 }, + { key: 'bbs_upload_file_size', label: '文件大小 (KB)', type: 'number', default: 10240, min: 100 }, + { key: 'bbs_upload_file_mimes', label: '允许格式', type: 'input', default: 'jpg,jpeg,png,gif,mp4,zip,rar,pdf', help: '逗号分隔' }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/global/code-highlight.js b/assets/admin/src/schema/sections/global/code-highlight.js new file mode 100644 index 00000000..1c98f607 --- /dev/null +++ b/assets/admin/src/schema/sections/global/code-highlight.js @@ -0,0 +1,68 @@ +export const codeHighlightSchema = { + key: 'global.code-highlight', + title: '代码高亮', + icon: 'fas fa-code', + description: '代码块语法高亮主题、行号、最大高度等配置。', + defaults: { + highlight_kg: true, + highlight_hh: false, + highlight_btn: true, + highlight_maxheight: 400, + highlight_zt: 'enlighter', + highlight_dark_zt: 'monokai', + }, + groups: [ + { + key: 'basic', + title: '代码高亮设置', + icon: 'fas fa-highlighter', + fields: [ + { key: 'highlight_kg', label: '代码高亮开关', type: 'switch', default: true }, + { key: 'highlight_hh', label: '显示行号', type: 'switch', default: false }, + { key: 'highlight_btn', label: '显示扩展按钮', type: 'switch', default: true, help: '控制原始代码 / 信息 / 新窗口按钮的显示' }, + { key: 'highlight_maxheight', label: '最大高度(px,0=不限)', type: 'number', default: 400, min: 0, max: 2000, help: '代码块最大高度,超出滚动显示。0 表示不限制' }, + ], + }, + { + key: 'theme', + title: '主题设置', + icon: 'fas fa-palette', + fields: [ + { + key: 'highlight_zt', + label: '日间默认主题', + type: 'select', + default: 'enlighter', + options: [ + { label: 'Enlighter', value: 'enlighter' }, + { label: 'Bootstrap 4', value: 'bootstrap4' }, + { label: 'Classic', value: 'classic' }, + { label: 'Beyond', value: 'beyond' }, + { label: 'Mowtwo', value: 'mowtwo' }, + { label: 'Eclipse', value: 'eclipse' }, + { label: 'Droide', value: 'droide' }, + { label: 'Minimal', value: 'minimal' }, + { label: 'Rowhammer', value: 'rowhammer' }, + { label: 'Godzilla', value: 'godzilla' }, + { label: 'Dracula', value: 'dracula' }, + { label: 'Atomic', value: 'atomic' }, + { label: 'Monokai', value: 'monokai' }, + ], + }, + { + key: 'highlight_dark_zt', + label: '暗色默认主题', + type: 'select', + default: 'monokai', + options: [ + { label: 'Dracula', value: 'dracula' }, + { label: 'Atomic', value: 'atomic' }, + { label: 'Monokai', value: 'monokai' }, + { label: 'Enlighter', value: 'enlighter' }, + { label: 'Bootstrap 4', value: 'bootstrap4' }, + ], + }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/global/common.js b/assets/admin/src/schema/sections/global/common.js new file mode 100644 index 00000000..74cd6123 --- /dev/null +++ b/assets/admin/src/schema/sections/global/common.js @@ -0,0 +1,99 @@ +export const commonSchema = { + key: 'global.common', + title: '常用功能', + icon: 'fas fa-cog', + description: '管理站点常用开关、懒加载、头像源与备案信息等基础配置。', + defaults: { + comment_enabled: true, + lazyload_enabled: true, + lazyload_animation: 'blur', + lazyload_placeholder: '', + lazyload_blur: true, + lazyload_avatar: true, + lazyload_content: false, + lazyload_comment: true, + lazyload_private: true, + lazyload_slider: true, + lazyload_cover: true, + lazyload_other: true, + lazyload_widget: true, + avatar_source: 'cn.cravatar.com', + icp_filing: '', + footer_text: '', + }, + groups: [ + { + key: 'comment', + title: '评论功能', + icon: 'fas fa-comment', + fields: [ + { key: 'comment_enabled', label: '开启评论', type: 'switch', default: true, help: '全局评论开关,关闭后所有文章不再显示评论区' }, + ], + }, + { + key: 'performance', + title: '性能优化', + icon: 'fas fa-tachometer-alt', + fields: [ + { key: 'lazyload_enabled', label: '图片懒加载', type: 'switch', default: true, help: '开启后页面中的图片将在滚动到可见区域时才加载' }, + { + key: 'lazyload_animation', + label: '懒加载动画', + type: 'select', + default: 'blur', + options: [ + { label: '模糊渐显', value: 'blur' }, + { label: '淡入', value: 'fade' }, + { label: '缩放', value: 'scale' }, + ], + dependsOn: { key: 'lazyload_enabled', value: true }, + }, + { key: 'lazyload_placeholder', label: '预载图', type: 'media', default: '', dependsOn: { key: 'lazyload_enabled', value: true }, help: '自定义占位图,留空使用内置默认' }, + { key: 'lazyload_blur', label: '模糊预览', type: 'switch', default: true, dependsOn: { key: 'lazyload_enabled', value: true }, help: '加载前显示低分辨率模糊预览' }, + ], + }, + { + key: 'lazyload-scope', + title: '懒加载范围', + icon: 'fas fa-layer-group', + fields: [ + { key: 'lazyload_avatar', label: '头像懒加载', type: 'switch', default: true, dependsOn: { key: 'lazyload_enabled', value: true } }, + { key: 'lazyload_content', label: '文章内容图片懒加载', type: 'switch', default: false, dependsOn: { key: 'lazyload_enabled', value: true } }, + { key: 'lazyload_comment', label: '评论内容图片懒加载', type: 'switch', default: true, dependsOn: { key: 'lazyload_enabled', value: true } }, + { key: 'lazyload_private', label: '私信聊天图片懒加载', type: 'switch', default: true, dependsOn: { key: 'lazyload_enabled', value: true } }, + { key: 'lazyload_slider', label: '幻灯片图片懒加载', type: 'switch', default: true, dependsOn: { key: 'lazyload_enabled', value: true } }, + { key: 'lazyload_cover', label: '封面图片懒加载', type: 'switch', default: true, dependsOn: { key: 'lazyload_enabled', value: true } }, + { key: 'lazyload_other', label: '其它图片懒加载', type: 'switch', default: true, dependsOn: { key: 'lazyload_enabled', value: true } }, + { key: 'lazyload_widget', label: '小工具图片懒加载', type: 'switch', default: true, dependsOn: { key: 'lazyload_enabled', value: true } }, + ], + }, + { + key: 'avatar', + title: '头像服务', + icon: 'fas fa-user-circle', + fields: [ + { + key: 'avatar_source', + label: '头像源', + type: 'select', + default: 'cn.cravatar.com', + options: [ + { label: 'Cravatar (推荐)', value: 'cn.cravatar.com' }, + { label: 'Gravatar 中国', value: 'cn.gravatar.com' }, + { label: 'Gravatar 国际', value: 'gravatar.com' }, + ], + help: 'Cravatar 是国内最快的头像服务,推荐使用', + }, + ], + }, + { + key: 'filing', + title: '备案信息', + icon: 'fas fa-shield-alt', + fields: [ + { key: 'icp_filing', label: 'ICP 备案号', type: 'input', placeholder: '例如:京ICP备12345678号', help: '显示在页脚的备案信息' }, + { key: 'footer_text', label: '页脚附加文字', type: 'input', placeholder: '自定义页脚文字', help: '显示在版权信息下方' }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/global/custom-code.js b/assets/admin/src/schema/sections/global/custom-code.js new file mode 100644 index 00000000..86b28720 --- /dev/null +++ b/assets/admin/src/schema/sections/global/custom-code.js @@ -0,0 +1,39 @@ +export const customCodeSchema = { + key: 'global.custom-code', + title: '自定义代码', + icon: 'fas fa-code', + description: '自定义 CSS、JS 代码注入。', + defaults: { + custom_css: '', + head_code: '', + foot_code: '', + trackcode: '', + }, + groups: [ + { + key: 'css', + title: '自定义 CSS', + icon: 'fab fa-css3-alt', + fields: [ + { key: 'custom_css', label: '自定义 CSS', type: 'code', language: 'css', placeholder: '/* 在此输入自定义 CSS */', rows: 10, help: '将在页面 中加载' }, + ], + }, + { + key: 'code', + title: '头部 & 底部代码', + icon: 'fas fa-code', + fields: [ + { key: 'head_code', label: '头部 HTML 代码', type: 'code', placeholder: '', rows: 6, help: '在 中输出,支持 meta/link/style/script 等任意 HTML' }, + { key: 'foot_code', label: '底部 HTML 代码', type: 'code', placeholder: '', rows: 6, help: '在页面底部 前输出,支持任意 HTML' }, + ], + }, + { + key: 'stats', + title: '统计代码', + icon: 'fas fa-chart-bar', + fields: [ + { key: 'trackcode', label: '统计代码', type: 'code', placeholder: '', rows: 6, help: '百度统计、Google Analytics、CNZZ、51la 等统计代码,自动在 head 中输出' }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/global/email.js b/assets/admin/src/schema/sections/global/email.js new file mode 100644 index 00000000..99df372e --- /dev/null +++ b/assets/admin/src/schema/sections/global/email.js @@ -0,0 +1,123 @@ +export const emailSchema = { + key: 'global.email', + title: '邮件配置', + icon: 'fas fa-envelope', + description: 'SMTP 发信配置、邮件事件开关与模板自定义。', + defaults: { + email_footer_text: '', + email_site_branding: true, + email_template_mode: 'plain', + email_private_receive_limit: 'all', + email_reg_verify_link_enabled: true, + email_reg_verify_code_enabled: true, + email_reg_success_enabled: true, + email_password_reset_enabled: true, + email_change_code_enabled: true, + email_change_notice_enabled: true, + }, + groups: [ + { + key: 'global', + title: '全局设置', + icon: 'fas fa-cog', + fields: [ + { key: 'email_footer_text', label: '邮件页脚文字', type: 'textarea', rows: 2, placeholder: '留空则使用站点名称', help: '附加在每封邮件底部的文字' }, + { key: 'email_site_branding', label: '显示站点品牌', type: 'switch', default: true, help: '在邮件开头显示「[站点名]」前缀' }, + { key: 'email_template_mode', label: '邮件格式', type: 'radio', default: 'plain', options: [ + { label: '纯文本', value: 'plain' }, + { label: 'HTML', value: 'html' }, + ]}, + { + key: 'email_private_receive_limit', + label: '私信邮件接收组', + type: 'select', + default: 'all', + options: [ + { label: '所有用户', value: 'all' }, + { label: 'VIP 及以上', value: 'vip' }, + { label: 'VIP2 及以上', value: 'vip2' }, + { label: '仅管理员', value: 'admin' }, + ], + help: '限制哪些用户组可以收到私信邮件通知', + }, + ], + }, + { + key: 'branding', + title: '邮件品牌', + icon: 'fas fa-signature', + fields: [ + { key: 'mail_showname', label: '发件人名称', type: 'input', default: '', help: '覆盖系统默认发件人名称' }, + { key: 'mail_title_prefix', label: '主题前缀', type: 'input', default: '', help: '自动添加在所有邮件主题前面,如【站点名】' }, + { key: 'mail_description', label: '邮件底部说明', type: 'textarea', rows: 3, default: '', help: '附加在邮件页脚文字之后,留空不显示' }, + { key: 'mail_more_content', label: '邮件附加内容', type: 'textarea', rows: 4, default: '', help: '附加在邮件内容末尾的补充说明' }, + ], + }, + { + key: 'reg_verify_link', + title: '注册验证链接', + icon: 'fas fa-link', + fields: [ + { key: 'email_reg_verify_link_enabled', label: '启用', type: 'switch', default: true, help: '注册后发送验证链接邮件' }, + { key: 'email_reg_verify_link_subject', label: '主题(留空使用默认)', type: 'input', placeholder: '验证你的邮箱地址' }, + { key: 'email_reg_verify_link_content', label: '内容模板(留空使用默认)', type: 'textarea', rows: 4, placeholder: '支持 {username} {site_name} {url}', help: '可用占位符:{username} 用户名、{site_name} 站点名、{url} 验证链接' }, + { key: 'email_reg_verify_link_html', label: 'HTML 格式', type: 'switch', default: false }, + ], + }, + { + key: 'reg_verify_code', + title: '注册验证码', + icon: 'fas fa-keyboard', + fields: [ + { key: 'email_reg_verify_code_enabled', label: '启用', type: 'switch', default: true, help: '注册后发送 6 位验证码邮件' }, + { key: 'email_reg_verify_code_subject', label: '主题(留空使用默认)', type: 'input', placeholder: '邮箱验证码' }, + { key: 'email_reg_verify_code_content', label: '内容模板(留空使用默认)', type: 'textarea', rows: 4, placeholder: '支持 {username} {site_name} {code}', help: '可用占位符:{username} 用户名、{site_name} 站点名、{code} 验证码' }, + { key: 'email_reg_verify_code_html', label: 'HTML 格式', type: 'switch', default: false }, + ], + }, + { + key: 'reg_success', + title: '注册成功通知', + icon: 'fas fa-check-circle', + fields: [ + { key: 'email_reg_success_enabled', label: '启用', type: 'switch', default: true, help: '邮箱验证成功后发送通知邮件' }, + { key: 'email_reg_success_subject', label: '主题(留空使用默认)', type: 'input', placeholder: '邮箱验证成功' }, + { key: 'email_reg_success_content', label: '内容模板(留空使用默认)', type: 'textarea', rows: 4, placeholder: '支持 {username} {site_name}', help: '可用占位符:{username} 用户名、{site_name} 站点名' }, + { key: 'email_reg_success_html', label: 'HTML 格式', type: 'switch', default: false }, + ], + }, + { + key: 'password_reset', + title: '密码重置', + icon: 'fas fa-unlock-alt', + fields: [ + { key: 'email_password_reset_enabled', label: '启用', type: 'switch', default: true, help: '发送密码重置链接邮件' }, + { key: 'email_password_reset_subject', label: '主题(留空使用默认)', type: 'input', placeholder: '重置密码' }, + { key: 'email_password_reset_content', label: '内容模板(留空使用默认)', type: 'textarea', rows: 4, placeholder: '支持 {username} {site_name} {url}', help: '可用占位符:{username} 用户名、{site_name} 站点名、{url} 重置链接' }, + { key: 'email_password_reset_html', label: 'HTML 格式', type: 'switch', default: false }, + ], + }, + { + key: 'change_code', + title: '邮箱变更验证码', + icon: 'fas fa-shield-alt', + fields: [ + { key: 'email_change_code_enabled', label: '启用', type: 'switch', default: true, help: '变更邮箱时发送验证码邮件' }, + { key: 'email_change_code_subject', label: '主题(留空使用默认)', type: 'input', placeholder: '邮箱验证码' }, + { key: 'email_change_code_content', label: '内容模板(留空使用默认)', type: 'textarea', rows: 4, placeholder: '支持 {username} {site_name} {code}', help: '可用占位符:{username} 用户名、{site_name} 站点名、{code} 验证码' }, + { key: 'email_change_code_html', label: 'HTML 格式', type: 'switch', default: false }, + ], + }, + { + key: 'change_notice', + title: '邮箱变更通知', + icon: 'fas fa-bell', + fields: [ + { key: 'email_change_notice_enabled', label: '启用', type: 'switch', default: true, help: '邮箱变更成功后向旧邮箱发送通知' }, + { key: 'email_change_notice_subject', label: '主题(留空使用默认)', type: 'input', placeholder: '账号邮箱已变更' }, + { key: 'email_change_notice_content', label: '内容模板(留空使用默认)', type: 'textarea', rows: 4, placeholder: '支持 {username} {site_name} {new_email}', help: '可用占位符:{username} 用户名、{site_name} 站点名、{new_email} 新邮箱' }, + { key: 'email_change_notice_html', label: 'HTML 格式', type: 'switch', default: false }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/global/floating.js b/assets/admin/src/schema/sections/global/floating.js new file mode 100644 index 00000000..d9df88c5 --- /dev/null +++ b/assets/admin/src/schema/sections/global/floating.js @@ -0,0 +1,8 @@ +export const floatingSchema = { + key: 'global.floating', + title: '右侧悬浮按钮', + icon: 'fas fa-ellipsis-v', + description: '页面右侧浮动按钮的显示与配置。', + defaults: {}, + groups: [], +} diff --git a/assets/admin/src/schema/sections/global/i18n.js b/assets/admin/src/schema/sections/global/i18n.js new file mode 100644 index 00000000..ef80bbb4 --- /dev/null +++ b/assets/admin/src/schema/sections/global/i18n.js @@ -0,0 +1,85 @@ +export const i18nSchema = { + key: 'global.i18n', + title: '多语言翻译', + icon: 'fas fa-language', + description: '配置站点语言、语言切换器和翻译管理。', + defaults: { + i18n_enabled: false, + i18n_default_lang: 'zh-CN', + i18n_switcher_position: 'footer', + i18n_auto_detect: true, + i18n_available_langs: 'zh-CN|简体中文\nen|English\nzh-TW|繁體中文\nja|日本語\nko|한국어\nfr|Français\nde|Deutsch\nes|Español\nru|Русский', + i18n_nomenclature: '', + i18n_url_strategy: 'query', + }, + groups: [ + { + key: 'basic', + title: '基础配置', + icon: 'fas fa-globe', + fields: [ + { key: 'i18n_enabled', label: '启用多语言', type: 'switch', default: false, help: '开启后站点将支持多语言切换' }, + { + key: 'i18n_default_lang', + label: '默认语言', + type: 'select', + default: 'zh-CN', + options: [ + { label: '简体中文', value: 'zh-CN' }, + { label: 'English', value: 'en' }, + { label: '繁體中文', value: 'zh-TW' }, + { label: '日本語', value: 'ja' }, + { label: '한국어', value: 'ko' }, + { label: 'Français', value: 'fr' }, + { label: 'Deutsch', value: 'de' }, + { label: 'Español', value: 'es' }, + { label: 'Русский', value: 'ru' }, + ], + dependsOn: { key: 'i18n_enabled', value: true }, + }, + { key: 'i18n_auto_detect', label: '自动检测浏览器语言', type: 'switch', default: true, dependsOn: { key: 'i18n_enabled', value: true }, help: '基于浏览器 Accept-Language 自动切换(AKSRT 方案,区别于 zibll IP 定位方案)' }, + ], + }, + { + key: 'languages', + title: '语言管理', + icon: 'fas fa-language', + fields: [ + { key: 'i18n_available_langs', label: '可用语言列表', type: 'textarea', default: 'zh-CN|简体中文\nen|English\nzh-TW|繁體中文\nja|日本語\nko|한국어\nfr|Français\nde|Deutsch\nes|Español\nru|Русский', dependsOn: { key: 'i18n_enabled', value: true }, help: '每行一个语言:语言代码|显示名称(9 种内置语言)' }, + { key: 'i18n_nomenclature', label: '自定义术语', type: 'code', default: '', dependsOn: { key: 'i18n_enabled', value: true }, help: 'JSON 格式自定义翻译术语(key => 翻译),对齐 zibll nomenclature' }, + ], + }, + { + key: 'display', + title: '展示设置', + icon: 'fas fa-eye', + fields: [ + { + key: 'i18n_switcher_position', + label: '语言切换器位置', + type: 'radio', + default: 'footer', + options: [ + { label: '导航栏', value: 'header' }, + { label: '页脚', value: 'footer' }, + { label: '导航栏+页脚', value: 'both' }, + { label: '隐藏', value: 'none' }, + ], + dependsOn: { key: 'i18n_enabled', value: true }, + }, + { + key: 'i18n_url_strategy', + label: 'URL 策略', + type: 'radio', + default: 'query', + options: [ + { label: '查询参数 (?lang=en)', value: 'query' }, + { label: '路径前缀 (/en/page)', value: 'path' }, + { label: '子域名 (en.site.com)', value: 'subdomain' }, + ], + dependsOn: { key: 'i18n_enabled', value: true }, + }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/global/index.js b/assets/admin/src/schema/sections/global/index.js new file mode 100644 index 00000000..5a788423 --- /dev/null +++ b/assets/admin/src/schema/sections/global/index.js @@ -0,0 +1,32 @@ +/** + * Global & Functions domain schemas. + * Each key corresponds to a sub-page under the "全局&功能" section. + */ + +import { logoSchema } from './logo.js' +import { commonSchema } from './common.js' +import { layoutSchema } from './layout.js' +import { searchSchema } from './search.js' +import { emailSchema } from './email.js' +import { tabbarSchema } from './tabbar.js' +import { floatingSchema } from './floating.js' +import { i18nSchema } from './i18n.js' +import { customCodeSchema } from './custom-code.js' +import { seoSchema } from './seo.js' +import { codeHighlightSchema } from './code-highlight.js' +import { systemNoticeSchema } from './system-notice.js' + +export const globalSchemas = { + logo: logoSchema, + common: commonSchema, + layout: layoutSchema, + search: searchSchema, + email: emailSchema, + tabbar: tabbarSchema, + floating: floatingSchema, + i18n: i18nSchema, + 'custom-code': customCodeSchema, + seo: seoSchema, + 'code-highlight': codeHighlightSchema, + 'system-notice': systemNoticeSchema, +} diff --git a/assets/admin/src/schema/sections/global/layout.js b/assets/admin/src/schema/sections/global/layout.js new file mode 100644 index 00000000..2fab1a1c --- /dev/null +++ b/assets/admin/src/schema/sections/global/layout.js @@ -0,0 +1,160 @@ +export const layoutSchema = { + key: 'global.layout', + title: '显示 & 布局', + icon: 'fas fa-desktop', + description: '全局布局风格、颜色方案、字体设置。', + defaults: { + theme_mode: 'time-auto', + theme_mode_button: ['pc_nav', 'm_menu'], + primary_color: '#1890ff', + theme_skin: 'f04494', + dark_primary_color: '', + border_radius: 8, + theme_main_radius: 12, + container_max_width: 1200, + sidebar_enabled: true, + sidebar_position: 'right', + sidebar_width: 300, + sidebar_home_enabled: true, + sidebar_category_enabled: true, + sidebar_tag_enabled: true, + sidebar_single_enabled: true, + sidebar_page_enabled: true, + sidebar_search_enabled: true, + font_family: 'system', + qj_loading: false, + qj_dh_xs: 'no1', + }, + groups: [ + { + key: 'loading-animation', + title: '加载动画', + icon: 'fas fa-spinner', + fields: [ + { key: 'qj_loading', label: '全站加载动画', type: 'switch', default: false, help: '开启后页面加载时显示全屏加载动画覆盖层' }, + { + key: 'qj_dh_xs', + label: '加载动画样式', + type: 'select', + default: 'no1', + dependsOn: { key: 'qj_loading', value: true }, + options: [ + { label: '样式 1', value: 'no1' }, + { label: '样式 2', value: 'no2' }, + { label: '样式 3', value: 'no3' }, + { label: '样式 4', value: 'no4' }, + { label: '样式 5', value: 'no5' }, + { label: '样式 6', value: 'no6' }, + { label: '样式 7', value: 'no7' }, + { label: '样式 8', value: 'no8' }, + { label: '样式 9', value: 'no9' }, + { label: '样式 10', value: 'no10' }, + ], + help: '10 种加载动画样式可选', + }, + ], + }, + { + key: 'color', + title: '色彩方案', + icon: 'fas fa-palette', + fields: [ + { + key: 'theme_mode', + label: '默认主题模式', + type: 'radio', + default: 'time-auto', + options: [ + { label: '浅色', value: 'white-theme' }, + { label: '深色', value: 'dark-theme' }, + { label: '跟随系统', value: 'time-auto' }, + ], + }, + { + key: 'theme_mode_button', + label: '主题切换按钮位置', + type: 'checkbox', + default: ['pc_nav', 'm_menu'], + options: [ + { label: 'PC 顶部导航', value: 'pc_nav' }, + { label: '移动端菜单', value: 'm_menu' }, + ], + help: '控制主题切换按钮在哪些位置显示', + }, + { key: 'primary_color', label: '主题色', type: 'color', default: '#f04494', help: '按钮、链接和强调元素的主色调' }, + { + key: 'theme_skin', + label: '预设主题色', + type: 'palette', + default: 'f04494', + options: [ + { label: '玫红', value: 'ff1856' }, + { label: '粉色', value: 'f04494' }, + { label: '紫色', value: 'ae53f3' }, + { label: '靛蓝', value: '627bf5' }, + { label: '天蓝', value: '00a2e3' }, + { label: '青绿', value: '16b597' }, + { label: '翠绿', value: '36af18' }, + { label: '黄绿', value: '8fb107' }, + { label: '棕金', value: 'b18c07' }, + { label: '橘橙', value: 'e06711' }, + { label: '朱红', value: 'f74735' }, + ], + help: '选择预设主题色,将覆盖上方自定义主题色', + }, + { key: 'dark_primary_color', label: '暗色模式主题色', type: 'color', default: '', help: '暗色模式下独立的主题色,留空则跟随浅色模式' }, + { key: 'border_radius', label: '全局圆角 (px)', type: 'slider', min: 0, max: 20, step: 1, default: 8 }, + { key: 'theme_main_radius', label: '卡片圆角 (px)', type: 'number', min: 0, max: 30, default: 12, help: '独立卡片圆角,留空则使用全局圆角 +4' }, + ], + }, + { + key: 'layout', + title: '布局设置', + icon: 'fas fa-columns', + fields: [ + { key: 'container_max_width', label: '内容最大宽度 (px)', type: 'select', default: 1200, options: [ + { label: '1200px', value: 1200 }, + { label: '1320px', value: 1320 }, + { label: '1440px', value: 1440 }, + { label: '1600px', value: 1600 }, + { label: '1800px', value: 1800 }, + { label: '2000px', value: 2000 }, + { label: '2200px', value: 2200 }, + { label: '全宽', value: 0 }, + ]}, + { key: 'sidebar_enabled', label: '开启侧边栏', type: 'switch', default: true }, + { key: 'sidebar_position', label: '侧边栏位置', type: 'radio', default: 'right', dependsOn: { key: 'sidebar_enabled', value: true }, options: [ + { label: '左侧', value: 'left' }, + { label: '右侧', value: 'right' }, + ]}, + { key: 'sidebar_width', label: '侧边栏宽度 (px)', type: 'number', default: 300, min: 200, max: 400, step: 10, dependsOn: { key: 'sidebar_enabled', value: true } }, + ], + }, + { + key: 'sidebar-scope', + title: '侧边栏显示范围', + icon: 'fas fa-th-list', + fields: [ + { key: 'sidebar_home_enabled', label: '首页侧边栏', type: 'switch', default: true, dependsOn: { key: 'sidebar_enabled', value: true } }, + { key: 'sidebar_category_enabled', label: '分类页侧边栏', type: 'switch', default: true, dependsOn: { key: 'sidebar_enabled', value: true } }, + { key: 'sidebar_tag_enabled', label: '标签页侧边栏', type: 'switch', default: true, dependsOn: { key: 'sidebar_enabled', value: true } }, + { key: 'sidebar_single_enabled', label: '文章页侧边栏', type: 'switch', default: true, dependsOn: { key: 'sidebar_enabled', value: true } }, + { key: 'sidebar_page_enabled', label: '页面侧边栏', type: 'switch', default: true, dependsOn: { key: 'sidebar_enabled', value: true } }, + { key: 'sidebar_search_enabled', label: '搜索页侧边栏', type: 'switch', default: true, dependsOn: { key: 'sidebar_enabled', value: true } }, + ], + }, + { + key: 'typography', + title: '字体设置', + icon: 'fas fa-font', + fields: [ + { key: 'font_family', label: '正文字体', type: 'select', default: 'system', options: [ + { label: '系统默认', value: 'system' }, + { label: '思源黑体', value: 'noto-sans-sc' }, + { label: '思源宋体', value: 'noto-serif-sc' }, + { label: '自定义', value: 'custom' }, + ]}, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/global/logo.js b/assets/admin/src/schema/sections/global/logo.js new file mode 100644 index 00000000..0c0aab87 --- /dev/null +++ b/assets/admin/src/schema/sections/global/logo.js @@ -0,0 +1,36 @@ +export const logoSchema = { + key: 'global.logo', + title: 'LOGO & 品牌', + icon: 'fas fa-image', + description: '管理站点 Logo、站点图标和品牌标识。', + defaults: { + site_logo: '', + site_logo_dark: '', + site_favicon: '', + site_icon: '', + logo_width: 150, + logo_text: '', + }, + groups: [ + { + key: 'logo', + title: 'Logo 设置', + icon: 'fas fa-image', + fields: [ + { key: 'site_logo', label: '站点 Logo', type: 'media', help: '建议尺寸 300x80,支持 PNG/SVG 透明背景' }, + { key: 'site_logo_dark', label: '暗色模式 Logo', type: 'media', help: '暗色模式下显示的 Logo,留空则使用默认 Logo' }, + { key: 'logo_width', label: 'Logo 宽度 (px)', type: 'number', min: 50, max: 400, step: 10, help: '导航栏中 Logo 显示宽度' }, + { key: 'logo_text', label: 'Logo 文字', type: 'input', placeholder: '当无 Logo 图片时显示的文字', help: '同时作为首页标题和 alt 文本使用' }, + ], + }, + { + key: 'favicon', + title: '站点图标', + icon: 'fas fa-fingerprint', + fields: [ + { key: 'site_favicon', label: '浏览器图标 (Favicon)', type: 'media', help: '建议 32x32 或 16x16 的 ICO/PNG 文件' }, + { key: 'site_icon', label: '移动端图标', type: 'media', help: '建议 192x192 的 PNG,用于移动端添加到桌面' }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/global/search.js b/assets/admin/src/schema/sections/global/search.js new file mode 100644 index 00000000..19650c0b --- /dev/null +++ b/assets/admin/src/schema/sections/global/search.js @@ -0,0 +1,135 @@ +export const searchSchema = { + key: 'global.search', + title: '搜索功能', + icon: 'fas fa-search', + description: '搜索模式、内容类型切换、搜索建议、热门搜索配置。', + defaults: { + search_mode: 'default', + search_type: 'default', + search_history_enabled: true, + hot_search_enabled: true, + hot_search_keywords: '', + search_placeholder: '开启精彩搜索', + search_suggestions_enabled: false, + search_posts: false, + search_no_page: false, + search_exclude_cats: '', + search_cat: false, + search_cat_in: '', + search_more_cat_obj: true, + search_custom_key_s: false, + search_custom_key: 'title,content', + search_facets: false, + search_facets_order: 'category\ntag', + search_type_post: true, + search_type_user: false, + search_type_category: true, + search_type_tag: true, + search_type_product: false, + search_type_topic: false, + search_default_type: 'post', + search_ms_enabled: false, + search_ms_api_url: '', + search_ms_api_key: '', + search_ms_index_prefix: 'aksrt', + search_ms_synonyms: '', + }, + groups: [ + { + key: 'basic', + title: '搜索基础', + icon: 'fas fa-search', + fields: [ + { + key: 'search_mode', + label: '搜索模式', + type: 'radio', + default: 'default', + options: [ + { label: '默认搜索', value: 'default' }, + { label: 'AJAX 实时搜索', value: 'ajax' }, + { label: '跳转搜索', value: 'redirect' }, + ], + }, + { key: 'search_placeholder', label: '搜索框提示文字', type: 'input', placeholder: '开启精彩搜索' }, + ], + }, + { + key: 'content-types', + title: '搜索内容类型', + icon: 'fas fa-layer-group', + fields: [ + { key: 'search_type_post', label: '文章', type: 'switch', default: true }, + { key: 'search_type_user', label: '用户', type: 'switch', default: false }, + { key: 'search_type_category', label: '分类', type: 'switch', default: true }, + { key: 'search_type_tag', label: '标签', type: 'switch', default: true }, + { key: 'search_type_product', label: '商品', type: 'switch', default: false }, + { key: 'search_type_topic', label: '帖子', type: 'switch', default: false }, + { + key: 'search_default_type', + label: '默认类型', + type: 'select', + default: 'post', + options: [ + { label: '文章', value: 'post' }, + { label: '用户', value: 'user' }, + { label: '分类', value: 'category' }, + { label: '标签', value: 'tag' }, + { label: '商品', value: 'product' }, + { label: '帖子', value: 'topic' }, + ], + }, + ], + }, + { + key: 'features', + title: '搜索功能', + icon: 'fas fa-magic', + fields: [ + { key: 'search_history_enabled', label: '搜索历史', type: 'switch', default: true, help: '记录用户最近的搜索关键词' }, + { key: 'hot_search_enabled', label: '热门搜索', type: 'switch', default: true, help: '在搜索框下方展示热门搜索词' }, + { key: 'hot_search_keywords', label: '热门搜索关键词', type: 'textarea', placeholder: '每行一个关键词', dependsOn: { key: 'hot_search_enabled', value: true }, rows: 3, help: '手动设置热门搜索关键词,留空则自动统计' }, + { key: 'search_suggestions_enabled', label: '搜索建议', type: 'switch', default: false, help: '输入时显示搜索建议(需要 AJAX 搜索模式)' }, + { key: 'search_posts', label: '搜索框热门文章', type: 'switch', default: false, help: '在搜索面板中显示热门文章列表' }, + { key: 'search_no_page', label: '排除页面', type: 'switch', default: false, help: '搜索结果中排除页面(仅显示文章)' }, + { key: 'search_exclude_cats', label: '排除分类', type: 'textarea', placeholder: '分类 ID,逗号分隔', rows: 2, default: '', help: '搜索结果中排除的分类 ID(逗号分隔)' }, + { key: 'search_custom_key_s', label: '自定义匹配范围', type: 'switch', default: false, help: '启用后可配置搜索匹配字段范围' }, + { key: 'search_custom_key', label: '匹配字段', type: 'textarea', placeholder: 'title,content,excerpt,category,tag,author', rows: 2, default: 'title,content', dependsOn: { key: 'search_custom_key_s', value: true }, help: '搜索匹配字段(逗号分隔:title/content/excerpt/category/tag/author)' }, + { key: 'search_facets', label: '搜索结果筛选', type: 'switch', default: false, help: '启用搜索结果 taxonomy 筛选面板' }, + { key: 'search_facets_order', label: '筛选排序', type: 'textarea', rows: 3, default: 'category\ntag', dependsOn: { key: 'search_facets', value: true }, help: '筛选 fieldset 显示顺序(每行一个 taxonomy slug)' }, + ], + }, + { + key: 'category-search', + title: '分类搜索', + icon: 'fas fa-folder-open', + fields: [ + { key: 'search_cat', label: '启用分类搜索', type: 'switch', default: false, help: '在搜索框旁显示分类选择器' }, + { key: 'search_cat_in', label: '默认分类', type: 'input', default: '', help: '默认搜索的分类 ID(留空为全部分类)' }, + { key: 'search_more_cat_obj', label: '显示更多分类', type: 'switch', default: true, help: '展开显示全部分类列表' }, + ], + }, + { + key: 'popular', + title: '热门搜索', + icon: 'fas fa-fire', + fields: [ + { key: 'search_popular_title', label: '标题', type: 'input', default: '热门搜索', help: '热门搜索区域标题,留空使用默认' }, + { key: 'search_popular_key_num', label: '显示数量', type: 'number', default: 20, min: 1, max: 50, help: '热门搜索显示条数' }, + { key: 'search_popular_sticky', label: '置顶词(每行一个)', type: 'textarea', rows: 3, default: '', help: '置顶搜索词始终显示在最前面' }, + ], + }, + { + key: 'meilisearch', + title: 'Meilisearch 集成', + icon: 'fas fa-bolt', + fields: [ + { key: 'search_ms_enabled', label: '启用 Meilisearch', type: 'switch', default: false, help: '启用后搜索请求将发送至 Meilisearch 服务器' }, + { key: 'search_ms_api_url', label: 'API 地址', type: 'input', default: '', help: 'Meilisearch 服务器 URL(如 http://localhost:7700)' }, + { key: 'search_ms_api_key', label: 'API Key', type: 'input', default: '', help: 'Meilisearch Search API Key(只读密钥)' }, + { key: 'search_ms_index_prefix', label: '索引前缀', type: 'input', default: 'aksrt', help: '索引名称前缀(实际索引名为 {prefix}_posts)' }, + { key: 'search_ms_synonyms', label: '同义词(JSON)', type: 'textarea', rows: 3, default: '', help: 'Meilisearch 同义词配置,JSON 格式(如 {"手机": ["phone","mobile"]})' }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/global/seo.js b/assets/admin/src/schema/sections/global/seo.js new file mode 100644 index 00000000..6caaede1 --- /dev/null +++ b/assets/admin/src/schema/sections/global/seo.js @@ -0,0 +1,102 @@ +export const seoSchema = { + key: 'global.seo', + title: 'SEO 优化', + icon: 'fas fa-search', + description: '站点标题、描述、关键词、Sitemap 等搜索引擎优化设置。', + defaults: { + seo_enabled: true, + seo_ai_enabled: false, + seo_connector: '-', + seo_title: '', + seo_keywords: '', + seo_description: '', + ai_seo_title_prompt: '', + ai_seo_keywords_prompt: '', + ai_seo_description_prompt: '', + seo_baidu_push: false, + seo_baidu_normal: false, + seo_baidu_fast: false, + seo_baidu_token: '', + seo_image_optimize: true, + seo_image_default: '', + seo_image_always_default: false, + seo_auto_alt: true, + seo_go_link: true, + seo_go_link_nonce: true, + seo_go_link_post: true, + seo_go_link_exclude: '', + seo_no_category: false, + seo_jsonld: true, + seo_jsonld_single: true, + seo_jsonld_page: true, + }, + groups: [ + { + key: 'basic', + title: 'SEO 基础', + icon: 'fas fa-globe', + fields: [ + { key: 'seo_enabled', label: '启用核心 SEO 优化', type: 'switch', default: true }, + { key: 'seo_ai_enabled', label: '启用 AI SEO 生成', type: 'switch', default: false }, + { key: 'seo_connector', label: '标题连接符', type: 'input', default: '-', placeholder: '例如:- 或 |' }, + { key: 'seo_title', label: '站点 SEO 标题', type: 'textarea', rows: 2, default: '' }, + { key: 'seo_keywords', label: '站点 SEO 关键词', type: 'textarea', rows: 3, default: '' }, + { key: 'seo_description', label: '站点 SEO 描述', type: 'textarea', rows: 4, default: '' }, + ], + }, + { + key: 'ai-seo', + title: 'AI SEO', + icon: 'fas fa-robot', + fields: [ + { key: 'ai_seo_title_prompt', label: '标题提示词', type: 'textarea', rows: 3, default: '' }, + { key: 'ai_seo_keywords_prompt', label: '关键词提示词', type: 'textarea', rows: 3, default: '' }, + { key: 'ai_seo_description_prompt', label: '描述提示词', type: 'textarea', rows: 3, default: '' }, + ], + }, + { + key: 'baidu', + title: '百度推送', + icon: 'fas fa-paper-plane', + fields: [ + { key: 'seo_baidu_push', label: '启用百度自动推送', type: 'switch', default: false }, + { key: 'seo_baidu_normal', label: '普通收录推送', type: 'switch', default: false }, + { key: 'seo_baidu_fast', label: '快速收录推送', type: 'switch', default: false }, + { key: 'seo_baidu_token', label: '百度推送 Token', type: 'input', default: '', placeholder: '百度资源平台 Token' }, + ], + }, + { + key: 'image', + title: '图片 SEO', + icon: 'fas fa-image', + fields: [ + { key: 'seo_image_optimize', label: '优化搜索引擎图片展示', type: 'switch', default: true }, + { key: 'seo_image_default', label: '默认分享图 / SEO 图', type: 'media', default: '' }, + { key: 'seo_image_always_default', label: '固定使用默认图', type: 'switch', default: false }, + { key: 'seo_auto_alt', label: '自动补全图片 alt', type: 'switch', default: true }, + ], + }, + { + key: 'redirect', + title: '外链重定向', + icon: 'fas fa-external-link-alt', + fields: [ + { key: 'seo_go_link', label: '启用外链重定向', type: 'switch', default: true }, + { key: 'seo_go_link_nonce', label: '启用外链鉴权', type: 'switch', default: true }, + { key: 'seo_go_link_post', label: '文章内容链接走重定向', type: 'switch', default: true }, + { key: 'seo_go_link_exclude', label: '重定向排除域名', type: 'textarea', rows: 4, default: '', placeholder: '每行一个域名' }, + ], + }, + { + key: 'advanced', + title: '高级 SEO', + icon: 'fas fa-sliders-h', + fields: [ + { key: 'seo_no_category', label: '分类 URL 去掉 category', type: 'switch', default: false }, + { key: 'seo_jsonld', label: '启用 JSON-LD', type: 'switch', default: true }, + { key: 'seo_jsonld_single', label: '文章页输出 JSON-LD', type: 'switch', default: true }, + { key: 'seo_jsonld_page', label: '页面输出 JSON-LD', type: 'switch', default: true }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/global/system-notice.js b/assets/admin/src/schema/sections/global/system-notice.js new file mode 100644 index 00000000..fa80e2c9 --- /dev/null +++ b/assets/admin/src/schema/sections/global/system-notice.js @@ -0,0 +1,149 @@ +export const systemNoticeSchema = { + key: 'global.system-notice', + title: '弹窗通知', + icon: 'fas fa-bullhorn', + description: '全站弹窗公告配置,支持按用户身份定向、多按钮组、弹窗尺寸与过期周期。', + defaults: { + system_notice_enabled: true, + system_notice_policy: '', + system_notice_title: '', + system_notice_content: '', + system_notice_btn1_text: '', + system_notice_btn1_link: '', + system_notice_btn1_color: 'primary', + system_notice_btn2_text: '', + system_notice_btn2_link: '', + system_notice_btn2_color: 'primary', + system_notice_btn3_text: '', + system_notice_btn3_link: '', + system_notice_btn3_color: 'primary', + system_notice_btn4_text: '', + system_notice_btn4_link: '', + system_notice_btn4_color: 'primary', + system_notice_size: '', + system_notice_radius: false, + system_notice_class: '', + system_notice_title_icon: '', + system_notice_title_class: '', + system_notice_title_style: '', + system_notice_expires: 24, + }, + groups: [ + { + key: 'basic', + title: '公告设置', + icon: 'fas fa-bullhorn', + fields: [ + { key: 'system_notice_enabled', label: '启用系统公告', type: 'switch', default: true }, + { + key: 'system_notice_policy', + label: '显示对象', + type: 'select', + default: '', + options: [ + { label: '所有访客', value: '' }, + { label: '仅登录用户', value: 'signin' }, + { label: '仅 VIP 会员', value: 'vip' }, + { label: '仅非 VIP', value: 'free' }, + { label: '仅认证用户', value: 'auth' }, + ], + dependsOn: { key: 'system_notice_enabled', value: true }, + }, + { key: 'system_notice_title', label: '公告标题', type: 'input', default: '', dependsOn: { key: 'system_notice_enabled', value: true } }, + { key: 'system_notice_content', label: '公告内容', type: 'textarea', default: '', rows: 5, dependsOn: { key: 'system_notice_enabled', value: true }, help: '支持 HTML 标签' }, + ], + }, + { + key: 'buttons', + title: '按钮组(留空则不显示)', + icon: 'fas fa-th-large', + fields: [ + { key: 'system_notice_btn1_text', label: '按钮 1 文案', type: 'input', default: '', dependsOn: { key: 'system_notice_enabled', value: true } }, + { key: 'system_notice_btn1_link', label: '按钮 1 链接', type: 'input', default: '', dependsOn: { key: 'system_notice_enabled', value: true }, help: '可选,填写后按钮变为链接' }, + { key: 'system_notice_btn1_color', label: '按钮 1 颜色', type: 'select', default: 'primary', options: [ + { label: '主题色', value: 'primary' }, + { label: '成功绿', value: 'success' }, + { label: '警告橙', value: 'warning' }, + { label: '危险红', value: 'danger' }, + { label: '信息蓝', value: 'info' }, + { label: '深紫', value: 'purple' }, + { label: '深灰', value: 'dark' }, + ], dependsOn: { key: 'system_notice_enabled', value: true } }, + { key: 'system_notice_btn2_text', label: '按钮 2 文案', type: 'input', default: '', dependsOn: { key: 'system_notice_enabled', value: true } }, + { key: 'system_notice_btn2_link', label: '按钮 2 链接', type: 'input', default: '', dependsOn: { key: 'system_notice_enabled', value: true } }, + { key: 'system_notice_btn2_color', label: '按钮 2 颜色', type: 'select', default: 'primary', options: [ + { label: '主题色', value: 'primary' }, + { label: '成功绿', value: 'success' }, + { label: '警告橙', value: 'warning' }, + { label: '危险红', value: 'danger' }, + { label: '信息蓝', value: 'info' }, + { label: '深紫', value: 'purple' }, + { label: '深灰', value: 'dark' }, + ], dependsOn: { key: 'system_notice_enabled', value: true } }, + { key: 'system_notice_btn3_text', label: '按钮 3 文案', type: 'input', default: '', dependsOn: { key: 'system_notice_enabled', value: true } }, + { key: 'system_notice_btn3_link', label: '按钮 3 链接', type: 'input', default: '', dependsOn: { key: 'system_notice_enabled', value: true } }, + { key: 'system_notice_btn3_color', label: '按钮 3 颜色', type: 'select', default: 'primary', options: [ + { label: '主题色', value: 'primary' }, + { label: '成功绿', value: 'success' }, + { label: '警告橙', value: 'warning' }, + { label: '危险红', value: 'danger' }, + { label: '信息蓝', value: 'info' }, + { label: '深紫', value: 'purple' }, + { label: '深灰', value: 'dark' }, + ], dependsOn: { key: 'system_notice_enabled', value: true } }, + { key: 'system_notice_btn4_text', label: '按钮 4 文案', type: 'input', default: '', dependsOn: { key: 'system_notice_enabled', value: true } }, + { key: 'system_notice_btn4_link', label: '按钮 4 链接', type: 'input', default: '', dependsOn: { key: 'system_notice_enabled', value: true } }, + { key: 'system_notice_btn4_color', label: '按钮 4 颜色', type: 'select', default: 'primary', options: [ + { label: '主题色', value: 'primary' }, + { label: '成功绿', value: 'success' }, + { label: '警告橙', value: 'warning' }, + { label: '危险红', value: 'danger' }, + { label: '信息蓝', value: 'info' }, + { label: '深紫', value: 'purple' }, + { label: '深灰', value: 'dark' }, + ], dependsOn: { key: 'system_notice_enabled', value: true } }, + ], + }, + { + key: 'style', + title: '样式设置', + icon: 'fas fa-paint-brush', + fields: [ + { + key: 'system_notice_size', + label: '弹窗尺寸', + type: 'select', + default: '', + options: [ + { label: '默认', value: '' }, + { label: '迷你', value: 'modal-mini' }, + { label: '小号', value: 'modal-sm' }, + { label: '大号', value: 'modal-lg' }, + ], + dependsOn: { key: 'system_notice_enabled', value: true }, + }, + { key: 'system_notice_radius', label: '按钮圆角', type: 'switch', default: false, dependsOn: { key: 'system_notice_enabled', value: true }, help: '开启后弹窗按钮使用圆角样式' }, + { key: 'system_notice_class', label: '公告样式类', type: 'input', default: '', dependsOn: { key: 'system_notice_enabled', value: true } }, + { key: 'system_notice_title_icon', label: '标题图标', type: 'input', default: '', dependsOn: { key: 'system_notice_enabled', value: true }, help: '如 fas fa-bullhorn' }, + { key: 'system_notice_title_class', label: '标题背景配色', type: 'select', default: '', dependsOn: { key: 'system_notice_enabled', value: true }, options: [ + { label: '无', value: '' }, + { label: '炫彩黄', value: 'jb-yellow' }, + { label: '炫彩蓝', value: 'jb-blue' }, + { label: '炫彩绿', value: 'jb-green' }, + { label: '炫彩紫', value: 'jb-purple' }, + { label: '炫彩红', value: 'jb-red' }, + { label: '炫彩橙', value: 'jb-orange' }, + ], help: '弹窗标题区域的背景配色方案' }, + { key: 'system_notice_title_style', label: '标题样式', type: 'input', default: '', dependsOn: { key: 'system_notice_enabled', value: true } }, + ], + }, + { + key: 'expire', + title: '过期设置', + icon: 'fas fa-clock', + fields: [ + { key: 'system_notice_expires', label: '弹窗周期(小时)', type: 'number', default: 24, min: 0, max: 720, dependsOn: { key: 'system_notice_enabled', value: true }, help: '0 表示永不过期' }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/global/tabbar.js b/assets/admin/src/schema/sections/global/tabbar.js new file mode 100644 index 00000000..08e664e9 --- /dev/null +++ b/assets/admin/src/schema/sections/global/tabbar.js @@ -0,0 +1,8 @@ +export const tabbarSchema = { + key: 'global.tabbar', + title: '手机底部Tab', + icon: 'fas fa-mobile-alt', + description: '移动端底部导航栏配置。', + defaults: {}, + groups: [], +} diff --git a/assets/admin/src/schema/sections/page-display/index.js b/assets/admin/src/schema/sections/page-display/index.js new file mode 100644 index 00000000..8ff6af73 --- /dev/null +++ b/assets/admin/src/schema/sections/page-display/index.js @@ -0,0 +1,991 @@ +/** + * Page Display domain schemas. + * Each key corresponds to a sub-page under the "页面&显示" section. + */ + +export const pageDisplaySchemas = { + /** + * Header navigation settings + */ + 'header-nav': { + key: 'page_display.header-nav', + title: '顶部导航栏', + icon: 'fas fa-bars', + description: '配置导航栏布局、菜单结构、搜索入口和用户入口。', + defaults: { + header_fixed: true, + header_search_enabled: true, + header_user_menu: true, + header_publish_btn: true, + header_mobile_drawer: true, + header_nav_style: 'default', + header_max_width: 1200, + header_scroll_down_hide: false, + header_layout: '2', + mobile_header_layout: 'center', + mobile_navbar_align: 'left', + header_newposts_enabled: true, + header_newposts_text: ' 发布', + header_newposts_radius: 20, + header_newposts_class: '', + header_theme_custom: false, + header_theme_bg_custom: '', + header_theme_bg: '', + header_theme_color_custom: '', + }, + groups: [ + { + key: 'layout', + title: '导航栏布局', + icon: 'fas fa-th-large', + fields: [ + { key: 'header_fixed', label: '固定导航栏', type: 'switch', default: true, help: '页面滚动时导航栏保持在顶部' }, + { key: 'header_nav_style', label: '导航栏风格', type: 'radio', default: 'default', options: [ + { label: '默认', value: 'default' }, + { label: '透明', value: 'transparent' }, + { label: '毛玻璃', value: 'blur' }, + ]}, + { key: 'header_max_width', label: '导航栏最大宽度', type: 'select', default: 1200, options: [ + { label: '跟随内容', value: 0 }, + { label: '1080px', value: 1080 }, + { label: '1200px', value: 1200 }, + { label: '1320px', value: 1320 }, + ]}, + { key: 'header_scroll_down_hide', label: '下滑隐藏导航', type: 'switch', default: false, help: '页面向下滑动时自动隐藏导航栏' }, + ], + }, + { + key: 'layout-type', + title: '布局类型', + icon: 'fas fa-columns', + fields: [ + { + key: 'header_layout', + label: '电脑端布局', + type: 'radio', + default: '2', + options: [ + { label: '布局 1(经典)', value: '1' }, + { label: '布局 2(内联搜索)', value: '2' }, + { label: '布局 3(紧凑)', value: '3' }, + ], + help: '不同布局切换导航 DOM 结构', + }, + { + key: 'mobile_header_layout', + label: '移动端布局', + type: 'radio', + default: 'center', + options: [ + { label: '居中(含搜索按钮)', value: 'center' }, + { label: '靠左', value: 'left' }, + ], + }, + { + key: 'mobile_navbar_align', + label: '移动端菜单方向', + type: 'radio', + default: 'left', + options: [ + { label: '顶部(关闭手势)', value: 'top' }, + { label: '左侧滑出', value: 'left' }, + { label: '右侧滑出', value: 'right' }, + ], + }, + ], + }, + { + key: 'newposts', + title: '发布按钮', + icon: 'fas fa-pen', + fields: [ + { key: 'header_newposts_enabled', label: '启用发布按钮', type: 'switch', default: true, help: '导航栏显示前台发布入口' }, + { key: 'header_newposts_text', label: '按钮文案', type: 'input', default: ' 发布', dependsOn: { key: 'header_newposts_enabled', value: true } }, + { key: 'header_newposts_radius', label: '圆角 (px)', type: 'number', default: 20, min: 0, max: 30, dependsOn: { key: 'header_newposts_enabled', value: true } }, + { key: 'header_newposts_class', label: '自定义 CSS 类', type: 'input', default: '', dependsOn: { key: 'header_newposts_enabled', value: true } }, + ], + }, + { + key: 'color', + title: '导航配色', + icon: 'fas fa-palette', + fields: [ + { key: 'header_theme_custom', label: '自定义导航配色', type: 'switch', default: false, help: '启用后覆盖导航栏默认配色' }, + { key: 'header_theme_bg_custom', label: '导航背景色', type: 'color', default: '', dependsOn: { key: 'header_theme_custom', value: true } }, + { key: 'header_theme_bg', label: '导航背景色(备选)', type: 'color', default: '', dependsOn: { key: 'header_theme_custom', value: true } }, + { key: 'header_theme_color_custom', label: '导航文字色', type: 'color', default: '', dependsOn: { key: 'header_theme_custom', value: true } }, + ], + }, + { + key: 'components', + title: '导航组件', + icon: 'fas fa-puzzle-piece', + fields: [ + { key: 'header_search_enabled', label: '搜索入口', type: 'switch', default: true }, + { key: 'header_user_menu', label: '用户菜单', type: 'switch', default: true }, + { key: 'header_publish_btn', label: '发布按钮', type: 'switch', default: true, help: '管理员可见的前台发布入口' }, + { key: 'header_mobile_drawer', label: '移动端抽屉导航', type: 'switch', default: true }, + ], + }, + ], + }, + + /** + * Category page settings + */ + category: { + key: 'page_display.category', + title: '分类页面', + icon: 'fas fa-folder-open', + description: '为分类归档页配置头图、简介、列表模式、筛选条和侧边栏。', + defaults: { + category_cover_enabled: true, + category_desc_enabled: true, + category_header_style: 'cover', + category_list_mode: 'grid', + category_filter_bar: true, + category_sidebar: true, + category_per_page: 12, + category_default_cover: '', + category_post_count: true, + category_orderby: 'modified', + }, + groups: [ + { + key: 'header', + title: '分类头图', + icon: 'fas fa-image', + fields: [ + { key: 'category_header_style', label: '头部风格', type: 'radio', default: 'cover', options: [ + { label: '封面图', value: 'cover' }, + { label: '简洁标题', value: 'simple' }, + { label: '隐藏', value: 'none' }, + ]}, + { key: 'category_cover_enabled', label: '显示封面图', type: 'switch', default: true, dependsOn: { key: 'category_header_style', value: 'cover' } }, + { key: 'category_desc_enabled', label: '显示分类简介', type: 'switch', default: true }, + { key: 'category_default_cover', label: '默认封面图', type: 'upload', default: '', help: '未设置分类封面时使用的默认图片' }, + ], + }, + { + key: 'list', + title: '列表设置', + icon: 'fas fa-list', + fields: [ + { key: 'category_list_mode', label: '列表模式', type: 'radio', default: 'grid', options: [ + { label: '网格', value: 'grid' }, + { label: '列表', value: 'list' }, + { label: '紧凑', value: 'compact' }, + ]}, + { key: 'category_per_page', label: '每页文章数', type: 'number', default: 12, min: 4, max: 48 }, + { key: 'category_filter_bar', label: '筛选条', type: 'switch', default: true, help: '子分类筛选和排序' }, + { key: 'category_sidebar', label: '侧边栏', type: 'switch', default: true }, + { key: 'category_post_count', label: '显示文章数', type: 'switch', default: true, help: '在分类列表中显示每篇文章的浏览次数' }, + ], + }, + { + key: 'orderby', + title: '排序设置', + icon: 'fas fa-sort', + fields: [ + { key: 'category_orderby', label: '默认排序', type: 'select', default: 'modified', options: [ + { label: '最近更新', value: 'modified' }, + { label: '发布时间', value: 'date' }, + { label: '浏览数', value: 'views' }, + { label: '评论数', value: 'comments' }, + { label: '点赞数', value: 'likes' }, + ]}, + ], + }, + ], + }, + + /** + * Tag page settings + */ + tag: { + key: 'page_display.tag', + title: '标签页面', + icon: 'fas fa-tags', + description: '为标签归档页配置展示样式和布局。', + defaults: { + tag_header_style: 'simple', + tag_default_cover: '', + tag_list_mode: 'grid', + tag_sidebar: true, + tag_per_page: 12, + tag_post_count: true, + tag_orderby: 'count', + }, + groups: [ + { + key: 'header', + title: '标签头图', + icon: 'fas fa-tag', + fields: [ + { key: 'tag_header_style', label: '头部风格', type: 'radio', default: 'simple', options: [ + { label: '简洁标题', value: 'simple' }, + { label: '封面图', value: 'cover' }, + { label: '隐藏', value: 'none' }, + ]}, + { key: 'tag_default_cover', label: '默认封面图', type: 'upload', default: '' }, + ], + }, + { + key: 'list', + title: '列表设置', + icon: 'fas fa-list', + fields: [ + { key: 'tag_list_mode', label: '列表模式', type: 'radio', default: 'grid', options: [ + { label: '网格', value: 'grid' }, + { label: '列表', value: 'list' }, + ]}, + { key: 'tag_per_page', label: '每页文章数', type: 'number', default: 12, min: 4, max: 48 }, + { key: 'tag_sidebar', label: '侧边栏', type: 'switch', default: true }, + { key: 'tag_post_count', label: '显示文章数', type: 'switch', default: true }, + ], + }, + { + key: 'orderby', + title: '排序设置', + icon: 'fas fa-sort', + fields: [ + { key: 'tag_orderby', label: '默认排序', type: 'select', default: 'count', options: [ + { label: '文章数', value: 'count' }, + { label: '名称', value: 'name' }, + { label: '最近更新', value: 'modified' }, + ]}, + ], + }, + ], + }, + + /** + * User homepage settings + */ + 'user-home': { + key: 'page_display.user-home', + title: '用户主页', + icon: 'fas fa-user-circle', + description: '配置用户公开主页的布局、Tab 展示和互动功能。', + defaults: { + user_home_tabs: 'posts,comments,favorites,credits,product,plate,forum,follow', + user_home_tabs_order: 'posts,comments,favorites,credits,product,plate,forum,follow', + user_home_tabs_pc_hide: '', + user_home_tabs_mobile_hide: '', + user_home_sidebar: true, + user_home_cover: true, + user_home_follow: true, + user_home_posts_per_page: 10, + user_home_avatar: true, + user_home_name: true, + user_home_desc: true, + user_home_stats: true, + user_home_social: true, + author_main_tab_swiper: false, + user_home_message: true, + user_home_private: true, + user_home_paging_ajax: true, + }, + groups: [ + { + key: 'layout', + title: '主页布局', + icon: 'fas fa-th-large', + fields: [ + { key: 'user_home_cover', label: '封面区域', type: 'switch', default: true, help: '用户主页顶部封面图' }, + { key: 'user_home_sidebar', label: '侧边栏', type: 'switch', default: true }, + { key: 'user_home_follow', label: '关注按钮', type: 'switch', default: true }, + { key: 'user_home_posts_per_page', label: '每页文章数', type: 'number', default: 10, min: 5, max: 50 }, + { key: 'user_home_paging_ajax', label: 'AJAX 翻页', type: 'switch', default: true, help: '使用 AJAX 加载替代传统翻页' }, + ], + }, + { + key: 'tabs', + title: 'Tab 配置', + icon: 'fas fa-columns', + fields: [ + { key: 'user_home_tabs', label: '显示的 Tab', type: 'input', default: 'posts,comments,favorites,credits,product,plate,forum,follow', help: '逗号分隔: posts, comments, favorites, credits, product, plate, forum, follow' }, + { key: 'user_home_tabs_order', label: 'Tab 显示顺序', type: 'input', default: 'posts,comments,favorites,credits,product,plate,forum,follow', help: '逗号分隔,拖拽排列栏目顺序' }, + { key: 'user_home_tabs_pc_hide', label: 'PC 端隐藏', type: 'input', default: '', help: '逗号分隔要在 PC 端隐藏的栏目' }, + { key: 'user_home_tabs_mobile_hide', label: '移动端隐藏', type: 'input', default: '', help: '逗号分隔要在移动端隐藏的栏目' }, + { key: 'author_main_tab_swiper', label: '移动端滑动切换', type: 'switch', default: false, help: '移动端左右滑动切换栏目' }, + ], + }, + { + key: 'display', + title: '展示设置', + icon: 'fas fa-eye', + fields: [ + { key: 'user_home_avatar', label: '显示头像', type: 'switch', default: true }, + { key: 'user_home_name', label: '显示用户名', type: 'switch', default: true }, + { key: 'user_home_desc', label: '显示个人简介', type: 'switch', default: true }, + { key: 'user_home_stats', label: '显示统计数据', type: 'switch', default: true }, + { key: 'user_home_social', label: '显示社交链接', type: 'switch', default: true }, + { key: 'user_home_message', label: '私信按钮', type: 'switch', default: true, help: '在用户主页显示发送私信按钮' }, + { key: 'user_home_private', label: '留言按钮', type: 'switch', default: true, help: '在用户主页显示留言入口' }, + ], + }, + ], + }, + + /** + * Other pages settings (404, search results, etc.) + */ + other: { + key: 'page_display.other', + title: '其它页面', + icon: 'fas fa-file', + description: '配置 404 页面、搜索结果页等特殊页面的展示。', + defaults: { + page_header_style: '1', + page_header_cover_img: '', + page_404_search: true, + page_404_popular: true, + page_404_message: '页面未找到', + page_404_back: true, + page_search_sidebar: true, + page_search_per_page: 12, + page_author_cover: true, + page_author_sidebar: true, + page_author_posts_per_page: 12, + }, + groups: [ + { + key: 'page-header', + title: '统一页头', + icon: 'fas fa-heading', + fields: [ + { key: 'page_header_style', label: '页面标题样式', type: 'select', default: '1', options: [ + { label: '不显示', value: '' }, + { label: '简单', value: '1' }, + { label: '卡片', value: '2' }, + { label: '封面', value: '3' }, + ], help: '控制归档/搜索/页面的页头样式,单页可通过 post meta 覆盖' }, + { key: 'page_header_cover_img', label: '默认封面图', type: 'upload', default: '', help: '封面样式(样式3)的默认背景图,单页可覆盖' }, + ], + }, + { + key: 'page_404', + title: '404 页面', + icon: 'fas fa-exclamation-triangle', + fields: [ + { key: 'page_404_message', label: '404 提示文字', type: 'input', default: '页面未找到' }, + { key: 'page_404_search', label: '显示搜索框', type: 'switch', default: true }, + { key: 'page_404_popular', label: '显示热门文章', type: 'switch', default: true }, + { key: 'page_404_back', label: '显示返回首页', type: 'switch', default: true }, + ], + }, + { + key: 'search_results', + title: '搜索结果页', + icon: 'fas fa-search', + fields: [ + { key: 'page_search_sidebar', label: '搜索结果侧边栏', type: 'switch', default: true }, + { key: 'page_search_per_page', label: '每页结果数', type: 'number', default: 12, min: 4, max: 48 }, + ], + }, + { + key: 'author', + title: '作者页面', + icon: 'fas fa-user-edit', + fields: [ + { key: 'page_author_cover', label: '显示封面', type: 'switch', default: true }, + { key: 'page_author_sidebar', label: '显示侧边栏', type: 'switch', default: true }, + { key: 'page_author_posts_per_page', label: '每页文章数', type: 'number', default: 12, min: 4, max: 48 }, + ], + }, + ], + }, + + /** + * Global background settings + */ + background: { + key: 'page_display.background', + title: '全局背景', + icon: 'fas fa-image', + description: '配置站点全局背景图、背景色、暗色模式背景和遮罩。', + defaults: { + bg_color: '', + bg_image: '', + bg_size: '100%', + bg_position: 'center center', + bg_repeat: 'no-repeat', + bg_attachment: 'fixed', + bg_dark_color: '', + bg_dark_image: '', + bg_overlay: false, + bg_overlay_color: 'rgba(0, 0, 0, 0.3)', + page_bg_color: '', + page_bg_image: '', + }, + groups: [ + { + key: 'light', + title: '亮色背景', + icon: 'fas fa-sun', + fields: [ + { key: 'bg_color', label: '背景颜色', type: 'color', default: '', help: '留空则使用主题默认背景色' }, + { key: 'bg_image', label: '背景图片', type: 'media', help: '推荐使用高分辨率图片' }, + { key: 'bg_size', label: '图片尺寸', type: 'select', default: 'cover', options: [ + { label: '覆盖 (Cover)', value: 'cover' }, + { label: '包含 (Contain)', value: 'contain' }, + { label: '原始大小', value: 'auto' }, + { label: '拉伸填充', value: '100% 100%' }, + { label: '100% 宽度(旧版兼容)', value: '100%' }, + ]}, + { key: 'bg_position', label: '图片位置', type: 'select', default: 'center center', options: [ + { label: '居中', value: 'center center' }, + { label: '顶部居中', value: 'top center' }, + { label: '底部居中', value: 'bottom center' }, + ]}, + { key: 'bg_repeat', label: '图片重复', type: 'select', default: 'no-repeat', options: [ + { label: '不重复', value: 'no-repeat' }, + { label: '平铺', value: 'repeat' }, + { label: '水平平铺', value: 'repeat-x' }, + { label: '垂直平铺', value: 'repeat-y' }, + ]}, + { key: 'bg_attachment', label: '固定方式', type: 'select', default: 'fixed', options: [ + { label: '固定 (视差)', value: 'fixed' }, + { label: '随内容滚动', value: 'scroll' }, + ]}, + ], + }, + { + key: 'dark', + title: '暗色背景', + icon: 'fas fa-moon', + fields: [ + { key: 'bg_dark_color', label: '暗色背景颜色', type: 'color', default: '', help: '留空则使用主题默认暗色背景' }, + { key: 'bg_dark_image', label: '暗色背景图片', type: 'media', help: '暗色模式下显示的背景图' }, + ], + }, + { + key: 'overlay', + title: '遮罩设置', + icon: 'fas fa-layer-group', + fields: [ + { key: 'bg_overlay', label: '启用背景遮罩', type: 'switch', default: false, help: '在背景图上方叠加半透明遮罩,提升文字可读性' }, + { key: 'bg_overlay_color', label: '遮罩颜色', type: 'input', default: 'rgba(0, 0, 0, 0.3)', dependsOn: { key: 'bg_overlay', value: true }, help: '支持 rgba/hex 色值' }, + ], + }, + { + key: 'page', + title: '页面背景', + icon: 'fas fa-file', + fields: [ + { key: 'page_bg_color', label: '内容区背景色', type: 'color', default: '', help: '主内容区域的背景色' }, + { key: 'page_bg_image', label: '内容区背景图', type: 'media' }, + ], + }, + ], + }, + + /** + * Menu decoration settings + */ + 'menu-decoration': { + key: 'page_display.menu-decoration', + title: '菜单装饰', + icon: 'fas fa-bars', + description: '配置导航菜单的图标、徽章、下拉样式和图文卡片子菜单。', + defaults: { + menu_icon_enabled: true, + menu_badge_enabled: true, + menu_submenu_style: 'dropdown', + menu_submenu_columns: 1, + menu_submenu_width: 220, + menu_mobile_style: 'drawer', + menu_item_padding: 12, + menu_hover_effect: 'underline', + }, + groups: [ + { + key: 'icon', + title: '菜单图标', + icon: 'fas fa-icons', + fields: [ + { key: 'menu_icon_enabled', label: '显示菜单图标', type: 'switch', default: true, help: '在菜单项文字前显示图标(需在菜单管理中设置 CSS 类)' }, + { key: 'menu_badge_enabled', label: '显示菜单徽章', type: 'switch', default: true, help: '在菜单项上显示 "新"、"热" 等徽章标记' }, + ], + }, + { + key: 'submenu', + title: '下拉子菜单', + icon: 'fas fa-caret-square-down', + fields: [ + { + key: 'menu_submenu_style', + label: '子菜单样式', + type: 'radio', + default: 'dropdown', + options: [ + { label: '普通下拉', value: 'dropdown' }, + { label: '多列下拉', value: 'mega' }, + { label: '图文卡片', value: 'cards' }, + ], + }, + { key: 'menu_submenu_columns', label: '多列列数', type: 'number', default: 1, min: 1, max: 5, dependsOn: { key: 'menu_submenu_style', value: 'mega' } }, + { key: 'menu_submenu_width', label: '子菜单宽度 (px)', type: 'number', default: 220, min: 160, max: 800, dependsOn: { key: 'menu_submenu_style', value: 'mega' } }, + ], + }, + { + key: 'effect', + title: '交互效果', + icon: 'fas fa-magic', + fields: [ + { + key: 'menu_hover_effect', + label: 'Hover 效果', + type: 'radio', + default: 'underline', + options: [ + { label: '下划线', value: 'underline' }, + { label: '背景色', value: 'background' }, + { label: '文字变色', value: 'color' }, + { label: '无效果', value: 'none' }, + ], + }, + { key: 'menu_item_padding', label: '菜单项内边距 (px)', type: 'number', default: 12, min: 4, max: 32 }, + ], + }, + { + key: 'mobile', + title: '移动端菜单', + icon: 'fas fa-mobile-alt', + fields: [ + { + key: 'menu_mobile_style', + label: '移动端菜单样式', + type: 'radio', + default: 'drawer', + options: [ + { label: '抽屉', value: 'drawer' }, + { label: '全屏', value: 'fullscreen' }, + { label: '下拉', value: 'dropdown' }, + ], + }, + ], + }, + ], + }, + + /** + * Header components + */ + 'header-components': { + key: 'page_display.header-components', + title: '顶部多功能组件', + icon: 'fas fa-th', + description: '顶部轮播、图标卡片、搜索条、公告条等组件配置。', + defaults: { + header_components_search_enabled: true, + header_components_cards_enabled: false, + header_components_nav_color: '#ffffff', + header_components_show_on: ['home'], + header_components_device_visibility: '', + header_components_filter_blur_devices: [], + header_components_card_show_devices: ['pc', 'mobile'], + header_components_card_shared_bg: false, + header_components_card_blur: true, + header_components_card_icon_shape: 'circle', + header_components_icon_cards: [], + header_components_search_pc: true, + header_components_search_mobile: true, + header_components_search_show_keywords: false, + header_components_search_popular_limit: 5, + header_components_search_show_input_cat: false, + header_components_search_show_type: false, + slider_config_direction: 'horizontal', + slider_config_speed: 300, + slider_config_button: true, + slider_config_scale_height: false, + slider_config_scale: false, + slider_config_max_height: '', + slider_config_min_height: '', + }, + groups: [ + { + key: 'visibility', + title: '显示规则', + icon: 'fas fa-eye', + fields: [ + { key: 'header_components_search_enabled', label: '启用搜索覆盖层', type: 'switch', default: true, help: '在顶部组件区域显示搜索条覆盖层' }, + { key: 'header_components_cards_enabled', label: '启用图标卡片', type: 'switch', default: false, help: '在顶部组件区域显示图标卡片入口' }, + { key: 'header_components_show_on', label: '按页面类型启用', type: 'checkbox', default: ['home'], options: [ + { label: '首页', value: 'home' }, + { label: '文章', value: 'single' }, + { label: '页面', value: 'page' }, + { label: '分类', value: 'category' }, + { label: '标签', value: 'tag' }, + { label: '搜索', value: 'search' }, + { label: '归档', value: 'archive' }, + { label: '专题', value: 'special' }, + ], help: '选择哪些页面类型显示顶部组件,不选则关闭' }, + { key: 'header_components_device_visibility', label: '设备显示规则', type: 'radio', default: '', options: [ + { label: '全部', value: '' }, + { label: '仅电脑', value: 'only_pc' }, + { label: '仅移动', value: 'only_sm' }, + ] }, + ], + }, + { + key: 'nav-color', + title: '导航栏配色', + icon: 'fas fa-palette', + fields: [ + { key: 'header_components_nav_color', label: '导航栏文字颜色', type: 'color', default: '#ffffff', help: '顶部组件叠加时固定导航的文字颜色' }, + ], + }, + { + key: 'slider-config', + title: '幻灯片配置', + icon: 'fas fa-sliders-h', + fields: [ + { key: 'slider_config_direction', label: '滑动方向', type: 'radio', default: 'horizontal', options: [ + { label: '水平', value: 'horizontal' }, + { label: '垂直', value: 'vertical' }, + ]}, + { key: 'slider_config_speed', label: '切换速度 (ms)', type: 'number', default: 300, min: 100, max: 3000 }, + { key: 'slider_config_button', label: '显示前进后退按钮', type: 'switch', default: true }, + { key: 'slider_config_scale_height', label: '缩放高度', type: 'switch', default: false }, + { key: 'slider_config_scale', label: '缩放效果', type: 'switch', default: false }, + { key: 'slider_config_max_height', label: '最大高度', type: 'input', default: '' }, + { key: 'slider_config_min_height', label: '最小高度', type: 'input', default: '' }, + ], + }, + { + key: 'search', + title: '搜索组件', + icon: 'fas fa-search', + fields: [ + { key: 'header_components_search_pc', label: 'PC 端显示搜索', type: 'switch', default: true, dependsOn: { key: 'header_components_search_enabled', value: true } }, + { key: 'header_components_search_mobile', label: '移动端显示搜索', type: 'switch', default: true, dependsOn: { key: 'header_components_search_enabled', value: true } }, + { key: 'header_components_search_show_keywords', label: '显示热门搜索词', type: 'switch', default: false, dependsOn: { key: 'header_components_search_enabled', value: true } }, + { key: 'header_components_search_popular_limit', label: '热词数量', type: 'number', default: 5, min: 1, max: 20, dependsOn: { key: 'header_components_search_show_keywords', value: true } }, + { key: 'header_components_search_show_input_cat', label: '显示分类搜索', type: 'switch', default: false, dependsOn: { key: 'header_components_search_enabled', value: true }, help: '搜索框内显示分类选择下拉' }, + { key: 'header_components_search_show_type', label: '显示类型切换', type: 'switch', default: false, dependsOn: { key: 'header_components_search_enabled', value: true }, help: '搜索框内显示内容类型切换' }, + ], + }, + { + key: 'cards', + title: '图标卡片样式', + icon: 'fas fa-th-large', + fields: [ + { key: 'header_components_filter_blur_devices', label: '容器模糊设备', type: 'checkbox', default: [], dependsOn: { key: 'header_components_cards_enabled', value: true }, options: [ + { label: 'PC', value: 'pc' }, + { label: '移动端', value: 'mobile' }, + ]}, + { key: 'header_components_card_show_devices', label: '卡片显示设备', type: 'checkbox', default: ['pc', 'mobile'], dependsOn: { key: 'header_components_cards_enabled', value: true }, options: [ + { label: 'PC', value: 'pc' }, + { label: '移动端', value: 'mobile' }, + ]}, + { key: 'header_components_card_shared_bg', label: '共享卡片背景', type: 'switch', default: false, dependsOn: { key: 'header_components_cards_enabled', value: true }, help: '开启后所有图标卡片共用背景模式' }, + { key: 'header_components_card_blur', label: '启用卡片模糊效果', type: 'switch', default: true, dependsOn: { key: 'header_components_cards_enabled', value: true } }, + { key: 'header_components_card_icon_shape', label: '图标形状', type: 'radio', default: 'circle', dependsOn: { key: 'header_components_cards_enabled', value: true }, options: [ + { label: '圆形', value: 'circle' }, + { label: '方形', value: 'square' }, + ]}, + ], + }, + { + key: 'icon-cards', + title: '图标卡片列表', + icon: 'fas fa-list', + fields: [ + { + key: 'header_components_icon_cards', + label: '图标卡片', + type: 'repeater', + sanitize_type: 'array', + default: [], + dependsOn: { key: 'header_components_cards_enabled', value: true }, + addText: '添加卡片', + fields: [ + { key: 'icon', label: '图标类名', type: 'input', default: 'fas fa-star', placeholder: 'fas fa-star' }, + { key: 'custom_icon', label: '自定义图标 HTML', type: 'textarea', default: '', rows: 2, placeholder: '...' }, + { key: 'icon_class', label: '图标配色', type: 'select', default: 'c-blue', options: [ + { label: 'Blue', value: 'c-blue' }, + { label: 'Yellow', value: 'c-yellow' }, + { label: 'Green', value: 'c-green' }, + { label: 'Red', value: 'c-red' }, + { label: 'Orange', value: 'c-orange' }, + { label: 'Cyan', value: 'c-cyan' }, + { label: 'Pink', value: 'c-pink' }, + { label: 'Gradient Cyan', value: 'jb-cyan' }, + { label: 'Gradient Green', value: 'jb-green' }, + { label: 'Gradient Orange', value: 'jb-orange' }, + ]}, + { key: 'title', label: '标题', type: 'input', default: '' }, + { key: 'desc', label: '描述', type: 'input', default: '' }, + { key: 'url', label: '链接 URL', type: 'input', default: '', placeholder: 'https://example.com' }, + { key: 'target', label: '打开方式', type: 'radio', default: '_self', options: [ + { label: '当前标签页', value: '_self' }, + { label: '新标签页', value: '_blank' }, + ]}, + ], + }, + ], + }, + ], + }, + + /** + * Footer settings (already has a real Vue page) + */ + footer: { + key: 'page_display.footer', + title: '底部页脚', + icon: 'fas fa-shoe-prints', + description: '页脚布局、友链、版权信息配置。实际编辑界面在 FooterSettings.vue(customPage),此处为 REST 聚合描述。', + defaults: { + footer_columns: 3, + footer_copyright: true, + footer_icp: true, + icp_number: '', + footer_police: true, + police_number: '', + footer_theme_custom: false, + footer_theme_bg_custom: '', + footer_theme_bg: '', + footer_theme_color_custom: '', + footer_contact_wechat_img: '', + footer_contact_qq: '', + footer_contact_weibo: '', + footer_contact_email: '', + footer_t1_m_s: ['pc', 'mobile'], + footer_t1_img: '', + footer_t1_img_dark: '', + footer_t1_t: '', + fcode_t1_code: '', + footer_contact_m_s: ['pc', 'mobile'], + footer_contact_more: '', + fcode_t2_code_1: '', + fcode_t2_code_2: '', + footer_mini_img_m_s: ['pc', 'mobile'], + footer_mini_img: '', + fcode_customize_code: '', + }, + groups: [ + { + key: 'layout', + title: '页脚布局', + icon: 'fas fa-columns', + fields: [ + { key: 'footer_columns', label: '页脚列数', type: 'number', default: 3, min: 1, max: 4 }, + { key: 'footer_copyright', label: '显示版权信息', type: 'switch', default: true }, + { key: 'footer_icp', label: '显示ICP备案', type: 'switch', default: true }, + { key: 'icp_number', label: 'ICP备案号', type: 'input', default: '' }, + { key: 'footer_police', label: '显示公安备案', type: 'switch', default: true }, + { key: 'police_number', label: '公安备案号', type: 'input', default: '' }, + ], + }, + { + key: 'color', + title: '页脚颜色', + icon: 'fas fa-palette', + fields: [ + { key: 'footer_theme_custom', label: '自定义页脚颜色', type: 'switch', default: false }, + { key: 'footer_theme_bg_custom', label: '自定义背景色', type: 'color', default: '' }, + { key: 'footer_theme_bg', label: '背景图片', type: 'media', default: '' }, + { key: 'footer_theme_color_custom', label: '自定义文字颜色', type: 'color', default: '' }, + ], + }, + { + key: 'contact', + title: '联系方式', + icon: 'fas fa-address-book', + fields: [ + { key: 'footer_contact_wechat_img', label: '微信二维码', type: 'media', default: '' }, + { key: 'footer_contact_qq', label: 'QQ', type: 'input', default: '' }, + { key: 'footer_contact_weibo', label: '微博', type: 'input', default: '' }, + { key: 'footer_contact_email', label: '邮箱', type: 'input', default: '' }, + ], + }, + { + key: 'brand', + title: '品牌列(第一列)', + icon: 'fas fa-building', + fields: [ + { key: 'footer_t1_m_s', label: '显示设备', type: 'checkbox', default: ['pc', 'mobile'] }, + { key: 'footer_t1_img', label: '页脚 Logo(日间)', type: 'media', default: '' }, + { key: 'footer_t1_img_dark', label: '页脚 Logo(夜间)', type: 'media', default: '' }, + { key: 'footer_t1_t', label: '品牌标题', type: 'input', default: '' }, + { key: 'fcode_t1_code', label: '品牌列自定义代码', type: 'textarea', default: '' }, + ], + }, + { + key: 'contact_ext', + title: '联系方式扩展', + icon: 'fas fa-phone-alt', + fields: [ + { key: 'footer_contact_m_s', label: '显示设备', type: 'checkbox', default: ['pc', 'mobile'] }, + { key: 'footer_contact_more', label: '更多联系项', type: 'textarea', default: '' }, + { key: 'fcode_t2_code_1', label: '联系区上方自定义代码', type: 'textarea', default: '' }, + { key: 'fcode_t2_code_2', label: '联系区下方自定义代码', type: 'textarea', default: '' }, + ], + }, + { + key: 'mini_qr', + title: '二维码图片组(第三列)', + icon: 'fas fa-qrcode', + fields: [ + { key: 'footer_mini_img_m_s', label: '显示设备', type: 'checkbox', default: ['pc', 'mobile'] }, + { key: 'footer_mini_img', label: '二维码组', type: 'textarea', default: '' }, + ], + }, + { + key: 'custom', + title: '底部自定义', + icon: 'fas fa-code', + fields: [ + { key: 'fcode_customize_code', label: '底部自定义代码', type: 'textarea', default: '' }, + ], + }, + ], + }, + + /** + * Homepage settings (already has a real Vue page) + */ + homepage: { + key: 'page_display.homepage', + title: '首页配置', + icon: 'fas fa-home', + description: '首页模块、布局、推荐位配置。', + defaults: {}, + groups: [], + }, + + /** + * Special/topic page settings + */ + special: { + key: 'page_display.special', + title: '专题页面', + icon: 'fas fa-layer-group', + description: '配置专题聚合页的封面、摘要、布局、聚合规则和子页面展示策略。', + defaults: { + special_cover_enabled: true, + special_summary_enabled: true, + special_summary_length: 200, + special_default_cover: '', + special_layout: 'grid', + special_columns: 2, + special_source_type: 'category', + special_source_ids: '', + special_subpage_display: 'cards', + special_subpage_count: 6, + special_sidebar: true, + special_pagination: 'numeric', + special_per_page: 12, + special_post_count: false, + special_orderby: 'date', + }, + groups: [ + { + key: 'header', + title: '专题头部', + icon: 'fas fa-image', + fields: [ + { key: 'special_cover_enabled', label: '显示专题封面', type: 'switch', default: true, help: '在专题页顶部展示封面图' }, + { key: 'special_summary_enabled', label: '显示专题摘要', type: 'switch', default: true }, + { key: 'special_summary_length', label: '摘要最大长度', type: 'number', default: 200, min: 50, max: 500, dependsOn: { key: 'special_summary_enabled', value: true } }, + { key: 'special_default_cover', label: '默认封面图', type: 'upload', default: '', help: '未设置专题封面时使用的默认图片' }, + ], + }, + { + key: 'layout', + title: '布局设置', + icon: 'fas fa-th-large', + fields: [ + { + key: 'special_layout', + label: '内容布局', + type: 'radio', + default: 'grid', + options: [ + { label: '网格', value: 'grid' }, + { label: '列表', value: 'list' }, + { label: '瀑布流', value: 'masonry' }, + ], + }, + { key: 'special_columns', label: '网格列数', type: 'number', default: 2, min: 1, max: 4, dependsOn: { key: 'special_layout', value: 'grid' } }, + { key: 'special_sidebar', label: '侧边栏', type: 'switch', default: true }, + { key: 'special_post_count', label: '显示文章数', type: 'switch', default: false, help: '在专题列表中显示每篇文章的浏览次数' }, + ], + }, + { + key: 'aggregation', + title: '聚合规则', + icon: 'fas fa-database', + fields: [ + { + key: 'special_source_type', + label: '内容来源', + type: 'radio', + default: 'category', + options: [ + { label: '分类', value: 'category' }, + { label: '标签', value: 'tag' }, + { label: '手动选择', value: 'manual' }, + { label: '最新文章', value: 'latest' }, + ], + }, + { key: 'special_source_ids', label: '来源 ID 列表', type: 'input', default: '', placeholder: '逗号分隔分类/标签 ID', help: '填写分类或标签的 ID,逗号分隔' }, + ], + }, + { + key: 'subpage', + title: '子页面展示', + icon: 'fas fa-clone', + fields: [ + { + key: 'special_subpage_display', + label: '子页面样式', + type: 'radio', + default: 'cards', + options: [ + { label: '卡片', value: 'cards' }, + { label: '列表', value: 'list' }, + { label: '时间线', value: 'timeline' }, + ], + }, + { key: 'special_subpage_count', label: '每页子页面数', type: 'number', default: 6, min: 1, max: 24 }, + ], + }, + { + key: 'pagination', + title: '分页设置', + icon: 'fas fa-ellipsis-h', + fields: [ + { + key: 'special_pagination', + label: '分页模式', + type: 'radio', + default: 'numeric', + options: [ + { label: '数字分页', value: 'numeric' }, + { label: '加载更多', value: 'load_more' }, + { label: '无限滚动', value: 'infinite' }, + ], + }, + { key: 'special_per_page', label: '每页文章数', type: 'number', default: 12, min: 4, max: 48 }, + ], + }, + { + key: 'orderby', + title: '排序设置', + icon: 'fas fa-sort', + fields: [ + { key: 'special_orderby', label: '默认排序', type: 'select', default: 'date', options: [ + { label: '发布时间', value: 'date' }, + { label: '最近更新', value: 'modified' }, + { label: '浏览数', value: 'views' }, + { label: '评论数', value: 'comments' }, + ]}, + ], + }, + ], + }, +} diff --git a/assets/admin/src/schema/sections/payment/affiliate.js b/assets/admin/src/schema/sections/payment/affiliate.js new file mode 100644 index 00000000..90112a04 --- /dev/null +++ b/assets/admin/src/schema/sections/payment/affiliate.js @@ -0,0 +1,73 @@ +export const affiliateSchema = { + key: 'payment.affiliate', + title: '推广返佣', + icon: 'fas fa-share-alt', + description: '配置返佣比例、归因窗口和结算说明。', + defaults: { + payment_affiliate_enabled: false, + payment_affiliate_ratio: 10, + payment_affiliate_window_days: 30, + payment_affiliate_require_paid_order: true, + payment_affiliate_self_s: true, + payment_affiliate_show_users: true, + payment_affiliate_user_ratio: 10, + payment_affiliate_vip1_ratio: 0, + payment_affiliate_vip2_ratio: 0, + payment_affiliate_poster_s: true, + payment_affiliate_poster_img: '', + payment_affiliate_poster_title: '', + payment_affiliate_poster_desc: '', + payment_affiliate_text_desc: '', + payment_affiliate_text_details_title: '', + payment_affiliate_text_details: '', + payment_affiliate_text_discount: '', + payment_affiliate_note: '返佣链路复用邀请体系,订单归因和结算自动衔接当前策略字段。', + }, + groups: [ + { + key: 'policy', + title: '返佣策略', + icon: 'fas fa-project-diagram', + fields: [ + { key: 'payment_affiliate_enabled', label: '启用推广返佣结构', type: 'switch', default: false }, + { key: 'payment_affiliate_ratio', label: '返佣比例(%)', type: 'number', default: 10, min: 0, max: 100, dependsOn: { key: 'payment_affiliate_enabled', value: true } }, + { key: 'payment_affiliate_window_days', label: '归因窗口(天)', type: 'number', default: 30, min: 1, max: 365, dependsOn: { key: 'payment_affiliate_enabled', value: true } }, + { key: 'payment_affiliate_require_paid_order', label: '仅对已支付订单返佣', type: 'switch', default: true, dependsOn: { key: 'payment_affiliate_enabled', value: true } }, + { key: 'payment_affiliate_self_s', label: '自购返佣', type: 'switch', default: true, dependsOn: { key: 'payment_affiliate_enabled', value: true } }, + { key: 'payment_affiliate_show_users', label: '显示推广用户', type: 'switch', default: true, dependsOn: { key: 'payment_affiliate_enabled', value: true } }, + ], + }, + { + key: 'user-ratio', + title: '用户返佣比例', + icon: 'fas fa-users-cog', + fields: [ + { key: 'payment_affiliate_user_ratio', label: '普通用户比例(%)', type: 'number', default: 10, min: 0, max: 100, dependsOn: { key: 'payment_affiliate_enabled', value: true } }, + { key: 'payment_affiliate_vip1_ratio', label: 'VIP1比例(%)', type: 'number', default: 0, min: 0, max: 100, dependsOn: { key: 'payment_affiliate_enabled', value: true } }, + { key: 'payment_affiliate_vip2_ratio', label: 'VIP2比例(%)', type: 'number', default: 0, min: 0, max: 100, dependsOn: { key: 'payment_affiliate_enabled', value: true } }, + ], + }, + { + key: 'poster', + title: '推广海报', + icon: 'fas fa-image', + fields: [ + { key: 'payment_affiliate_poster_s', label: '启用推广海报', type: 'switch', default: true, dependsOn: { key: 'payment_affiliate_enabled', value: true } }, + { key: 'payment_affiliate_poster_img', label: '海报背景图', type: 'media', default: '', dependsOn: { key: 'payment_affiliate_enabled', value: true } }, + { key: 'payment_affiliate_poster_title', label: '海报标题', type: 'input', default: '', dependsOn: { key: 'payment_affiliate_enabled', value: true } }, + { key: 'payment_affiliate_poster_desc', label: '海报描述', type: 'input', default: '', dependsOn: { key: 'payment_affiliate_enabled', value: true } }, + ], + }, + { + key: 'text', + title: '文案设置', + icon: 'fas fa-pen-fancy', + fields: [ + { key: 'payment_affiliate_text_desc', label: '返佣说明', type: 'textarea', default: '', dependsOn: { key: 'payment_affiliate_enabled', value: true } }, + { key: 'payment_affiliate_text_details_title', label: '明细标题', type: 'input', default: '', dependsOn: { key: 'payment_affiliate_enabled', value: true } }, + { key: 'payment_affiliate_text_details', label: '明细说明', type: 'textarea', default: '', dependsOn: { key: 'payment_affiliate_enabled', value: true } }, + { key: 'payment_affiliate_text_discount', label: '折扣文案', type: 'input', default: '', dependsOn: { key: 'payment_affiliate_enabled', value: true } }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/payment/cardPass.js b/assets/admin/src/schema/sections/payment/cardPass.js new file mode 100644 index 00000000..c039a4d6 --- /dev/null +++ b/assets/admin/src/schema/sections/payment/cardPass.js @@ -0,0 +1,59 @@ +export const cardPassSchema = { + key: 'payment.card-pass', + title: '充值卡', + icon: 'fas fa-ticket-alt', + description: '配置充值卡类型、卡号规则、批量生成和兑换逻辑。', + defaults: { + card_pass_enabled: false, + card_pass_desc: '', + card_pass_type: 'balance', + card_pass_prefix: 'AK', + card_pass_length: 16, + card_pass_batch_size: 50, + card_pass_max_redeem: 1, + card_pass_expiry: 0, + card_pass_amount: 10, + card_pass_vip_days: 30, + card_pass_credits: 0, + card_pass_redeem_page: '', + card_pass_success_msg: '兑换成功!', + card_pass_fail_msg: '卡号无效或已过期', + }, + groups: [ + { + key: 'basic', + title: '充值卡设置', + icon: 'fas fa-id-card', + fields: [ + { key: 'card_pass_enabled', label: '启用充值卡', type: 'switch', default: false }, + { key: 'card_pass_desc', label: '充值卡说明', type: 'textarea', default: '', dependsOn: { key: 'card_pass_enabled', value: true } }, + { key: 'card_pass_type', label: '充值卡类型', type: 'select', default: 'balance', options: [{ label: '余额卡', value: 'balance' }, { label: 'VIP卡', value: 'vip' }, { label: '积分卡', value: 'credits' }], dependsOn: { key: 'card_pass_enabled', value: true } }, + { key: 'card_pass_prefix', label: '卡号前缀', type: 'input', default: 'AK', dependsOn: { key: 'card_pass_enabled', value: true } }, + { key: 'card_pass_length', label: '卡号长度', type: 'number', default: 16, min: 8, max: 32, dependsOn: { key: 'card_pass_enabled', value: true } }, + { key: 'card_pass_batch_size', label: '批量生成数量', type: 'number', default: 50, min: 1, max: 500, dependsOn: { key: 'card_pass_enabled', value: true } }, + ], + }, + { + key: 'redeem', + title: '兑换设置', + icon: 'fas fa-exchange-alt', + fields: [ + { key: 'card_pass_max_redeem', label: '单卡最大兑换次数', type: 'number', default: 1, min: 1, max: 100, dependsOn: { key: 'card_pass_enabled', value: true } }, + { key: 'card_pass_expiry', label: '有效期(天)', type: 'number', default: 0, min: 0, help: '0为永久有效', dependsOn: { key: 'card_pass_enabled', value: true } }, + { key: 'card_pass_amount', label: '面额(元)', type: 'number', default: 10, min: 1, max: 9999, dependsOn: { key: 'card_pass_enabled', value: true } }, + { key: 'card_pass_vip_days', label: 'VIP天数', type: 'number', default: 30, min: 1, max: 3650, dependsOn: { key: 'card_pass_enabled', value: true } }, + { key: 'card_pass_credits', label: '赠送积分', type: 'number', default: 0, min: 0, max: 99999, dependsOn: { key: 'card_pass_enabled', value: true } }, + ], + }, + { + key: 'display', + title: '展示设置', + icon: 'fas fa-desktop', + fields: [ + { key: 'card_pass_redeem_page', label: '兑换页面', type: 'input', default: '', dependsOn: { key: 'card_pass_enabled', value: true } }, + { key: 'card_pass_success_msg', label: '兑换成功提示', type: 'input', default: '兑换成功!', dependsOn: { key: 'card_pass_enabled', value: true } }, + { key: 'card_pass_fail_msg', label: '兑换失败提示', type: 'input', default: '卡号无效或已过期', dependsOn: { key: 'card_pass_enabled', value: true } }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/payment/gateway.js b/assets/admin/src/schema/sections/payment/gateway.js new file mode 100644 index 00000000..050acb31 --- /dev/null +++ b/assets/admin/src/schema/sections/payment/gateway.js @@ -0,0 +1,172 @@ +export const gatewaySchema = { + key: 'payment.gateway', + title: '收款接口', + icon: 'fas fa-credit-card', + description: '配置支付网关凭据、回调校验口令与运行模式。填写凭据后对应网关即变为”可用”。', + defaults: { + payment_gateway_mode: 'manual_review', + payment_gateway_test_enabled: true, + payment_gateway_manual_review: true, + payment_gateway_callback_secret: '', + payment_gateway_help: '未接真实支付网关时,建议使用测试网关或人工确认模式完成联调。', + payment_wechat_enabled: false, + payment_alipay_enabled: false, + payment_paypal_enabled: false, + payment_balance_enabled: false, + payment_card_pass_enabled: false, + payment_xunhupay_enabled: false, + payment_xhpay_enabled: false, + payment_payjs_enabled: false, + payment_epay_enabled: false, + payment_vmq_enabled: false, + payment_codepay_enabled: false, + payment_stripe_enabled: false, + wechat_merchantid: '', + wechat_jsapi: '', + wechat_appsecret: '', + wechat_cert_path: '', + wechat_key_path: '', + alipay_appid: '', + alipay_privatekey: '', + alipay_publickey: '', + alipay_return_url: '', + alipay_notify_url: '', + paypal_client_id: '', + paypal_client_secret: '', + paypal_currency: 'USD', + paypal_sandbox: true, + xunhupay_appid: '', + xunhupay_appsecret: '', + xhpay_mchid: '', + xhpay_alipay_v2: '', + xhpay_wx_h5: '', + payjs_mchid: '', + payjs_key: '', + epay_apiurl: '', + epay_pid: '', + epay_key: '', + stripe_secret_key: '', + stripe_publishable_key: '', + stripe_webhook_secret: '', + stripe_currency: 'usd', + stripe_test_mode: true, + }, + groups: [ + { + key: 'adapter', + title: '网关适配器', + icon: 'fas fa-plug', + fields: [ + { key: 'payment_gateway_mode', label: '运行模式', type: 'radio', default: 'manual_review', options: [{ label: '人工确认', value: 'manual_review' }, { label: '测试网关', value: 'test_gateway' }, { label: '适配器就绪', value: 'adapter_ready' }] }, + { key: 'payment_gateway_test_enabled', label: '启用测试网关', type: 'switch', default: true }, + { key: 'payment_gateway_manual_review', label: '保留人工复核', type: 'switch', default: true }, + { key: 'payment_gateway_callback_secret', label: '回调校验口令', type: 'input', default: '', help: '真实网关接入时可直接复用该口令字段。' }, + { key: 'payment_gateway_help', label: '运维说明', type: 'textarea', default: '未接真实支付网关时,建议使用测试网关或人工确认模式完成联调。' }, + ], + }, + { + key: 'gateways', + title: '支付网关', + icon: 'fas fa-toggle-on', + fields: [ + { key: 'payment_wechat_enabled', label: '微信支付', type: 'switch', default: false }, + { key: 'payment_alipay_enabled', label: '支付宝', type: 'switch', default: false }, + { key: 'payment_paypal_enabled', label: 'PayPal', type: 'switch', default: false }, + { key: 'payment_stripe_enabled', label: 'Stripe', type: 'switch', default: false }, + { key: 'payment_balance_enabled', label: '余额支付', type: 'switch', default: false }, + { key: 'payment_card_pass_enabled', label: '卡密充值', type: 'switch', default: false }, + { key: 'payment_xunhupay_enabled', label: '虎皮椒', type: 'switch', default: false }, + { key: 'payment_xhpay_enabled', label: '小火付', type: 'switch', default: false }, + { key: 'payment_payjs_enabled', label: 'PayJS', type: 'switch', default: false }, + { key: 'payment_epay_enabled', label: '易支付', type: 'switch', default: false }, + { key: 'payment_vmq_enabled', label: 'V免签', type: 'switch', default: false }, + { key: 'payment_codepay_enabled', label: 'Codepay', type: 'switch', default: false }, + ], + }, + { + key: 'wechat', + title: '微信支付配置', + icon: 'fab fa-weixin', + fields: [ + { key: 'wechat_merchantid', label: '商户号', type: 'input', default: '' }, + { key: 'wechat_jsapi', label: 'JSAPI密钥', type: 'input', default: '' }, + { key: 'wechat_appsecret', label: 'AppSecret', type: 'input', default: '' }, + { key: 'wechat_cert_path', label: '证书路径', type: 'input', default: '', help: '退款等接口需要,绝对路径。' }, + { key: 'wechat_key_path', label: '密钥路径', type: 'input', default: '' }, + ], + }, + { + key: 'alipay', + title: '支付宝配置', + icon: 'fab fa-alipay', + fields: [ + { key: 'alipay_appid', label: 'AppID', type: 'input', default: '' }, + { key: 'alipay_privatekey', label: '应用私钥', type: 'textarea', rows: 3, default: '' }, + { key: 'alipay_publickey', label: '支付宝公钥', type: 'textarea', rows: 3, default: '' }, + { key: 'alipay_return_url', label: '同步回调地址', type: 'input', default: '' }, + { key: 'alipay_notify_url', label: '异步回调地址', type: 'input', default: '' }, + ], + }, + { + key: 'paypal', + title: 'PayPal 配置', + icon: 'fab fa-paypal', + fields: [ + { key: 'paypal_client_id', label: 'Client ID', type: 'input', default: '' }, + { key: 'paypal_client_secret', label: 'Client Secret', type: 'input', default: '' }, + { key: 'paypal_currency', label: '货币', type: 'input', default: 'USD' }, + { key: 'paypal_sandbox', label: '沙箱模式', type: 'switch', default: true }, + ], + }, + { + key: 'xunhupay', + title: '虎皮椒配置', + icon: 'fas fa-pepper-hot', + fields: [ + { key: 'xunhupay_appid', label: 'AppID', type: 'input', default: '' }, + { key: 'xunhupay_appsecret', label: 'AppSecret', type: 'input', default: '' }, + ], + }, + { + key: 'xhpay', + title: '小火付配置', + icon: 'fas fa-fire', + fields: [ + { key: 'xhpay_mchid', label: '商户号', type: 'input', default: '' }, + { key: 'xhpay_alipay_v2', label: '支付宝V2', type: 'input', default: '' }, + { key: 'xhpay_wx_h5', label: '微信H5', type: 'input', default: '' }, + ], + }, + { + key: 'payjs', + title: 'PayJS 配置', + icon: 'fab fa-js', + fields: [ + { key: 'payjs_mchid', label: '商户号', type: 'input', default: '' }, + { key: 'payjs_key', label: '通信密钥', type: 'input', default: '' }, + ], + }, + { + key: 'epay', + title: '易支付配置', + icon: 'fas fa-university', + fields: [ + { key: 'epay_apiurl', label: '接口地址', type: 'input', default: '' }, + { key: 'epay_pid', label: '商户ID', type: 'input', default: '' }, + { key: 'epay_key', label: '商户密钥', type: 'input', default: '' }, + ], + }, + { + key: 'stripe', + title: 'Stripe 配置', + icon: 'fab fa-stripe', + fields: [ + { key: 'stripe_secret_key', label: 'Secret Key', type: 'input', default: '' }, + { key: 'stripe_publishable_key', label: 'Publishable Key', type: 'input', default: '' }, + { key: 'stripe_webhook_secret', label: 'Webhook Secret', type: 'input', default: '', help: 'Stripe Dashboard 配置 Webhook 后获得,用于验签。' }, + { key: 'stripe_currency', label: '货币', type: 'input', default: 'usd' }, + { key: 'stripe_test_mode', label: '测试模式', type: 'switch', default: true }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/payment/index.js b/assets/admin/src/schema/sections/payment/index.js new file mode 100644 index 00000000..16741d18 --- /dev/null +++ b/assets/admin/src/schema/sections/payment/index.js @@ -0,0 +1,25 @@ +/** + * Payment & Paid domain schemas. + */ + +import { paidSchema } from './paid.js' +import { rechargeSchema } from './recharge.js' +import { vipSchema } from './vip.js' +import { revenueSchema } from './revenue.js' +import { affiliateSchema } from './affiliate.js' +import { gatewaySchema } from './gateway.js' +import { orderSchema } from './order.js' +import { cardPassSchema } from './cardPass.js' +import { withdrawalSchema } from './withdrawal.js' + +export const paymentSchemas = { + paid: paidSchema, + recharge: rechargeSchema, + vip: vipSchema, + revenue: revenueSchema, + affiliate: affiliateSchema, + gateway: gatewaySchema, + order: orderSchema, + cardPass: cardPassSchema, + withdrawal: withdrawalSchema, +} diff --git a/assets/admin/src/schema/sections/payment/order.js b/assets/admin/src/schema/sections/payment/order.js new file mode 100644 index 00000000..7d5e12b7 --- /dev/null +++ b/assets/admin/src/schema/sections/payment/order.js @@ -0,0 +1,96 @@ +export const orderSchema = { + key: 'payment.order', + title: '订单设置', + icon: 'fas fa-file-invoice', + description: '配置订单前缀、有效期、展示字段和状态文案。', + defaults: { + payment_order_prefix: 'AKSRT', + payment_order_expiry: 30, + payment_show_user: true, + payment_show_paycount: true, + payment_no_logged_in: true, + payment_cookie_day: 15, + payment_no_logged_remind: '', + payment_wait_float_s: true, + payment_order_max_minutes: 30, + payment_mark: '付费', + payment_box_position: 'content', + payment_free_logged_show: false, + payment_show_ip: false, + payment_show_device: false, + payment_show_order_no: true, + payment_show_create_time: true, + payment_show_pay_time: true, + payment_order_status_pending: '待支付', + payment_order_status_paid: '已支付', + payment_order_status_completed: '已完成', + payment_order_status_cancelled: '已取消', + payment_order_status_refunded: '已退款', + payment_order_status_refunding: '退款中', + payment_order_status_processing: '处理中', + payment_service: '', + payment_service_icon: '', + payment_service_value: '', + payment_service_qq: '', + payment_service_wechat: '', + }, + groups: [ + { + key: 'basic', + title: '订单基础', + icon: 'fas fa-cog', + fields: [ + { key: 'payment_order_prefix', label: '订单前缀', type: 'input', default: 'AKSRT' }, + { key: 'payment_order_expiry', label: '订单有效期(分钟)', type: 'number', default: 30, min: 5, max: 1440 }, + { key: 'payment_show_user', label: '显示用户信息', type: 'switch', default: true }, + { key: 'payment_show_paycount', label: '显示购买次数', type: 'switch', default: true }, + { key: 'payment_no_logged_in', label: '未登录提示', type: 'switch', default: true }, + { key: 'payment_cookie_day', label: 'Cookie有效期(天)', type: 'number', default: 15, min: 1, max: 31, help: '未登录购买 Cookie 有效时间,1-31 天' }, + { key: 'payment_no_logged_remind', label: '未登录提醒文案', type: 'textarea', default: '', help: '未登录用户查看付费内容时的提醒文案' }, + { key: 'payment_wait_float_s', label: '订单等待浮动', type: 'switch', default: true }, + { key: 'payment_order_max_minutes', label: '订单最大支付时间(分钟)', type: 'number', default: 30, min: 5, max: 1440 }, + ], + }, + { + key: 'display', + title: '展示设置', + icon: 'fas fa-eye', + fields: [ + { key: 'payment_mark', label: '付费标记', type: 'input', default: '付费' }, + { key: 'payment_box_position', label: '付费框位置', type: 'radio', default: 'top', options: [{ label: '文章模块上', value: 'box_top' }, { label: '内容顶', value: 'top' }, { label: '内容底', value: 'bottom' }, { label: '模块下', value: 'box_bottom' }] }, + { key: 'payment_free_logged_show', label: '登录用户显示免费', type: 'switch', default: false }, + { key: 'payment_show_ip', label: '显示用户IP', type: 'switch', default: false }, + { key: 'payment_show_device', label: '显示设备信息', type: 'switch', default: false }, + { key: 'payment_show_order_no', label: '显示订单号', type: 'switch', default: true }, + { key: 'payment_show_create_time', label: '显示创建时间', type: 'switch', default: true }, + { key: 'payment_show_pay_time', label: '显示支付时间', type: 'switch', default: true }, + ], + }, + { + key: 'status', + title: '状态设置', + icon: 'fas fa-tags', + fields: [ + { key: 'payment_order_status_pending', label: '待支付状态文案', type: 'input', default: '待支付' }, + { key: 'payment_order_status_paid', label: '已支付状态文案', type: 'input', default: '已支付' }, + { key: 'payment_order_status_completed', label: '已完成状态文案', type: 'input', default: '已完成' }, + { key: 'payment_order_status_cancelled', label: '已取消状态文案', type: 'input', default: '已取消' }, + { key: 'payment_order_status_refunded', label: '已退款状态文案', type: 'input', default: '已退款' }, + { key: 'payment_order_status_refunding', label: '退款中状态文案', type: 'input', default: '退款中' }, + { key: 'payment_order_status_processing', label: '处理中状态文案', type: 'input', default: '处理中' }, + ], + }, + { + key: 'service', + title: '服务设置', + icon: 'fas fa-headset', + fields: [ + { key: 'payment_service', label: '客服设置', type: 'textarea', default: '' }, + { key: 'payment_service_icon', label: '客服图标', type: 'icon', default: '' }, + { key: 'payment_service_value', label: '客服内容', type: 'input', default: '' }, + { key: 'payment_service_qq', label: 'QQ客服', type: 'input', default: '' }, + { key: 'payment_service_wechat', label: '微信客服', type: 'media', default: '' }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/payment/paid.js b/assets/admin/src/schema/sections/payment/paid.js new file mode 100644 index 00000000..ae3511e7 --- /dev/null +++ b/assets/admin/src/schema/sections/payment/paid.js @@ -0,0 +1,72 @@ +export const paidSchema = { + key: 'payment.paid', + title: '下载/付费内容中心', + icon: 'fas fa-download', + description: '统一管理资源下载、付费内容、角色免扣和人工交付说明。', + defaults: { + payment_paid_post_types: 'post', + payment_vip_free_download: false, + payment_vip_exempt_roles: '', + payment_paid_catalog_note: '', + payment_manual_delivery_note: '付费或人工确认内容请在订单备注中记录交付方式。', + payment_paid_mark: '付费', + payment_currency_symbol: '¥', + payment_paid_show_user: true, + payment_paid_show_paycount: true, + payment_paid_box_position: 'top', + payment_download_enable: false, + payment_download_remote_s: true, + payment_download_verify_nonce: true, + payment_download_user_log: 50, + payment_download_post_log: 50, + payment_download_remote_size: 2048, + payment_download_remote_ext: 'zip,rar,7z', + }, + groups: [ + { + key: 'strategy', + title: '付费内容策略', + icon: 'fas fa-layer-group', + fields: [ + { key: 'payment_paid_post_types', label: '受管内容类型', type: 'input', default: 'post', help: '逗号分隔,例如 post,course,doc' }, + { key: 'payment_vip_free_download', label: '角色白名单免扣下载', type: 'switch', default: false }, + { key: 'payment_vip_exempt_roles', label: '免扣角色列表', type: 'input', default: '', help: '逗号分隔的 WordPress role slug,例如 administrator,editor,vip' }, + ], + }, + { + key: 'delivery', + title: '交付说明', + icon: 'fas fa-receipt', + fields: [ + { key: 'payment_paid_catalog_note', label: '前台付费提示', type: 'textarea', default: '', help: '显示在资源/付费内容页的运营说明。' }, + { key: 'payment_manual_delivery_note', label: '人工交付备注', type: 'textarea', default: '付费或人工确认内容请在订单备注中记录交付方式。', help: '人工确认或离线交付流程的标准说明。' }, + ], + }, + { + key: 'display', + title: '展示设置', + icon: 'fas fa-eye', + fields: [ + { key: 'payment_paid_mark', label: '付费标记', type: 'input', default: '付费' }, + { key: 'payment_currency_symbol', label: '货币符号', type: 'input', default: '¥', help: '商品价格前缀,如 ¥、$、¥' }, + { key: 'payment_paid_show_user', label: '显示购买用户', type: 'switch', default: true }, + { key: 'payment_paid_show_paycount', label: '显示购买次数', type: 'switch', default: true }, + { key: 'payment_paid_box_position', label: '付费框位置', type: 'radio', default: 'top', options: [{ label: '文章模块上', value: 'box_top' }, { label: '内容顶', value: 'top' }, { label: '内容底', value: 'bottom' }, { label: '模块下', value: 'box_bottom' }] }, + ], + }, + { + key: 'download', + title: '下载设置', + icon: 'fas fa-cloud-download-alt', + fields: [ + { key: 'payment_download_enable', label: '启用下载功能', type: 'switch', default: false }, + { key: 'payment_download_remote_s', label: '远程文件下载', type: 'switch', default: true, dependsOn: { key: 'payment_download_enable', value: true } }, + { key: 'payment_download_verify_nonce', label: '下载鉴权', type: 'switch', default: true, dependsOn: { key: 'payment_download_enable', value: true } }, + { key: 'payment_download_user_log', label: '用户下载记录条数', type: 'number', default: 50, min: 0, max: 9999, help: '0 为不保存', dependsOn: { key: 'payment_download_enable', value: true } }, + { key: 'payment_download_post_log', label: '文章下载记录条数', type: 'number', default: 50, min: 0, max: 9999, help: '0 为不保存', dependsOn: { key: 'payment_download_enable', value: true } }, + { key: 'payment_download_remote_size', label: '远程文件大小限制(KB)', type: 'number', default: 2048, min: 1, max: 999999, dependsOn: { key: 'payment_download_remote_s', value: true } }, + { key: 'payment_download_remote_ext', label: '远程文件格式限制', type: 'input', default: 'zip,rar,7z', help: '逗号分隔扩展名', dependsOn: { key: 'payment_download_remote_s', value: true } }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/payment/recharge.js b/assets/admin/src/schema/sections/payment/recharge.js new file mode 100644 index 00000000..2ef3c672 --- /dev/null +++ b/assets/admin/src/schema/sections/payment/recharge.js @@ -0,0 +1,94 @@ +export const rechargeSchema = { + key: 'payment.recharge', + title: '余额充值', + icon: 'fas fa-charging-station', + description: '配置测试充值、手动确认和充值套餐结构。', + defaults: { + payment_recharge_enabled: false, + payment_recharge_mode: 'manual_review', + payment_recharge_desc: '', + payment_recharge_auto_to_balance: true, + payment_recharge_packages: '30|30|入门包\n98|108|推荐包', + payment_recharge_help: '当前支持人工确认与测试联调;接入真实网关时复用同一组字段。', + payment_recharge_product: '', + payment_recharge_price: 0, + payment_recharge_tag: '元', + payment_recharge_tag_class: '', + payment_recharge_custom_s: true, + payment_recharge_custom_limit: 9999, + payment_balance_transfer_s: false, + payment_balance_transfer_service_charge: 0, + payment_balance_transfer_desc: '', + payment_recharge_pass_charge_s: false, + payment_recharge_pass_charge_desc: '', + payment_recharge_pass_charge_only_password: false, + payment_recharge_exchange_s: false, + payment_recharge_exchange_rate: 100, + payment_recharge_exchange_desc: '', + }, + groups: [ + { + key: 'basic', + title: '充值开关', + icon: 'fas fa-wallet', + fields: [ + { key: 'payment_recharge_enabled', label: '启用余额充值结构', type: 'switch', default: false }, + { key: 'payment_recharge_mode', label: '充值模式', type: 'radio', default: 'manual_review', options: [{ label: '人工确认', value: 'manual_review' }, { label: '测试网关', value: 'test_gateway' }], dependsOn: { key: 'payment_recharge_enabled', value: true } }, + { key: 'payment_recharge_desc', label: '充值说明', type: 'textarea', default: '', dependsOn: { key: 'payment_recharge_enabled', value: true } }, + { key: 'payment_recharge_auto_to_balance', label: '自动到账到余额', type: 'switch', default: true, dependsOn: { key: 'payment_recharge_enabled', value: true } }, + ], + }, + { + key: 'packages', + title: '充值套餐', + icon: 'fas fa-box-open', + fields: [ + { key: 'payment_recharge_packages', label: '套餐列表', type: 'textarea', default: '30|30|入门包\n98|108|推荐包', help: '每行一个套餐:支付金额|到账金额|标题', dependsOn: { key: 'payment_recharge_enabled', value: true } }, + { key: 'payment_recharge_help', label: '帮助文案', type: 'textarea', default: '当前支持人工确认与测试联调;接入真实网关时复用同一组字段。', dependsOn: { key: 'payment_recharge_enabled', value: true } }, + ], + }, + { + key: 'product', + title: '充值产品', + icon: 'fas fa-box', + fields: [ + { key: 'payment_recharge_product', label: '充值产品', type: 'input', default: '', dependsOn: { key: 'payment_recharge_enabled', value: true } }, + { key: 'payment_recharge_price', label: '充值价格', type: 'number', default: 0, min: 0, max: 99999, dependsOn: { key: 'payment_recharge_enabled', value: true } }, + { key: 'payment_recharge_tag', label: '价格标签', type: 'input', default: '元', dependsOn: { key: 'payment_recharge_enabled', value: true } }, + { key: 'payment_recharge_tag_class', label: '标签样式', type: 'input', default: '', dependsOn: { key: 'payment_recharge_enabled', value: true } }, + { key: 'payment_recharge_custom_s', label: '自定义金额', type: 'switch', default: true, dependsOn: { key: 'payment_recharge_enabled', value: true } }, + { key: 'payment_recharge_custom_limit', label: '自定义金额限制', type: 'number', default: 9999, min: 1, max: 99999, dependsOn: { key: 'payment_recharge_enabled', value: true } }, + ], + }, + { + key: 'transfer', + title: '余额转账', + icon: 'fas fa-exchange-alt', + fields: [ + { key: 'payment_balance_transfer_s', label: '启用余额转账', type: 'switch', default: false }, + { key: 'payment_balance_transfer_service_charge', label: '转账手续费(%)', type: 'number', default: 0, min: 0, max: 50, dependsOn: { key: 'payment_balance_transfer_s', value: true } }, + { key: 'payment_balance_transfer_desc', label: '转账说明', type: 'textarea', default: '', dependsOn: { key: 'payment_balance_transfer_s', value: true } }, + ], + }, + { + key: 'pass-charge', + title: '充值卡免扣', + icon: 'fas fa-ticket-alt', + fields: [ + { key: 'payment_recharge_pass_charge_s', label: '启用充值卡免扣', type: 'switch', default: false }, + { key: 'payment_recharge_pass_charge_desc', label: '免扣说明', type: 'textarea', default: '', dependsOn: { key: 'payment_recharge_pass_charge_s', value: true } }, + { key: 'payment_recharge_pass_charge_only_password', label: '仅密码免扣', type: 'switch', default: false, dependsOn: { key: 'payment_recharge_pass_charge_s', value: true } }, + ], + }, + { + key: 'exchange', + title: '积分兑换', + icon: 'fas fa-coins', + fields: [ + { key: 'payment_recharge_exchange_s', label: '启用积分兑换', type: 'switch', default: false }, + { key: 'payment_recharge_exchange_rate', label: '兑换比例', type: 'number', default: 100, min: 1, max: 10000, help: 'N 积分 = 1 元', dependsOn: { key: 'payment_recharge_exchange_s', value: true } }, + { key: 'payment_recharge_exchange_desc', label: '兑换说明', type: 'textarea', default: '', dependsOn: { key: 'payment_recharge_exchange_s', value: true } }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/payment/revenue.js b/assets/admin/src/schema/sections/payment/revenue.js new file mode 100644 index 00000000..125b777c --- /dev/null +++ b/assets/admin/src/schema/sections/payment/revenue.js @@ -0,0 +1,44 @@ +export const revenueSchema = { + key: 'payment.revenue', + title: '创作分成', + icon: 'fas fa-chart-line', + description: '配置作者分成比例、结算门槛和结算周期。', + defaults: { + payment_revenue_enabled: false, + payment_revenue_share_ratio: 70, + payment_revenue_ratio_vip1: 0, + payment_revenue_ratio_vip2: 0, + payment_revenue_settlement_threshold: 100, + payment_revenue_cycle_days: 30, + payment_revenue_points_rule: '', + payment_revenue_note: '分成策略和台账结构已就绪,订单入账后自动按比例结算。', + payment_revenue_desc_details: '', + payment_revenue_desc_page: '', + }, + groups: [ + { + key: 'policy', + title: '分成策略', + icon: 'fas fa-percentage', + fields: [ + { key: 'payment_revenue_enabled', label: '启用创作分成结构', type: 'switch', default: false }, + { key: 'payment_revenue_share_ratio', label: '作者分成比例(%)', type: 'number', default: 70, min: 0, max: 100, dependsOn: { key: 'payment_revenue_enabled', value: true } }, + { key: 'payment_revenue_ratio_vip1', label: 'VIP1分成比例(%)', type: 'number', default: 0, min: 0, max: 100, dependsOn: { key: 'payment_revenue_enabled', value: true } }, + { key: 'payment_revenue_ratio_vip2', label: 'VIP2分成比例(%)', type: 'number', default: 0, min: 0, max: 100, dependsOn: { key: 'payment_revenue_enabled', value: true } }, + { key: 'payment_revenue_settlement_threshold', label: '最小结算金额', type: 'number', default: 100, min: 0, max: 100000, dependsOn: { key: 'payment_revenue_enabled', value: true } }, + { key: 'payment_revenue_cycle_days', label: '结算周期(天)', type: 'number', default: 30, min: 1, max: 365, dependsOn: { key: 'payment_revenue_enabled', value: true } }, + { key: 'payment_revenue_points_rule', label: '积分规则', type: 'textarea', default: '', dependsOn: { key: 'payment_revenue_enabled', value: true } }, + { key: 'payment_revenue_note', label: '结算备注', type: 'textarea', default: '分成策略和台账结构已就绪,订单入账后自动按比例结算。', dependsOn: { key: 'payment_revenue_enabled', value: true } }, + ], + }, + { + key: 'display', + title: '展示设置', + icon: 'fas fa-file-alt', + fields: [ + { key: 'payment_revenue_desc_details', label: '收益明细文案', type: 'textarea', default: '', dependsOn: { key: 'payment_revenue_enabled', value: true } }, + { key: 'payment_revenue_desc_page', label: '收益说明页', type: 'input', default: '', dependsOn: { key: 'payment_revenue_enabled', value: true } }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/payment/vip.js b/assets/admin/src/schema/sections/payment/vip.js new file mode 100644 index 00000000..c0091770 --- /dev/null +++ b/assets/admin/src/schema/sections/payment/vip.js @@ -0,0 +1,102 @@ +export const vipSchema = { + key: 'payment.vip', + title: 'VIP会员', + icon: 'fas fa-crown', + description: '配置会员名称、套餐和付费权益说明。', + defaults: { + payment_vip_enabled: false, + payment_vip_name: 'VIP会员', + payment_vip_badge_text: 'VIP', + payment_vip_desc: '', + payment_vip_packages: '30|30|月卡\n365|365|年卡', + payment_vip_help: '会员权益以免扣下载和身份展示为主,开通后立即生效。', + payment_vip_download_limit: 0, + payment_vip_download_speed: 0, + payment_vip_discount: 100, + payment_vip_pass_charge: false, + payment_vip_pass_charge_desc: '', + payment_vip_renew: true, + payment_vip_renew_desc: '', + payment_vip_renew_price_type: 'same', + payment_vip_renew_discount: 100, + payment_vip_renew_reduce: 0, + payment_vip_upgrade: true, + payment_vip_upgrade_desc: '', + payment_vip_upgrade_product: '', + payment_vip_month_price: 0, + payment_vip_year_price: 0, + payment_vip_permanent_price: 0, + payment_vip_unit_price: 0, + payment_vip_unit_tag: '元', + payment_vip_jump_price: 0, + }, + groups: [ + { + key: 'basic', + title: '会员基础', + icon: 'fas fa-user-shield', + fields: [ + { key: 'payment_vip_enabled', label: '启用会员结构', type: 'switch', default: false }, + { key: 'payment_vip_name', label: '会员名称', type: 'input', default: 'VIP会员', dependsOn: { key: 'payment_vip_enabled', value: true } }, + { key: 'payment_vip_badge_text', label: '会员徽标文案', type: 'input', default: 'VIP', dependsOn: { key: 'payment_vip_enabled', value: true } }, + { key: 'payment_vip_desc', label: '会员说明', type: 'textarea', default: '', dependsOn: { key: 'payment_vip_enabled', value: true } }, + ], + }, + { + key: 'packages', + title: '会员套餐', + icon: 'fas fa-gem', + fields: [ + { key: 'payment_vip_packages', label: '套餐列表', type: 'textarea', default: '30|30|月卡\n365|365|年卡', help: '每行一个套餐:价格|时长(天)|标题', dependsOn: { key: 'payment_vip_enabled', value: true } }, + { key: 'payment_vip_help', label: '权益说明', type: 'textarea', default: '会员权益以免扣下载和身份展示为主,开通后立即生效。', dependsOn: { key: 'payment_vip_enabled', value: true } }, + ], + }, + { + key: 'benefit', + title: '会员权益', + icon: 'fas fa-gift', + fields: [ + { key: 'payment_vip_download_limit', label: '下载限制', type: 'number', default: 0, min: 0, max: 9999, help: '0为不限制', dependsOn: { key: 'payment_vip_enabled', value: true } }, + { key: 'payment_vip_download_speed', label: '下载速度(KB/s)', type: 'number', default: 0, min: 0, max: 99999, help: '0为不限制', dependsOn: { key: 'payment_vip_enabled', value: true } }, + { key: 'payment_vip_discount', label: '折扣(%)', type: 'number', default: 100, min: 1, max: 100, dependsOn: { key: 'payment_vip_enabled', value: true } }, + { key: 'payment_vip_pass_charge', label: '积分充值免扣', type: 'switch', default: false, dependsOn: { key: 'payment_vip_enabled', value: true } }, + { key: 'payment_vip_pass_charge_desc', label: '积分充值说明', type: 'textarea', default: '', dependsOn: { key: 'payment_vip_enabled', value: true } }, + ], + }, + { + key: 'renew', + title: '续费设置', + icon: 'fas fa-sync-alt', + fields: [ + { key: 'payment_vip_renew', label: '允许续费', type: 'switch', default: true, dependsOn: { key: 'payment_vip_enabled', value: true } }, + { key: 'payment_vip_renew_desc', label: '续费说明', type: 'textarea', default: '', dependsOn: { key: 'payment_vip_enabled', value: true } }, + { key: 'payment_vip_renew_price_type', label: '续费价格类型', type: 'radio', default: 'same', options: [{ label: '相同价格', value: 'same' }, { label: '折扣价格', value: 'discount' }], dependsOn: { key: 'payment_vip_enabled', value: true } }, + { key: 'payment_vip_renew_discount', label: '续费折扣(%)', type: 'number', default: 100, min: 1, max: 100, dependsOn: { key: 'payment_vip_enabled', value: true } }, + { key: 'payment_vip_renew_reduce', label: '续费减免金额', type: 'number', default: 0, min: 0, max: 9999, dependsOn: { key: 'payment_vip_enabled', value: true } }, + ], + }, + { + key: 'upgrade', + title: '升级设置', + icon: 'fas fa-arrow-up', + fields: [ + { key: 'payment_vip_upgrade', label: '允许升级', type: 'switch', default: true, dependsOn: { key: 'payment_vip_enabled', value: true } }, + { key: 'payment_vip_upgrade_desc', label: '升级说明', type: 'textarea', default: '', dependsOn: { key: 'payment_vip_enabled', value: true } }, + { key: 'payment_vip_upgrade_product', label: '升级产品', type: 'input', default: '', dependsOn: { key: 'payment_vip_enabled', value: true } }, + ], + }, + { + key: 'price', + title: '价格设置', + icon: 'fas fa-yen-sign', + fields: [ + { key: 'payment_vip_month_price', label: '月卡价格', type: 'number', default: 0, min: 0, max: 99999, dependsOn: { key: 'payment_vip_enabled', value: true } }, + { key: 'payment_vip_year_price', label: '年卡价格', type: 'number', default: 0, min: 0, max: 99999, dependsOn: { key: 'payment_vip_enabled', value: true } }, + { key: 'payment_vip_permanent_price', label: '永久价格', type: 'number', default: 0, min: 0, max: 99999, dependsOn: { key: 'payment_vip_enabled', value: true } }, + { key: 'payment_vip_unit_price', label: '单位价格', type: 'number', default: 0, min: 0, max: 99999, dependsOn: { key: 'payment_vip_enabled', value: true } }, + { key: 'payment_vip_unit_tag', label: '单位标签', type: 'input', default: '元', dependsOn: { key: 'payment_vip_enabled', value: true } }, + { key: 'payment_vip_jump_price', label: '跳转价格', type: 'number', default: 0, min: 0, max: 99999, dependsOn: { key: 'payment_vip_enabled', value: true } }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/payment/withdrawal.js b/assets/admin/src/schema/sections/payment/withdrawal.js new file mode 100644 index 00000000..a5cfc03b --- /dev/null +++ b/assets/admin/src/schema/sections/payment/withdrawal.js @@ -0,0 +1,23 @@ +export const withdrawalSchema = { + key: 'payment.withdrawal', + title: '提现管理', + icon: 'fas fa-hand-holding-usd', + description: '配置提现门槛、手续费比例和提现说明。', + defaults: { + pay_rebate_withdraw_lowest_money: 50, + withdraw_service_charge: 0, + pay_rebate_withdraw_text_details: '', + }, + groups: [ + { + key: 'basic', + title: '提现设置', + icon: 'fas fa-sliders-h', + fields: [ + { key: 'pay_rebate_withdraw_lowest_money', label: '最低提现金额', type: 'number', default: 50, min: 1, max: 99999 }, + { key: 'withdraw_service_charge', label: '提现手续费(%)', type: 'number', default: 0, min: 0, max: 50 }, + { key: 'pay_rebate_withdraw_text_details', label: '提现说明', type: 'textarea', default: '' }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/permissions/index.js b/assets/admin/src/schema/sections/permissions/index.js new file mode 100644 index 00000000..d7732a91 --- /dev/null +++ b/assets/admin/src/schema/sections/permissions/index.js @@ -0,0 +1,316 @@ +/** + * Permissions & Security domain schemas. + * Section keys must stay aligned with the router and PHP schema provider. + */ + +export const permissionsSchemas = { + /** + * Login / captcha / access guard settings + */ + login: { + key: 'permissions.login', + title: '登录权限', + icon: 'fas fa-shield-alt', + description: '管理登录、注册、评论等场景的人机验证与访问限制。', + defaults: { + enabled: false, + active_provider: '', + mode: 'always', + disabled_scenarios: [], + tencent: {}, + aliyun: {}, + cloudflare: {}, + geetest: {}, + }, + groups: [ + { + key: 'basic', + title: '基础设置', + icon: 'fas fa-cog', + fields: [ + { key: 'enabled', label: '启用人机验证', type: 'switch', help: '开启后将在指定场景要求用户完成人机验证' }, + { + key: 'active_provider', + label: '当前服务商', + type: 'radio', + buttonStyle: false, + options: [ + { label: '腾讯云 Captcha', value: 'tencent' }, + { label: '阿里云 CAPTCHA 2.0', value: 'aliyun' }, + { label: 'Cloudflare Turnstile', value: 'cloudflare' }, + { label: '极验 GeeTest', value: 'geetest' }, + ], + dependsOn: 'enabled', + }, + { + key: 'mode', + label: '触发模式', + type: 'radio', + buttonStyle: false, + options: [ + { label: '始终显示', value: 'always' }, + ], + dependsOn: 'enabled', + }, + { + key: 'disabled_scenarios', + label: '停用场景', + type: 'checkbox', + help: '勾选后表示该场景不启用人机验证;未勾选的场景会启用。', + dependsOn: 'enabled', + options: [ + { label: '发表评论', value: 'comment' }, + { label: '用户登录', value: 'login' }, + { label: '用户注册', value: 'register' }, + { label: '绑定第三方', value: 'bind' }, + { label: '找回密码', value: 'lostpassword' }, + { label: '修改密码', value: 'change_password' }, + { label: '更改邮箱', value: 'change_email' }, + { label: '邮箱验证', value: 'verify_email' }, + { label: '资源下载', value: 'download' }, + { label: '每日签到', value: 'signin' }, + ], + }, + ], + }, + { + key: 'providers', + title: '服务商配置', + icon: 'fas fa-server', + description: '按当前选择的服务商填写对应密钥。', + fields: [ + { + key: 'provider_hint', + label: '配置说明', + type: 'notice', + alertType: 'info', + alertContent: '下方会渲染当前验证码服务商的专属配置面板。保存仍通过统一 schema contract 进行。', + dependsOn: 'enabled', + }, + { key: 'tencent', label: '腾讯云配置', type: 'custom', dependsOn: { key: 'active_provider', value: 'tencent' } }, + { key: 'aliyun', label: '阿里云配置', type: 'custom', dependsOn: { key: 'active_provider', value: 'aliyun' } }, + { key: 'cloudflare', label: 'Cloudflare 配置', type: 'custom', dependsOn: { key: 'active_provider', value: 'cloudflare' } }, + { key: 'geetest', label: '极验配置', type: 'custom', dependsOn: { key: 'active_provider', value: 'geetest' } }, + ], + }, + ], + }, + + /** + * Capability matrix metadata contract. + * The live page is still orchestration-heavy custom UI for now. + */ + capabilities: { + key: 'permissions.capabilities', + title: '基本权限 / 能力矩阵', + icon: 'fas fa-user-shield', + description: '统一管理用户治理、投稿和评论的角色能力矩阵。', + defaults: {}, + groups: [], + }, + + /** + * Moderation settings + */ + moderation: { + key: 'permissions.moderation', + title: '审核和风控', + icon: 'fas fa-gavel', + description: '管理评论审核、反垃圾、限流与风控配置。', + defaults: { + enabled: false, + akismet_enabled: false, + akismet_site_url: '', + akismet_blog_lang: 'zh-CN', + akismet_api_key: '', + ai_enabled: false, + ai_moderate_comments: true, + ai_moderate_background: true, + auto_approve_low_risk: false, + auto_reject_high_risk: false, + low_risk_max_score: 35, + high_risk_min_score: 80, + blocked_keywords: '', + rate_limit_enabled: false, + rate_limit_min_interval: 8, + rate_limit_max_per_hour: 15, + geoip_enabled: false, + geoip_block_countries: [], + }, + groups: [ + { + key: 'basic', + title: '审核总开关', + icon: 'fas fa-power-off', + fields: [ + { key: 'enabled', label: '启用审核系统', type: 'switch', help: '关闭后评论将不经过主题审核链路。' }, + ], + }, + { + key: 'akismet', + title: 'Akismet', + icon: 'fas fa-filter', + fields: [ + { key: 'akismet_enabled', label: '启用 Akismet', type: 'switch', dependsOn: 'enabled' }, + { key: 'akismet_site_url', label: 'Site URL', type: 'input', placeholder: 'https://your-site.com', dependsOn: { key: 'akismet_enabled', value: true } }, + { key: 'akismet_blog_lang', label: '博客语言', type: 'input', placeholder: 'zh-CN', dependsOn: { key: 'akismet_enabled', value: true } }, + { key: 'akismet_api_key', label: 'API Key', type: 'input', inputType: 'password', placeholder: 'Akismet API Key', dependsOn: { key: 'akismet_enabled', value: true } }, + ], + }, + { + key: 'ai', + title: 'AI 审核', + icon: 'fas fa-robot', + fields: [ + { key: 'ai_enabled', label: '启用 AI 审核', type: 'switch', dependsOn: 'enabled' }, + { key: 'ai_api_hint', label: '审核 API 配置', type: 'notice', alertType: 'info', alertContent: 'API 接入(百度/阿里云等)在「扩展 → API内容审核」中统一配置,此处仅控制审核范围和策略。', dependsOn: { key: 'ai_enabled', value: true } }, + { key: 'ai_moderate_comments', label: '用户评论', type: 'switch', dependsOn: { key: 'ai_enabled', value: true } }, + { key: 'ai_moderate_background', label: '用户资料背景', type: 'switch', dependsOn: { key: 'ai_enabled', value: true } }, + { key: 'auto_approve_low_risk', label: '低风险自动通过', type: 'switch', dependsOn: { key: 'ai_enabled', value: true } }, + { key: 'auto_reject_high_risk', label: '高风险自动拒绝', type: 'switch', dependsOn: { key: 'ai_enabled', value: true } }, + { key: 'low_risk_max_score', label: '低风险上限', type: 'number', min: 0, max: 100, dependsOn: { key: 'ai_enabled', value: true } }, + { key: 'high_risk_min_score', label: '高风险下限', type: 'number', min: 0, max: 100, dependsOn: { key: 'ai_enabled', value: true } }, + ], + }, + { + key: 'keywords', + title: '关键词拦截', + icon: 'fas fa-ban', + fields: [ + { key: 'blocked_keywords', label: '屏蔽关键词列表', type: 'textarea', rows: 4, placeholder: '每行一个关键词,也支持 /regex/i 格式。命中后会进入待审核并记录原因。', dependsOn: 'enabled' }, + ], + }, + { + key: 'rate-limit', + title: '频率限制', + icon: 'fas fa-stopwatch', + fields: [ + { key: 'rate_limit_enabled', label: '启用频率限制', type: 'switch', dependsOn: 'enabled' }, + { key: 'rate_limit_min_interval', label: '最小间隔(秒)', type: 'number', min: 1, max: 300, dependsOn: { key: 'rate_limit_enabled', value: true } }, + { key: 'rate_limit_max_per_hour', label: '每小时上限', type: 'number', min: 1, max: 200, dependsOn: { key: 'rate_limit_enabled', value: true } }, + ], + }, + { + key: 'geoip', + title: 'GeoIP 拦截', + icon: 'fas fa-globe-asia', + fields: [ + { key: 'geoip_enabled', label: '启用 GeoIP 拦截', type: 'switch', dependsOn: 'enabled' }, + { key: 'geoip_hint', label: 'IP 数据源', type: 'notice', alertType: 'info', alertContent: '复用「扩展 → IP归属地」中配置的数据源和 API Key。', dependsOn: { key: 'geoip_enabled', value: true } }, + { key: 'geoip_block_countries', label: '屏蔽国家/地区', type: 'custom', dependsOn: { key: 'geoip_enabled', value: true } }, + ], + }, + ], + }, + + /** + * Comment permissions + */ + comment: { + key: 'permissions.comment', + title: '评论权限', + icon: 'fas fa-comment-dots', + description: '控制评论功能的开关和权限要求。', + defaults: { + comment_allow: true, + comment_require_login: false, + comment_nested_depth: 3, + comment_allow_reply: true, + }, + groups: [ + { + key: 'basic', + title: '评论基础', + icon: 'fas fa-cog', + fields: [ + { key: 'comment_allow', label: '允许评论', type: 'switch', help: '全局开启评论功能' }, + { key: 'comment_require_login', label: '需登录评论', type: 'switch', help: '只有登录用户才能评论' }, + { key: 'comment_nested_depth', label: '嵌套层级', type: 'slider', min: 1, max: 5, default: 3 }, + { key: 'comment_allow_reply', label: '允许回复', type: 'switch' }, + ], + }, + ], + }, + + /** + * Attachment permissions + */ + attachment: { + key: 'permissions.attachment', + title: '附件权限', + icon: 'fas fa-paperclip', + description: '控制文件上传和附件下载权限。', + defaults: { + allow_avatar_upload: true, + allow_cover_upload: true, + allow_background_upload: false, + avatar_max_size: 2, + allow_submit_images: true, + submit_image_max_size: 5, + allow_comment_images: false, + comment_image_max_size: 2, + allow_resource_upload: true, + resource_max_size: 50, + }, + groups: [ + { + key: 'profile', + title: '头像与封面', + icon: 'fas fa-user-circle', + fields: [ + { key: 'allow_avatar_upload', label: '允许用户上传头像', type: 'switch' }, + { key: 'allow_cover_upload', label: '允许用户上传封面图', type: 'switch' }, + { key: 'allow_background_upload', label: '允许用户上传背景图', type: 'switch' }, + { key: 'avatar_max_size', label: '头像/封面大小上限 (MB)', type: 'number', min: 1, max: 10 }, + ], + }, + { + key: 'content', + title: '内容上传', + icon: 'fas fa-images', + fields: [ + { key: 'allow_submit_images', label: '允许投稿插入图片', type: 'switch' }, + { key: 'submit_image_max_size', label: '投稿图片大小上限 (MB)', type: 'number', min: 1, max: 20 }, + { key: 'allow_comment_images', label: '允许评论插入图片', type: 'switch' }, + { key: 'comment_image_max_size', label: '评论图片大小上限 (MB)', type: 'number', min: 1, max: 10 }, + ], + }, + { + key: 'resource', + title: '资源附件', + icon: 'fas fa-file-archive', + fields: [ + { key: 'allow_resource_upload', label: '允许资源附件上传', type: 'switch' }, + { key: 'resource_max_size', label: '资源附件大小上限 (MB)', type: 'number', min: 1, max: 200 }, + ], + }, + ], + }, + + /** + * Download permissions + */ + download: { + key: 'permissions.download', + title: '下载权限', + icon: 'fas fa-download', + description: '控制资源下载的权限和限制。', + defaults: { + download_require_login: false, + download_daily_limit: 0, + download_captcha: false, + }, + groups: [ + { + key: 'basic', + title: '下载设置', + icon: 'fas fa-cog', + fields: [ + { key: 'download_require_login', label: '需登录下载', type: 'switch' }, + { key: 'download_daily_limit', label: '每日下载限制', type: 'number', min: 0, help: '0 表示不限制' }, + { key: 'download_captcha', label: '下载验证码', type: 'switch', help: '下载前需要验证码' }, + ], + }, + ], + }, +}; diff --git a/assets/admin/src/schema/sections/post-list/comment.js b/assets/admin/src/schema/sections/post-list/comment.js new file mode 100644 index 00000000..77d42cb4 --- /dev/null +++ b/assets/admin/src/schema/sections/post-list/comment.js @@ -0,0 +1,100 @@ +export const commentSchema = { + key: 'post_list.comment', + title: '评论设置', + icon: 'fas fa-comment-dots', + description: '配置评论区的排序、分页、权限、工具栏与互动方式。', + defaults: { + comment_order: 'asc', + comment_per_page: 20, + comment_pagination_type: 'numbered', + comment_paging_ajax_ias_s: true, + comment_paging_ajax_ias_max: 3, + comment_nested_depth: 3, + comment_avatar_size: 48, + comment_require_login: false, + comment_allow_reply: true, + comment_close_comments: false, + comment_edit_window: 10, + comment_smilie: true, + comment_code: true, + comment_img: true, + comment_upload_img: false, + comment_like_s: true, + comment_quick_s: true, + comment_city_s: true, + comment_author_tag: true, + comment_corderby: true, + comment_empty_text: '暂无评论,来做第一个评论的人吧~', + comment_title: '评论', + comment_submit_text: '提交评论', + comment_text: '说点什么吧...', + }, + groups: [ + { + key: 'display', + title: '评论展示', + icon: 'fas fa-comment', + fields: [ + { key: 'comment_order', label: '评论排序', type: 'radio', default: 'asc', options: [ + { label: '正序 (旧→新)', value: 'asc' }, + { label: '倒序 (新→旧)', value: 'desc' }, + ]}, + { key: 'comment_per_page', label: '每页评论数', type: 'number', default: 20, min: 5, max: 100 }, + { key: 'comment_pagination_type', label: '分页方式', type: 'radio', default: 'numbered', options: [ + { label: '翻页', value: 'numbered' }, + { label: '加载更多', value: 'load_more' }, + { label: '无限滚动', value: 'infinite' }, + ]}, + { key: 'comment_paging_ajax_ias_s', label: '评论自动加载', type: 'switch', default: true, help: '评论无限滚动时自动触发加载' }, + { key: 'comment_paging_ajax_ias_max', label: '评论最大加载页数', type: 'number', default: 3, min: 0, max: 10, help: '0 = 不限' }, + { key: 'comment_nested_depth', label: '回复嵌套深度', type: 'number', default: 3, min: 1, max: 10 }, + { key: 'comment_avatar_size', label: '头像大小 (px)', type: 'number', default: 48, min: 24, max: 96 }, + ], + }, + { + key: 'permissions', + title: '评论权限', + icon: 'fas fa-lock', + fields: [ + { key: 'comment_require_login', label: '评论需要登录', type: 'switch', default: false }, + { key: 'comment_allow_reply', label: '允许回复', type: 'switch', default: true }, + { key: 'comment_close_comments', label: '关闭评论', type: 'switch', default: false }, + { key: 'comment_edit_window', label: '作者自助编辑时限 (分钟)', type: 'number', default: 10, min: 0, max: 1440 }, + ], + }, + { + key: 'toolbar', + title: '评论工具栏', + icon: 'fas fa-toolbox', + fields: [ + { key: 'comment_smilie', label: 'Emoji 表情', type: 'switch', default: true }, + { key: 'comment_code', label: '代码插入', type: 'switch', default: true }, + { key: 'comment_img', label: '图片按钮', type: 'switch', default: true }, + { key: 'comment_upload_img', label: '评论图片上传', type: 'switch', default: false }, + ], + }, + { + key: 'interaction', + title: '互动功能', + icon: 'fas fa-heart', + fields: [ + { key: 'comment_like_s', label: '评论点赞', type: 'switch', default: true }, + { key: 'comment_quick_s', label: '快捷回复', type: 'switch', default: true }, + { key: 'comment_city_s', label: '评论城市', type: 'switch', default: true }, + { key: 'comment_author_tag', label: '作者标签', type: 'switch', default: true }, + { key: 'comment_corderby', label: '评论排序切换', type: 'switch', default: true, help: '前台显示最新/最早/最热切换按钮' }, + ], + }, + { + key: 'text', + title: '文案配置', + icon: 'fas fa-font', + fields: [ + { key: 'comment_empty_text', label: '空评论提示', type: 'input', default: '暂无评论,来做第一个评论的人吧~' }, + { key: 'comment_title', label: '评论区标题', type: 'input', default: '评论' }, + { key: 'comment_submit_text', label: '提交按钮文案', type: 'input', default: '提交评论' }, + { key: 'comment_text', label: '评论框提示', type: 'input', default: '说点什么吧...' }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/post-list/feature.js b/assets/admin/src/schema/sections/post-list/feature.js new file mode 100644 index 00000000..17d8ebe7 --- /dev/null +++ b/assets/admin/src/schema/sections/post-list/feature.js @@ -0,0 +1,25 @@ +export const featureSchema = { + key: 'post_list.feature', + title: '文章功能', + icon: 'fas fa-magic', + description: '配置文章页的互动功能开关。', + defaults: { + feature_pdf_export: false, + feature_print: true, + feature_word_count: true, + feature_reading_time: true, + }, + groups: [ + { + key: 'tools', + title: '工具功能', + icon: 'fas fa-tools', + fields: [ + { key: 'feature_print', label: '打印文章', type: 'switch', default: true }, + { key: 'feature_word_count', label: '字数统计', type: 'switch', default: true }, + { key: 'feature_reading_time', label: '预计阅读时间', type: 'switch', default: true }, + { key: 'feature_pdf_export', label: 'PDF 导出', type: 'switch', default: false, help: '实验性功能' }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/post-list/filter.js b/assets/admin/src/schema/sections/post-list/filter.js new file mode 100644 index 00000000..d4606d30 --- /dev/null +++ b/assets/admin/src/schema/sections/post-list/filter.js @@ -0,0 +1,110 @@ +export const filterSchema = { + key: 'post_list.filter', + title: '高级筛选', + icon: 'fas fa-filter', + description: '为文章列表配置筛选栏、排序方式和分类筛选功能。', + defaults: { + filter_enabled: false, + filter_categories: true, + filter_tags: false, + filter_sort: true, + filter_ajax_enabled: true, + filter_cat_show_cat: true, + filter_cat_show_tag: false, + filter_tag_show_cat: true, + filter_tag_show_tag: false, + filter_topics_show_cat: true, + filter_topics_show_tag: false, + custom_filter_show: ['cat', 'tag'], + custom_filter: [], + cat_custom_filter: [], + }, + groups: [ + { + key: 'basic', + title: '筛选基础', + icon: 'fas fa-filter', + fields: [ + { key: 'filter_enabled', label: '启用筛选栏', type: 'switch', default: false }, + { key: 'custom_filter_show', label: '筛选显示页面', type: 'checkbox', default: ['cat', 'tag'], options: [{ label: '分类页', value: 'cat' }, { label: '标签页', value: 'tag' }, { label: '专题页', value: 'topics' }], help: '选择在哪些页面显示筛选栏', dependsOn: { key: 'filter_enabled', value: true } }, + { key: 'filter_categories', label: '分类筛选', type: 'switch', default: true, dependsOn: { key: 'filter_enabled', value: true } }, + { key: 'filter_tags', label: '标签筛选', type: 'switch', default: false, dependsOn: { key: 'filter_enabled', value: true } }, + { key: 'filter_sort', label: '排序选项', type: 'switch', default: true, dependsOn: { key: 'filter_enabled', value: true } }, + { key: 'filter_ajax_enabled', label: 'AJAX 无刷新筛选', type: 'switch', default: true, help: '启用后分类/标签/排序切换将通过 AJAX 加载,无需刷新页面', dependsOn: { key: 'filter_enabled', value: true } }, + ], + }, + { + key: 'ajax', + title: 'AJAX 筛选维度', + icon: 'fas fa-layer-group', + fields: [ + { key: 'filter_cat_show_cat', label: '分类页:显示分类筛选', type: 'switch', default: true }, + { key: 'filter_cat_show_tag', label: '分类页:显示标签筛选', type: 'switch', default: false }, + { key: 'filter_tag_show_cat', label: '标签页:显示分类筛选', type: 'switch', default: true }, + { key: 'filter_tag_show_tag', label: '标签页:显示标签筛选', type: 'switch', default: false }, + { key: 'filter_topics_show_cat', label: '专题页:显示分类筛选', type: 'switch', default: true }, + { key: 'filter_topics_show_tag', label: '专题页:显示标签筛选', type: 'switch', default: false }, + ], + }, + { + key: 'custom_filter', + title: '自定义筛选维度', + icon: 'fas fa-sliders-h', + fields: [ + { + key: 'custom_filter', + label: '筛选维度定义', + type: 'repeater', + default: [], + addText: '添加筛选维度', + help: '每项定义一个筛选维度(如"类型""地区""年份"),及其可选值列表', + fields: [ + { key: 'name', label: '维度名称', type: 'input', default: '', placeholder: '如:类型' }, + { key: 'key', label: '维度标识', type: 'input', default: '', placeholder: '如:type(英文下划线)' }, + { + key: 'vals', + label: '可选值', + type: 'repeater', + default: [], + addText: '添加可选值', + fields: [ + { key: 'key', label: '标识', type: 'input', default: '', placeholder: '如:action' }, + { key: 'name', label: '显示名', type: 'input', default: '', placeholder: '如:动作' }, + ], + }, + ], + }, + ], + }, + { + key: 'cat_custom_filter', + title: '按分类绑定筛选', + icon: 'fas fa-object-group', + fields: [ + { + key: 'cat_custom_filter', + label: '分类筛选绑定', + type: 'repeater', + default: [], + addText: '添加绑定规则', + help: '为指定分类绑定不同的筛选维度组合,实现分类级别的差异化筛选', + fields: [ + { key: 'cats', label: '绑定分类', type: 'taxonomy_select', taxonomy: 'category', multiple: true, default: [] }, + { key: 'cat_s', label: '显示分类筛选', type: 'switch', default: true }, + { key: 'tag_s', label: '显示标签筛选', type: 'switch', default: false }, + { key: 'topics_s', label: '显示专题筛选', type: 'switch', default: false }, + { key: 'orderby_s', label: '显示排序按钮', type: 'switch', default: true }, + { + key: 'custom_filters', + label: '启用的自定义维度', + type: 'input', + default: '', + placeholder: '逗号分隔维度标识,如:type,region,year', + help: '留空表示使用全部已定义的筛选维度', + }, + ], + }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/post-list/index.js b/assets/admin/src/schema/sections/post-list/index.js new file mode 100644 index 00000000..37d57fc4 --- /dev/null +++ b/assets/admin/src/schema/sections/post-list/index.js @@ -0,0 +1,25 @@ +/** + * Post & List domain schemas. + */ + +import { listSchema } from './list.js' +import { thumbSchema } from './thumb.js' +import { pageSchema } from './page.js' +import { filterSchema } from './filter.js' +import { featureSchema } from './feature.js' +import { paySchema } from './pay.js' +import { shareSchema } from './share.js' +import { commentSchema } from './comment.js' +import { submitSchema } from './submit.js' + +export const postListSchemas = { + list: listSchema, + thumb: thumbSchema, + page: pageSchema, + filter: filterSchema, + feature: featureSchema, + pay: paySchema, + share: shareSchema, + comment: commentSchema, + submit: submitSchema, +} diff --git a/assets/admin/src/schema/sections/post-list/list.js b/assets/admin/src/schema/sections/post-list/list.js new file mode 100644 index 00000000..ad7f8f2c --- /dev/null +++ b/assets/admin/src/schema/sections/post-list/list.js @@ -0,0 +1,158 @@ +export const listSchema = { + key: 'post_list.list', + title: '文章列表', + icon: 'fas fa-list', + description: '配置文章列表的卡片样式、元信息显示、分页方式和排序规则。', + defaults: { + post_card_style: 'standard', + post_show_author: true, + post_show_date: true, + post_show_views: true, + post_show_category: true, + post_excerpt_length: 120, + post_pagination_mode: 'numeric', + ias_max: 3, + ajax_trigger_text: '', + ajax_nomore_text: '', + sticky_s: ['home', 'cat'], + list_badge_show: ['pay', 'tag', 'topics', 'cat'], + post_target_blank: false, + item_heading_bold: false, + list_orderby: 'modified', + list_meta_show: 'like', + list_show_type: 'separate', + thumb_postfirstimg_size: 'medium', + post_t_placeholder: '', + post_c_placeholder: '', + list_list_style: '', + list_list_img_position: 'left', + list_list_scale: 70, + list_card_style: '', + list_card_scale: 70, + list_card_home: false, + list_card_cat: [], + list_card_tag: false, + list_card_topics: false, + list_card_author: false, + }, + groups: [ + { + key: 'card', + title: '卡片样式', + icon: 'fas fa-th-large', + fields: [ + { key: 'post_card_style', label: '卡片样式', type: 'select', default: 'standard', options: [ + { label: '标准卡', value: 'standard' }, + { label: '紧凑卡', value: 'compact' }, + { label: '大图卡', value: 'large_thumb' }, + { label: '多图卡', value: 'multi_thumb' }, + { label: '纯文字', value: 'text' }, + { label: '有图则图文', value: 'thumb_if_has' }, + ]}, + { key: 'post_excerpt_length', label: '摘要长度 (字)', type: 'number', default: 120, min: 20, max: 500 }, + ], + }, + { + key: 'meta', + title: '元信息显示', + icon: 'fas fa-info-circle', + fields: [ + { key: 'post_show_author', label: '显示作者', type: 'switch', default: true }, + { key: 'post_show_date', label: '显示日期', type: 'switch', default: true }, + { key: 'post_show_views', label: '显示浏览数', type: 'switch', default: true }, + { key: 'post_show_category', label: '显示分类', type: 'switch', default: true }, + { key: 'list_badge_show', label: '显示徽章类型', type: 'checkbox', default: ['pay', 'tag', 'topics', 'cat'], options: [ + { label: '付费', value: 'pay' }, + { label: '分类', value: 'cat' }, + { label: '专题', value: 'topics' }, + { label: '标签', value: 'tag' }, + ], help: '选择列表中显示哪些徽章类型' }, + { key: 'list_meta_show', label: '移动端优先部件', type: 'radio', default: 'like', options: [ + { label: '浏览数', value: 'view' }, + { label: '点赞数', value: 'like' }, + { label: '评论数', value: 'comm' }, + ], help: '移动端空间不足时保留显示的单一部件' }, + ], + }, + { + key: 'pagination', + title: '分页方式', + icon: 'fas fa-ellipsis-h', + fields: [ + { key: 'post_pagination_mode', label: '分页模式', type: 'radio', default: 'numeric', options: [ + { label: '数字分页', value: 'numeric' }, + { label: '加载更多', value: 'load_more' }, + { label: '无限滚动', value: 'infinite' }, + ]}, + { key: 'ias_max', label: '最大加载页数', type: 'number', default: 3, min: 0, max: 10, help: '0 = 不限' }, + { key: 'ajax_trigger_text', label: '加载更多文案', type: 'input', default: '', help: '留空使用默认"加载更多"' }, + { key: 'ajax_nomore_text', label: '没有更多文案', type: 'input', default: '', help: '留空使用默认"没有更多了"' }, + ], + }, + { + key: 'layout', + title: '布局设置', + icon: 'fas fa-columns', + fields: [ + { key: 'sticky_s', label: '置顶范围', type: 'checkbox', default: ['home', 'cat'], options: [ + { label: '首页', value: 'home' }, + { label: '分类页', value: 'cat' }, + { label: '标签页', value: 'tag' }, + { label: '专题页', value: 'topics' }, + ], help: '选择哪些页面类型显示置顶文章' }, + { key: 'post_target_blank', label: '新窗口打开', type: 'switch', default: false, help: '文章链接在新窗口打开' }, + { key: 'item_heading_bold', label: '标题加粗', type: 'switch', default: false, help: '列表文章标题使用粗体' }, + { key: 'list_show_type', label: '列表样式', type: 'radio', default: 'separate', options: [ + { label: '分隔卡片', value: 'separate' }, + { label: '无间距', value: 'no_margin' }, + ], help: '列表模式时的卡片间距样式' }, + { key: 'list_card_home', label: '首页卡片模式', type: 'switch', default: false, help: '首页列表切换为卡片模式' }, + { key: 'list_card_cat', label: '分类卡片模式', type: 'taxonomy_select', taxonomy: 'category', multiple: true, default: [], help: '选择分类,访问这些分类时使用卡片模式显示(为空则不使用)' }, + { key: 'list_card_tag', label: '标签页卡片模式', type: 'switch', default: false, help: '标签页列表切换为卡片模式' }, + { key: 'list_card_topics', label: '专题页卡片模式', type: 'switch', default: false, help: '专题页列表切换为卡片模式' }, + { key: 'list_card_author', label: '用户页卡片模式', type: 'switch', default: false, help: '用户页列表切换为卡片模式' }, + ], + }, + { + key: 'sort', + title: '默认排序', + icon: 'fas fa-sort', + fields: [ + { key: 'list_orderby', label: '列表默认排序', type: 'radio', default: 'modified', options: [ + { label: '更新时间', value: 'modified' }, + { label: '发布时间', value: 'date' }, + { label: '评论数', value: 'comments' }, + { label: 'WordPress 默认', value: 'default' }, + ], help: '分类/标签/归档页面的默认排序方式' }, + ], + }, + { + key: 'list_option', + title: '列表模式配置', + icon: 'fas fa-list-ul', + fields: [ + { key: 'list_list_style', label: '列表样式变体', type: 'radio', default: '', options: [ + { label: '默认', value: '' }, + { label: '样式2', value: 'style2' }, + ], help: '列表模式的样式变体' }, + { key: 'list_list_img_position', label: '缩略图位置', type: 'radio', default: 'left', options: [ + { label: '左侧', value: 'left' }, + { label: '右侧', value: 'right' }, + ], help: '列表模式缩略图靠左或靠右' }, + { key: 'list_list_scale', label: '缩略图比例 (%)', type: 'number', default: 70, min: 50, max: 200, help: '列表模式缩略图宽度百分比' }, + ], + }, + { + key: 'card_option', + title: '卡片模式配置', + icon: 'fas fa-th', + fields: [ + { key: 'list_card_style', label: '卡片样式变体', type: 'radio', default: '', options: [ + { label: '默认', value: '' }, + { label: '样式3', value: 'style3' }, + ], help: '卡片模式的样式变体' }, + { key: 'list_card_scale', label: '缩略图比例 (%)', type: 'number', default: 70, min: 20, max: 200, help: '卡片模式缩略图高度百分比' }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/post-list/page.js b/assets/admin/src/schema/sections/post-list/page.js new file mode 100644 index 00000000..7ab6a0eb --- /dev/null +++ b/assets/admin/src/schema/sections/post-list/page.js @@ -0,0 +1,155 @@ +export const pageSchema = { + key: 'post_list.page', + title: '文章页面', + icon: 'fas fa-file-alt', + description: '配置单篇文章页面的布局、目录树、图片灯箱、版权区和互动功能。', + defaults: { + post_toc_enabled: true, + post_toc_depth: 3, + article_nav_mobile_nav_s: true, + post_reading_progress: true, + breadcrumbs_single_s: true, + breadcrumbs_home_text: '首页', + post_prevnext_s: true, + post_authordesc_s: true, + post_single_hide_time_s: false, + post_lightbox: true, + post_lightbox_thumbs: true, + post_lightbox_zoom: true, + post_lightbox_full: true, + post_lightbox_down: false, + post_lightbox_play: false, + post_related_enabled: true, + post_related_count: 6, + post_related_type: 'category', + post_related_orderby: 'rand', + post_related_title: '相关推荐', + post_copyright_enabled: true, + post_copyright_text: '', + share_s: true, + post_like_enabled: true, + post_favorite_enabled: true, + post_front_content: '', + post_after_content: '', + post_yiyan_header: false, + post_yiyan_footer: false, + post_yiyan_box: false, + post_default_views_min: 20, + post_default_views_max: 50, + post_default_likes_min: 5, + post_default_likes_max: 15, + post_article_image_cover: true, + post_article_image_cover_scale: 35, + post_article_slide_cover: true, + post_article_video_cover: true, + post_article_video_cover_scale: 56, + item_heading_bold: false, + post_button_toptext: '喜欢就支持一下吧', + }, + groups: [ + { + key: 'navigation', + title: '文章导航', + icon: 'fas fa-compass', + fields: [ + { key: 'post_toc_enabled', label: '目录树导航', type: 'switch', default: true, help: '自动生成文章目录树' }, + { key: 'post_toc_depth', label: '目录深度', type: 'number', default: 3, min: 1, max: 6, dependsOn: { key: 'post_toc_enabled', value: true } }, + { key: 'article_nav_mobile_nav_s', label: '移动端弹出菜单', type: 'switch', default: true, dependsOn: { key: 'post_toc_enabled', value: true }, help: '在移动端弹出菜单中显示目录导航' }, + { key: 'post_reading_progress', label: '阅读进度条', type: 'switch', default: true }, + { key: 'breadcrumbs_single_s', label: '面包屑导航', type: 'switch', default: true }, + { key: 'breadcrumbs_home_text', label: '面包屑首页文字', type: 'input', default: '首页', dependsOn: { key: 'breadcrumbs_single_s', value: true } }, + { key: 'post_prevnext_s', label: '上一篇/下一篇', type: 'switch', default: true }, + { key: 'post_authordesc_s', label: '作者简介', type: 'switch', default: true }, + { key: 'post_single_hide_time_s', label: '隐藏文章时间', type: 'switch', default: false }, + ], + }, + { + key: 'cover', + title: '封面展示', + icon: 'fas fa-image', + fields: [ + { key: 'post_article_image_cover', label: '图片封面', type: 'switch', default: true, help: '文章有特色图时自动展示封面' }, + { key: 'post_article_image_cover_scale', label: '封面高度比例(%)', type: 'number', default: 35, min: 10, max: 100, dependsOn: { key: 'post_article_image_cover', value: true } }, + { key: 'post_article_slide_cover', label: '幻灯片封面', type: 'switch', default: true, help: '文章有图集时以轮播形式展示封面' }, + { key: 'post_article_video_cover', label: '视频封面', type: 'switch', default: true, help: '文章有视频时以视频播放器作为封面' }, + { key: 'post_article_video_cover_scale', label: '视频封面高度比例(%)', type: 'number', default: 56, min: 20, max: 100, dependsOn: { key: 'post_article_video_cover', value: true } }, + ], + }, + { + key: 'media', + title: '媒体功能', + icon: 'fas fa-photo-video', + fields: [ + { key: 'post_lightbox', label: '图片灯箱', type: 'switch', default: true, help: '点击图片放大查看' }, + { key: 'post_lightbox_thumbs', label: '灯箱缩略图', type: 'switch', default: true, dependsOn: { key: 'post_lightbox', value: true }, help: '灯箱底部显示缩略图导航' }, + { key: 'post_lightbox_zoom', label: '灯箱缩放', type: 'switch', default: true, dependsOn: { key: 'post_lightbox', value: true }, help: '灯箱显示缩放按钮' }, + { key: 'post_lightbox_full', label: '灯箱全屏', type: 'switch', default: true, dependsOn: { key: 'post_lightbox', value: true }, help: '灯箱显示全屏按钮' }, + { key: 'post_lightbox_down', label: '灯箱下载', type: 'switch', default: false, dependsOn: { key: 'post_lightbox', value: true }, help: '灯箱显示下载按钮' }, + { key: 'post_lightbox_play', label: '灯箱播放', type: 'switch', default: false, dependsOn: { key: 'post_lightbox', value: true }, help: '灯箱显示播放按钮(自动轮播)' }, + ], + }, + { + key: 'related', + title: '相关推荐', + icon: 'fas fa-random', + fields: [ + { key: 'post_related_enabled', label: '显示相关推荐', type: 'switch', default: true }, + { key: 'post_related_count', label: '推荐数量', type: 'number', default: 6, min: 1, max: 20, dependsOn: { key: 'post_related_enabled', value: true } }, + { key: 'post_related_type', label: '推荐方式', type: 'radio', default: 'category', options: [ + { label: '同分类', value: 'category' }, + { label: '同标签', value: 'tag' }, + { label: '随机', value: 'random' }, + ], dependsOn: { key: 'post_related_enabled', value: true } }, + { key: 'post_related_orderby', label: '推荐排序', type: 'select', default: 'rand', options: [ + { label: '随机', value: 'rand' }, + { label: '相关度', value: 'relevance' }, + { label: '时间', value: 'date' }, + ], dependsOn: { key: 'post_related_enabled', value: true } }, + { key: 'post_related_title', label: '推荐标题', type: 'input', default: '相关推荐', dependsOn: { key: 'post_related_enabled', value: true } }, + ], + }, + { + key: 'interactions', + title: '互动功能', + icon: 'fas fa-heart', + fields: [ + { key: 'post_like_enabled', label: '点赞', type: 'switch', default: true }, + { key: 'post_favorite_enabled', label: '收藏', type: 'switch', default: true }, + { key: 'share_s', label: '分享按钮', type: 'switch', default: true }, + { key: 'post_copyright_enabled', label: '版权信息', type: 'switch', default: true }, + { key: 'post_copyright_text', label: '版权默认文案', type: 'textarea', default: '', help: '留空则自动生成"本文由 XX 原创发布"' }, + { key: 'post_button_toptext', label: '按钮上方文案', type: 'input', default: '喜欢就支持一下吧', help: '显示在点赞/打赏/分享按钮上方' }, + ], + }, + { + key: 'content', + title: '内容插入', + icon: 'fas fa-pen-square', + fields: [ + { key: 'post_front_content', label: '文章头部内容', type: 'textarea', default: '', help: '显示在所有文章正文前方(支持 HTML)' }, + { key: 'post_after_content', label: '文章底部内容', type: 'textarea', default: '', help: '显示在所有文章正文后方(支持 HTML)' }, + ], + }, + { + key: 'yiyan', + title: '一言', + icon: 'fas fa-quote-left', + fields: [ + { key: 'post_yiyan_header', label: '文章头部一言', type: 'switch', default: false, help: '在文章标题下方显示一言' }, + { key: 'post_yiyan_footer', label: '文章底部一言', type: 'switch', default: false, help: '在文章内容底部显示一言' }, + { key: 'post_yiyan_box', label: '一言盒子', type: 'switch', default: false, help: '在相关文章前显示一言盒子' }, + ], + }, + { + key: 'meta', + title: '默认数值', + icon: 'fas fa-chart-bar', + fields: [ + { key: 'post_default_views_min', label: '默认浏览最小值', type: 'number', default: 20, min: 0, max: 9999 }, + { key: 'post_default_views_max', label: '默认浏览最大值', type: 'number', default: 50, min: 1, max: 9999 }, + { key: 'post_default_likes_min', label: '默认点赞最小值', type: 'number', default: 5, min: 0, max: 9999 }, + { key: 'post_default_likes_max', label: '默认点赞最大值', type: 'number', default: 15, min: 1, max: 9999 }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/post-list/pay.js b/assets/admin/src/schema/sections/post-list/pay.js new file mode 100644 index 00000000..e7724c59 --- /dev/null +++ b/assets/admin/src/schema/sections/post-list/pay.js @@ -0,0 +1,46 @@ +export const paySchema = { + key: 'post_list.pay', + title: '付费内容', + icon: 'fas fa-yen-sign', + description: '配置文章付费阅读、下载功能和购买用户显示。', + defaults: { + post_pay_enable: false, + post_pay_price_default: 0, + post_pay_free_logged_show: false, + post_pay_box_position: 'content', + post_pay_mark: '付费', + post_pay_show_user: false, + post_download_enable: false, + post_download_remote_s: false, + post_download_verify_nonce: true, + post_download_user_log: true, + post_download_post_log: true, + }, + groups: [ + { + key: 'basic', + title: '付费设置', + icon: 'fas fa-coins', + fields: [ + { key: 'post_pay_enable', label: '启用付费内容', type: 'switch', default: false }, + { key: 'post_pay_price_default', label: '默认价格', type: 'number', default: 0, min: 0, max: 9999, dependsOn: { key: 'post_pay_enable', value: true } }, + { key: 'post_pay_free_logged_show', label: '登录用户显示免费', type: 'switch', default: false, dependsOn: { key: 'post_pay_enable', value: true } }, + { key: 'post_pay_box_position', label: '付费框位置', type: 'radio', default: 'content', options: [{ label: '内容前', value: 'content' }, { label: '全文遮罩', value: 'mask' }], dependsOn: { key: 'post_pay_enable', value: true } }, + { key: 'post_pay_mark', label: '付费标记', type: 'input', default: '付费', dependsOn: { key: 'post_pay_enable', value: true } }, + { key: 'post_pay_show_user', label: '显示购买用户', type: 'switch', default: false, dependsOn: { key: 'post_pay_enable', value: true } }, + ], + }, + { + key: 'download', + title: '下载设置', + icon: 'fas fa-download', + fields: [ + { key: 'post_download_enable', label: '启用下载功能', type: 'switch', default: false }, + { key: 'post_download_remote_s', label: '远程文件下载', type: 'switch', default: false, dependsOn: { key: 'post_download_enable', value: true } }, + { key: 'post_download_verify_nonce', label: '下载鉴权', type: 'switch', default: true, dependsOn: { key: 'post_download_enable', value: true } }, + { key: 'post_download_user_log', label: '用户下载日志', type: 'switch', default: true, dependsOn: { key: 'post_download_enable', value: true } }, + { key: 'post_download_post_log', label: '文章下载日志', type: 'switch', default: true, dependsOn: { key: 'post_download_enable', value: true } }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/post-list/share.js b/assets/admin/src/schema/sections/post-list/share.js new file mode 100644 index 00000000..b65d0fc4 --- /dev/null +++ b/assets/admin/src/schema/sections/post-list/share.js @@ -0,0 +1,42 @@ +export const shareSchema = { + key: 'post_list.share', + title: '分享设置', + icon: 'fas fa-share-alt', + description: '配置微信分享凭据、海报分享和社交分享选项。', + defaults: { + share_wechat_s: false, + share_wechat_appid: '', + share_wechat_app_secret: '', + share_only_logo: false, + share_img_compatible_s: false, + share_img_byimg: '', + share_logo: '', + share_img: '', + share_desc: '扫一扫 立即查看', + }, + groups: [ + { + key: 'wechat', + title: '微信分享', + icon: 'fab fa-weixin', + fields: [ + { key: 'share_wechat_s', label: '启用微信分享', type: 'switch', default: false, help: '启用后文章页将支持微信 JSAPI 分享' }, + { key: 'share_wechat_appid', label: 'AppID', type: 'input', default: '', dependsOn: { key: 'share_wechat_s', value: true }, help: '微信公众号 AppID' }, + { key: 'share_wechat_app_secret', label: 'AppSecret', type: 'input', default: '', dependsOn: { key: 'share_wechat_s', value: true }, help: '微信公众号 AppSecret(请确保 JS 安全域名已配置)' }, + { key: 'share_only_logo', label: '仅显示 Logo', type: 'switch', default: false, help: '分享时仅显示站点 Logo' }, + ], + }, + { + key: 'poster', + title: '海报分享', + icon: 'fas fa-image', + fields: [ + { key: 'share_img_compatible_s', label: '兼容模式', type: 'switch', default: false, help: '启用后使用兼容模式生成分享海报' }, + { key: 'share_img_byimg', label: '海报默认图', type: 'media', default: '', help: '海报分享使用的默认背景图' }, + { key: 'share_logo', label: '海报 LOGO', type: 'media', default: '', help: '海报上叠加的站点 LOGO' }, + { key: 'share_img', label: '分享图片', type: 'media', default: '', help: '社交平台分享预览图' }, + { key: 'share_desc', label: '分享描述', type: 'textarea', default: '扫一扫 立即查看', help: '海报或分享卡片的描述文案' }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/post-list/submit.js b/assets/admin/src/schema/sections/post-list/submit.js new file mode 100644 index 00000000..940df576 --- /dev/null +++ b/assets/admin/src/schema/sections/post-list/submit.js @@ -0,0 +1,125 @@ +export const submitSchema = { + key: 'post_list.submit', + title: '前台投稿', + icon: 'fas fa-paper-plane', + description: '配置前台投稿表单字段、审核流程、附件权限和封面策略。', + defaults: { + front_submit_enabled: true, + front_submit_roles: 'author,editor,administrator', + front_submit_title: true, + front_submit_content: true, + front_submit_excerpt: true, + front_submit_cover: true, + front_submit_category: true, + post_article_cat: [], + front_submit_tags: true, + post_article_topics_s: false, + post_article_custom_filter_s: true, + front_submit_custom_fields: '', + front_submit_review_mode: 'pending', + front_submit_cover_required: false, + front_submit_max_images: 5, + front_submit_editor_mode: 'rich', + front_submit_draft_save: true, + front_submit_leave_warning: true, + post_article_btn_txte: '发布文章', + post_article_title_strlen_limit_min: 5, + post_article_title_strlen_limit_max: 30, + post_article_user: 0, + post_article_pay_free_s: true, + post_article_pay_price_min: 0, + post_article_pay_price_max: 100, + post_article_pay_vip_price_s: true, + post_article_pay_vip_1_discount: 80, + post_article_pay_vip_2_discount: 50, + }, + groups: [ + { + key: 'basic', + title: '基础配置', + icon: 'fas fa-cog', + fields: [ + { key: 'front_submit_enabled', label: '开启前台投稿', type: 'switch', default: true, help: '允许用户在前台提交文章' }, + { key: 'front_submit_roles', label: '允许投稿的角色', type: 'input', default: 'author,editor,administrator', dependsOn: { key: 'front_submit_enabled', value: true }, help: '逗号分隔角色标识' }, + { key: 'post_article_btn_txte', label: '投稿按钮文案', type: 'input', default: '发布文章', dependsOn: { key: 'front_submit_enabled', value: true }, help: '前台投稿按钮显示的文字' }, + { key: 'post_article_title_strlen_limit_min', label: '标题最少字数', type: 'number', default: 5, min: 1, max: 100, dependsOn: { key: 'front_submit_enabled', value: true } }, + { key: 'post_article_title_strlen_limit_max', label: '标题最多字数', type: 'number', default: 30, min: 5, max: 200, dependsOn: { key: 'front_submit_enabled', value: true } }, + { key: 'post_t_placeholder', label: '标题占位符', type: 'input', default: '', placeholder: '请输入标题', dependsOn: { key: 'front_submit_enabled', value: true } }, + { key: 'post_c_placeholder', label: '内容占位符', type: 'textarea', default: '', placeholder: '请输入内容', dependsOn: { key: 'front_submit_enabled', value: true } }, + ], + }, + { + key: 'fields', + title: '表单字段', + icon: 'fas fa-list-alt', + fields: [ + { key: 'front_submit_title', label: '标题', type: 'switch', default: true, dependsOn: { key: 'front_submit_enabled', value: true } }, + { key: 'front_submit_content', label: '正文', type: 'switch', default: true, dependsOn: { key: 'front_submit_enabled', value: true } }, + { key: 'front_submit_excerpt', label: '摘要', type: 'switch', default: true, dependsOn: { key: 'front_submit_enabled', value: true } }, + { key: 'front_submit_cover', label: '封面图', type: 'switch', default: true, dependsOn: { key: 'front_submit_enabled', value: true } }, + { key: 'front_submit_category', label: '分类选择', type: 'switch', default: true, dependsOn: { key: 'front_submit_enabled', value: true } }, + { key: 'post_article_cat', label: '允许投稿分类', type: 'taxonomy_select', taxonomy: 'category', multiple: true, default: [], help: '为空则允许全部分类', dependsOn: { key: 'front_submit_enabled', value: true } }, + { key: 'post_article_user', label: '免登录归属用户', type: 'user_select', default: 0, help: '免登录投稿时文章归属的用户,搜索选择', dependsOn: { key: 'front_submit_enabled', value: true } }, + { key: 'front_submit_tags', label: '标签输入', type: 'switch', default: true, dependsOn: { key: 'front_submit_enabled', value: true } }, + { key: 'post_article_topics_s', label: '专题选择', type: 'switch', default: false, help: '投稿时可选择专题分类', dependsOn: { key: 'front_submit_enabled', value: true } }, + { key: 'post_article_custom_filter_s', label: '高级筛选选择', type: 'switch', default: true, help: '投稿时可选择自定义筛选维度值', dependsOn: { key: 'front_submit_enabled', value: true } }, + { key: 'front_submit_custom_fields', label: '自定义字段', type: 'textarea', default: '', placeholder: '每行一个:field_key|字段名称|类型(input/textarea/select)', dependsOn: { key: 'front_submit_enabled', value: true } }, + ], + }, + { + key: 'review', + title: '审核策略', + icon: 'fas fa-tasks', + fields: [ + { + key: 'front_submit_review_mode', + label: '审核模式', + type: 'radio', + default: 'pending', + options: [ + { label: '待审核', value: 'pending' }, + { label: '直接发布', value: 'publish' }, + { label: '自动草稿', value: 'draft' }, + ], + dependsOn: { key: 'front_submit_enabled', value: true }, + }, + { key: 'front_submit_cover_required', label: '封面图必填', type: 'switch', default: false, dependsOn: { key: 'front_submit_cover', value: true } }, + { key: 'front_submit_max_images', label: '正文最大图片数', type: 'number', default: 5, min: 0, max: 50, dependsOn: { key: 'front_submit_enabled', value: true } }, + ], + }, + { + key: 'editor', + title: '编辑器设置', + icon: 'fas fa-edit', + fields: [ + { + key: 'front_submit_editor_mode', + label: '编辑器模式', + type: 'radio', + default: 'rich', + options: [ + { label: '富文本', value: 'rich' }, + { label: 'Markdown', value: 'markdown' }, + { label: '纯文本', value: 'plain' }, + ], + dependsOn: { key: 'front_submit_content', value: true }, + }, + { key: 'front_submit_draft_save', label: '自动保存草稿', type: 'switch', default: true, dependsOn: { key: 'front_submit_enabled', value: true } }, + { key: 'front_submit_leave_warning', label: '离开页面提醒', type: 'switch', default: true, dependsOn: { key: 'front_submit_enabled', value: true } }, + ], + }, + { + key: 'paid', + title: '付费投稿权限', + icon: 'fas fa-coins', + fields: [ + { key: 'post_article_pay_free_s', label: '允许设置免费', type: 'switch', default: true, dependsOn: { key: 'front_submit_enabled', value: true } }, + { key: 'post_article_pay_price_min', label: '最低价格', type: 'number', default: 0, min: 0, max: 9999, help: '投稿可设置的最低付费金额', dependsOn: { key: 'front_submit_enabled', value: true } }, + { key: 'post_article_pay_price_max', label: '最高价格', type: 'number', default: 100, min: 0, max: 9999, help: '投稿可设置的最高付费金额', dependsOn: { key: 'front_submit_enabled', value: true } }, + { key: 'post_article_pay_vip_price_s', label: '允许设置会员价', type: 'switch', default: true, dependsOn: { key: 'front_submit_enabled', value: true } }, + { key: 'post_article_pay_vip_1_discount', label: 'VIP1 折扣(%)', type: 'number', default: 80, min: 0, max: 100, help: 'VIP1 会员折扣百分比,80 即 8 折', dependsOn: { key: 'front_submit_enabled', value: true } }, + { key: 'post_article_pay_vip_2_discount', label: 'VIP2 折扣(%)', type: 'number', default: 50, min: 0, max: 100, help: 'VIP2 会员折扣百分比,50 即 5 折', dependsOn: { key: 'front_submit_enabled', value: true } }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/post-list/thumb.js b/assets/admin/src/schema/sections/post-list/thumb.js new file mode 100644 index 00000000..3345c629 --- /dev/null +++ b/assets/admin/src/schema/sections/post-list/thumb.js @@ -0,0 +1,70 @@ +export const thumbSchema = { + key: 'post_list.thumb', + title: '列表缩略图', + icon: 'fas fa-images', + description: '配置缩略图来源优先级、尺寸、占位图和多图模式。', + defaults: { + thumb_priority: 'featured,attachment,resource,custom', + thumb_fallback: 'placeholder', + thumb_spare_images: [], + thumb_lazy: true, + thumb_aspect_ratio: '16:9', + list_thumb_fit: 'cover', + thumb_multi_enabled: false, + thumb_multi_count: 3, + list_thumb_slides_s: true, + list_thumb_video_s: true, + list_thumb_video_mute_s: true, + thumb_postfirstimg_s: true, + thumb_catimg_s: true, + mult_thumb_cat: [], + }, + groups: [ + { + key: 'source', + title: '图片来源', + icon: 'fas fa-image', + fields: [ + { key: 'thumb_priority', label: '图片优先级', type: 'input', default: 'featured,attachment,resource,custom', help: '逗号分隔: featured(特色图), attachment(附件), resource(资源封面), custom(自定义), post_first_img(文章首图), category_cover(分类封面)' }, + { key: 'thumb_fallback', label: '无图时处理', type: 'radio', default: 'placeholder', options: [ + { label: '显示占位图', value: 'placeholder' }, + { label: '隐藏缩略图', value: 'hide' }, + { label: '随机封面', value: 'random' }, + ]}, + { key: 'thumb_spare_images', label: '备用缩略图', type: 'gallery', default: [], help: '当无图处理选择"随机封面"时从此图库随机选取', dependsOn: { key: 'thumb_fallback', value: 'random' } }, + { key: 'thumb_aspect_ratio', label: '图片比例', type: 'select', default: '16:9', options: [ + { label: '16:9 宽屏', value: '16:9' }, + { label: '4:3 标准', value: '4:3' }, + { label: '1:1 正方形', value: '1:1' }, + { label: '自适应', value: 'auto' }, + ]}, + { key: 'list_thumb_fit', label: '缩略图填充方式', type: 'radio', default: 'cover', options: [ + { label: '填充裁剪', value: 'cover' }, + { label: '完整显示', value: 'contain' }, + ]}, + { key: 'list_thumb_slides_s', label: '幻灯片缩略图', type: 'switch', default: true, help: '允许幻灯片作为列表缩略图' }, + { key: 'list_thumb_video_s', label: '视频缩略图', type: 'switch', default: true, help: '鼠标悬停时自动播放视频缩略图' }, + { key: 'list_thumb_video_mute_s', label: '视频缩略图静音', type: 'switch', default: true, dependsOn: { key: 'list_thumb_video_s', value: true }, help: '视频缩略图播放时静音' }, + { key: 'thumb_postfirstimg_s', label: '自动获取文章首图', type: 'switch', default: true, help: '自动从文章内容中提取第一张图片作为缩略图' }, + { key: 'thumb_postfirstimg_size', label: '首图尺寸', type: 'select', default: 'medium', options: [ + { label: '缩略图', value: 'thumbnail' }, + { label: '中等', value: 'medium' }, + { label: '大', value: 'large' }, + { label: '原始', value: 'full' }, + ], dependsOn: { key: 'thumb_postfirstimg_s', value: true }, help: '自动获取文章首图时使用的 WP 图片尺寸' }, + { key: 'thumb_catimg_s', label: '自动获取分类封面', type: 'switch', default: true, help: '无特色图时使用分类封面图作为缩略图' }, + ], + }, + { + key: 'multi', + title: '多图模式', + icon: 'fas fa-images', + fields: [ + { key: 'thumb_multi_enabled', label: '多图卡片', type: 'switch', default: false, help: '当文章有多张图片时,在列表中展示多图预览' }, + { key: 'thumb_multi_count', label: '最多显示几张', type: 'number', default: 3, min: 2, max: 6, dependsOn: { key: 'thumb_multi_enabled', value: true } }, + { key: 'mult_thumb_cat', label: '按分类多图显示', type: 'taxonomy_select', taxonomy: 'category', multiple: true, default: [], help: '选择的分类下所有文章自动显示多张缩略图' }, + { key: 'thumb_lazy', label: '懒加载', type: 'switch', default: true }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/shop/basic.js b/assets/admin/src/schema/sections/shop/basic.js new file mode 100644 index 00000000..7a004c0e --- /dev/null +++ b/assets/admin/src/schema/sections/shop/basic.js @@ -0,0 +1,49 @@ +export const basicSchema = { + key: 'shop.basic', + title: '商城基础', + icon: 'fas fa-store', + description: '商城总开关、首页链接、面包屑和 URL 别名设置。', + defaults: { + shop_s: false, + shop_home_url: '', + shop_breadcrumbs_s: true, + shop_breadcrumbs_home: true, + shop_breadcrumbs_shop_home: true, + shop_breadcrumbs_shop_home_name: '商城首页', + shop_product_rewrite_slug: 'product', + shop_cart_rewrite_slug: 'cart', + shop_rewrite_suffix_html_s: false, + }, + groups: [ + { + key: 'general', + title: '商城开关', + icon: 'fas fa-power-off', + fields: [ + { key: 'shop_s', label: '启用商城', type: 'switch', default: false }, + { key: 'shop_home_url', label: '商城首页链接', type: 'input', default: '', help: '留空使用默认商城页面', dependsOn: { key: 'shop_s', value: true } }, + ], + }, + { + key: 'breadcrumbs', + title: '面包屑', + icon: 'fas fa-angle-double-right', + fields: [ + { key: 'shop_breadcrumbs_s', label: '显示面包屑', type: 'switch', default: true, dependsOn: { key: 'shop_s', value: true } }, + { key: 'shop_breadcrumbs_home', label: '显示首页链接', type: 'switch', default: true, dependsOn: { key: 'shop_breadcrumbs_s', value: true } }, + { key: 'shop_breadcrumbs_shop_home', label: '显示商城首页', type: 'switch', default: true, dependsOn: { key: 'shop_breadcrumbs_s', value: true } }, + { key: 'shop_breadcrumbs_shop_home_name', label: '商城首页名称', type: 'input', default: '商城首页', dependsOn: { key: 'shop_breadcrumbs_s', value: true } }, + ], + }, + { + key: 'rewrite', + title: 'URL 别名', + icon: 'fas fa-link', + fields: [ + { key: 'shop_product_rewrite_slug', label: '商品 URL 别名', type: 'input', default: 'product', dependsOn: { key: 'shop_s', value: true } }, + { key: 'shop_cart_rewrite_slug', label: '购物车 URL 别名', type: 'input', default: 'cart', dependsOn: { key: 'shop_s', value: true } }, + { key: 'shop_rewrite_suffix_html_s', label: '.html 伪静态后缀', type: 'switch', default: false, dependsOn: { key: 'shop_s', value: true } }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/shop/contact.js b/assets/admin/src/schema/sections/shop/contact.js new file mode 100644 index 00000000..6a1fc171 --- /dev/null +++ b/assets/admin/src/schema/sections/shop/contact.js @@ -0,0 +1,32 @@ +export const contactSchema = { + key: 'shop.contact', + title: '客服配置', + icon: 'fas fa-headset', + description: '商品详情页客服按钮和联系方式配置。', + defaults: { + shop_contact_msg_s: true, + shop_contact_name: '联系客服', + shop_contact_note: '', + shop_contact_more: '', + }, + groups: [ + { + key: 'button', + title: '客服按钮', + icon: 'fas fa-comment-dots', + fields: [ + { key: 'shop_contact_msg_s', label: '显示客服按钮', type: 'switch', default: true }, + { key: 'shop_contact_name', label: '按钮文案', type: 'input', default: '联系客服', dependsOn: { key: 'shop_contact_msg_s', value: true } }, + { key: 'shop_contact_note', label: '客服备注', type: 'textarea', default: '', dependsOn: { key: 'shop_contact_msg_s', value: true } }, + ], + }, + { + key: 'channels', + title: '联系方式', + icon: 'fas fa-address-book', + fields: [ + { key: 'shop_contact_more', label: '多客服联系方式', type: 'textarea', default: '', help: '每行一个:名称|图标|链接|二维码图片 URL' }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/shop/index.js b/assets/admin/src/schema/sections/shop/index.js new file mode 100644 index 00000000..eb8f945e --- /dev/null +++ b/assets/admin/src/schema/sections/shop/index.js @@ -0,0 +1,21 @@ +/** + * Shop & Product domain schemas. + */ + +import { basicSchema } from './basic.js' +import { listSchema } from './list.js' +import { contactSchema } from './contact.js' +import { singleSchema } from './single.js' +import { productDefaultsSchema } from './product-defaults.js' +import { shippingConfigSchema } from './shipping-config.js' +import { timingSchema } from './timing.js' + +export const shopSchemas = { + basic: basicSchema, + list: listSchema, + contact: contactSchema, + single: singleSchema, + productDefaults: productDefaultsSchema, + shippingConfig: shippingConfigSchema, + timing: timingSchema, +} diff --git a/assets/admin/src/schema/sections/shop/list.js b/assets/admin/src/schema/sections/shop/list.js new file mode 100644 index 00000000..6074ddd7 --- /dev/null +++ b/assets/admin/src/schema/sections/shop/list.js @@ -0,0 +1,40 @@ +export const listSchema = { + key: 'shop.list', + title: '商品列表', + icon: 'fas fa-th-list', + description: '商品列表排序、分页、卡片样式和销量显示配置。', + defaults: { + shop_list_orderby: 'date', + shop_list_count: 12, + shop_list_paging: 'number', + shop_list_ias_s: false, + shop_list_ias_max: 5, + shop_list_style: 'default', + shop_sales_show: 'show', + shop_sales_show_min: 10, + }, + groups: [ + { + key: 'pagination', + title: '列表分页', + icon: 'fas fa-layer-group', + fields: [ + { key: 'shop_list_orderby', label: '默认排序', type: 'select', default: 'date', options: [{ label: '最新发布', value: 'date' }, { label: '销量最高', value: 'sales' }, { label: '价格最低', value: 'price_asc' }, { label: '价格最高', value: 'price_desc' }, { label: '评价最多', value: 'comments' }] }, + { key: 'shop_list_count', label: '每页数量', type: 'number', default: 12, min: 4, max: 100 }, + { key: 'shop_list_paging', label: '翻页模式', type: 'radio', default: 'number', options: [{ label: '数字翻页', value: 'number' }, { label: 'AJAX 加载', value: 'ajax' }] }, + { key: 'shop_list_ias_s', label: '滚动自动加载', type: 'switch', default: false, dependsOn: { key: 'shop_list_paging', value: 'ajax' } }, + { key: 'shop_list_ias_max', label: '自动加载页数', type: 'number', default: 5, min: 1, max: 50, dependsOn: { key: 'shop_list_ias_s', value: true } }, + ], + }, + { + key: 'style', + title: '列表样式', + icon: 'fas fa-palette', + fields: [ + { key: 'shop_list_style', label: '卡片样式', type: 'select', default: 'default', options: [{ label: '默认', value: 'default' }, { label: '竖向大卡片', value: 'card-lg' }, { label: '横向小卡片', value: 'card-sm' }] }, + { key: 'shop_sales_show', label: '销量显示', type: 'select', default: 'show', options: [{ label: '显示', value: 'show' }, { label: '不显示', value: 'hide' }, { label: '超量显示', value: 'threshold' }] }, + { key: 'shop_sales_show_min', label: '起显阈值', type: 'number', default: 10, min: 0, max: 99999, help: '销量超过此数字才显示', dependsOn: { key: 'shop_sales_show', value: 'threshold' } }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/shop/product-defaults.js b/assets/admin/src/schema/sections/shop/product-defaults.js new file mode 100644 index 00000000..566b6bbc --- /dev/null +++ b/assets/admin/src/schema/sections/shop/product-defaults.js @@ -0,0 +1,54 @@ +export const productDefaultsSchema = { + key: 'shop.product-defaults', + title: '商品默认参数', + icon: 'fas fa-sliders-h', + description: '主题级商品参数默认值,作为「商品→分类→主题设置」参数继承链的根。', + defaults: { + shop_limit_buy: -1, + shop_shipping_fee: 'free', + shop_guest_buy: false, + shop_email_fill: false, + shop_rebate: true, + shop_after_sale: 'refund_return', + shop_service: '', + shop_product_params_default: '', + }, + groups: [ + { + key: 'policy', + title: '购买策略', + icon: 'fas fa-shopping-bag', + fields: [ + { key: 'shop_limit_buy', label: '默认限购', type: 'number', default: -1, min: -1, max: 9999, help: '-1 不限,0 禁购,正数为限购数量' }, + { key: 'shop_guest_buy', label: '免登录购买', type: 'switch', default: false }, + { key: 'shop_email_fill', label: '邮箱必留', type: 'switch', default: false }, + { key: 'shop_rebate', label: '允许返佣', type: 'switch', default: true }, + ], + }, + { + key: 'shipping', + title: '运费默认', + icon: 'fas fa-truck', + fields: [ + { key: 'shop_shipping_fee', label: '默认运费类型', type: 'select', default: 'free', options: [{ label: '包邮', value: 'free' }, { label: '固定运费', value: 'fixed' }, { label: '按金额计算', value: 'amount' }] }, + ], + }, + { + key: 'afterSale', + title: '售后与服务', + icon: 'fas fa-undo', + fields: [ + { key: 'shop_after_sale', label: '默认售后政策', type: 'select', default: 'refund_return', options: [{ label: '仅退款', value: 'refund' }, { label: '退货退款', value: 'refund_return' }, { label: '不支持售后', value: 'none' }] }, + { key: 'shop_service', label: '服务保障', type: 'textarea', default: '', help: '每行一项:名称|说明|图标图片 URL' }, + ], + }, + { + key: 'params', + title: '默认参数模板', + icon: 'fas fa-list-alt', + fields: [ + { key: 'shop_product_params_default', label: '商品参数模板', type: 'textarea', default: '', help: '每行一个参数:参数名|参数值,新建商品自动带入' }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/shop/shipping-config.js b/assets/admin/src/schema/sections/shop/shipping-config.js new file mode 100644 index 00000000..9e41c82d --- /dev/null +++ b/assets/admin/src/schema/sections/shop/shipping-config.js @@ -0,0 +1,52 @@ +export const shippingConfigSchema = { + key: 'shop.shipping-config', + title: '物流配置', + icon: 'fas fa-shipping-fast', + description: '快递查询接口、物流公司列表和收货地址设置。', + defaults: { + shop_manual_address: false, + shop_express_api_sdk: 'kuaidi100', + shop_express_query_interval: 60, + shop_express_kdniao_opt: '', + shop_express_kuaidi100_opt: '', + shop_express_aliyun_opt: '', + shop_shipping_company: '', + }, + groups: [ + { + key: 'address', + title: '收货地址', + icon: 'fas fa-map-marker-alt', + fields: [ + { key: 'shop_manual_address', label: '手动填写收货地址', type: 'switch', default: false, help: '开启后用户可手动输入地址而非选择地址簿' }, + ], + }, + { + key: 'express', + title: '快递查询', + icon: 'fas fa-search', + fields: [ + { key: 'shop_express_api_sdk', label: '快递查询接口', type: 'select', default: 'kuaidi100', options: [{ label: '快递100', value: 'kuaidi100' }, { label: '阿里云', value: 'aliyun' }, { label: '快递鸟', value: 'kdniao' }] }, + { key: 'shop_express_query_interval', label: '查询刷新间隔(分钟)', type: 'number', default: 60, min: 30, max: 240 }, + ], + }, + { + key: 'credentials', + title: '接口凭据', + icon: 'fas fa-key', + fields: [ + { key: 'shop_express_kdniao_opt', label: '快递鸟凭据', type: 'textarea', default: '', help: 'AppID|APIKey', dependsOn: { key: 'shop_express_api_sdk', value: 'kdniao' } }, + { key: 'shop_express_kuaidi100_opt', label: '快递100凭据', type: 'textarea', default: '', help: 'Key|Customer', dependsOn: { key: 'shop_express_api_sdk', value: 'kuaidi100' } }, + { key: 'shop_express_aliyun_opt', label: '阿里云凭据', type: 'textarea', default: '', help: 'AppCode', dependsOn: { key: 'shop_express_api_sdk', value: 'aliyun' } }, + ], + }, + { + key: 'companies', + title: '物流公司', + icon: 'fas fa-building', + fields: [ + { key: 'shop_shipping_company', label: '物流公司列表', type: 'textarea', default: '', help: '每行一个:编码|名称,如 SF|顺丰速运' }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/shop/single.js b/assets/admin/src/schema/sections/shop/single.js new file mode 100644 index 00000000..e2056c55 --- /dev/null +++ b/assets/admin/src/schema/sections/shop/single.js @@ -0,0 +1,45 @@ +export const singleSchema = { + key: 'shop.single', + title: '商品详情', + icon: 'fas fa-box-open', + description: '商品详情页布局、相关推荐和底部内容设置。', + defaults: { + shop_content_layout: 'adapt', + shop_content_show_bg: false, + shop_single_tab: '', + shop_content_after: '', + shop_single_related_s: true, + shop_single_related_opt: '', + shop_main_image_default: '', + }, + groups: [ + { + key: 'layout', + title: '详情页布局', + icon: 'fas fa-columns', + fields: [ + { key: 'shop_content_layout', label: '内容布局', type: 'select', default: 'adapt', options: [{ label: '适应宽度', value: 'adapt' }, { label: '全屏', value: 'full' }, { label: '适应宽度+侧栏', value: 'sidebar' }] }, + { key: 'shop_content_show_bg', label: '详情背景盒子', type: 'switch', default: false }, + { key: 'shop_single_tab', label: '自定义 TAB 栏目', type: 'textarea', default: '', help: '每行一个 TAB:标题|内容 HTML' }, + { key: 'shop_content_after', label: '详情页底部内容', type: 'textarea', default: '', help: '追加在每个商品详情页底部的 HTML 内容' }, + ], + }, + { + key: 'related', + title: '相关推荐', + icon: 'fas fa-th', + fields: [ + { key: 'shop_single_related_s', label: '显示相关推荐', type: 'switch', default: true }, + { key: 'shop_single_related_opt', label: '推荐配置', type: 'textarea', default: '', help: '标题|关联类型(cat/discount/tag)|排序|数量|翻页|样式', dependsOn: { key: 'shop_single_related_s', value: true } }, + ], + }, + { + key: 'image', + title: '商品图片', + icon: 'fas fa-image', + fields: [ + { key: 'shop_main_image_default', label: '默认商品主图', type: 'media', default: '' }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/shop/timing.js b/assets/admin/src/schema/sections/shop/timing.js new file mode 100644 index 00000000..76c40067 --- /dev/null +++ b/assets/admin/src/schema/sections/shop/timing.js @@ -0,0 +1,54 @@ +export const timingSchema = { + key: 'shop.timing', + title: '时效与售后原因', + icon: 'fas fa-clock', + description: '确认收货、评价、退货时效和售后原因配置。', + defaults: { + shop_order_receipt_max_day: 15, + shop_comment_max_day: 7, + shop_comment_placeholder: '', + shop_comment_img_num: 3, + shop_after_sale_return_express_max_day: 7, + shop_after_sale_reason_refund: '', + shop_after_sale_reason_return: '', + shop_author_show: true, + }, + groups: [ + { + key: 'receipt', + title: '确认收货', + icon: 'fas fa-check-circle', + fields: [ + { key: 'shop_order_receipt_max_day', label: '自动确认收货(天)', type: 'number', default: 15, min: 1, max: 60, help: '发货后超过此天数自动确认收货' }, + ], + }, + { + key: 'comment', + title: '评价时效', + icon: 'fas fa-star', + fields: [ + { key: 'shop_comment_max_day', label: '评价时效(天)', type: 'number', default: 7, min: 1, max: 60, help: '确认收货后超过此天数自动好评' }, + { key: 'shop_comment_placeholder', label: '评价占位文案', type: 'input', default: '' }, + { key: 'shop_comment_img_num', label: '评价图片数', type: 'number', default: 3, min: 0, max: 9 }, + ], + }, + { + key: 'afterSale', + title: '售后退货', + icon: 'fas fa-undo-alt', + fields: [ + { key: 'shop_after_sale_return_express_max_day', label: '退货快递时效(天)', type: 'number', default: 7, min: 1, max: 30 }, + { key: 'shop_after_sale_reason_refund', label: '仅退款原因', type: 'textarea', default: '', help: '每行一个可选原因' }, + { key: 'shop_after_sale_reason_return', label: '退货退款原因', type: 'textarea', default: '', help: '每行一个可选原因' }, + ], + }, + { + key: 'author', + title: '商家信息', + icon: 'fas fa-user-tie', + fields: [ + { key: 'shop_author_show', label: '显示商家信息', type: 'switch', default: true }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/tools/backup.js b/assets/admin/src/schema/sections/tools/backup.js new file mode 100644 index 00000000..8700e521 --- /dev/null +++ b/assets/admin/src/schema/sections/tools/backup.js @@ -0,0 +1,29 @@ +export const backupSchema = { + key: 'tools.backup', + title: '数据备份', + icon: 'fas fa-cloud-download-alt', + description: '站点数据备份与恢复。', + defaults: {}, + groups: [ + { + key: 'backup', + title: '备份设置', + icon: 'fas fa-save', + fields: [ + { key: 'backup_auto', label: '自动备份', type: 'switch', help: '启用自动定时备份' }, + { + key: 'backup_frequency', + label: '备份频率', + type: 'select', + options: [ + { label: '每天', value: 'daily' }, + { label: '每周', value: 'weekly' }, + { label: '每月', value: 'monthly' }, + ], + }, + { key: 'backup_retain', label: '保留份数', type: 'number', min: 3, max: 20, default: 7 }, + { key: 'backup_now', label: '立即备份', type: 'button', help: '手动触发一次完整备份' }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/tools/email-rollback.js b/assets/admin/src/schema/sections/tools/email-rollback.js new file mode 100644 index 00000000..c83880ce --- /dev/null +++ b/assets/admin/src/schema/sections/tools/email-rollback.js @@ -0,0 +1,29 @@ +export const emailRollbackSchema = { + key: 'tools.email-rollback', + title: '邮件回滚', + icon: 'fas fa-undo', + description: '批量回滚已发送的通知邮件。', + defaults: {}, + groups: [ + { + key: 'rollback', + title: '邮件回滚', + icon: 'fas fa-envelope-open', + fields: [ + { + key: 'rollback_type', + label: '回滚类型', + type: 'select', + options: [ + { label: '按时间范围', value: 'date_range' }, + { label: '按邮件类型', value: 'email_type' }, + { label: '全部回滚', value: 'all' }, + ], + }, + { key: 'rollback_date_from', label: '开始日期', type: 'date' }, + { key: 'rollback_date_to', label: '结束日期', type: 'date' }, + { key: 'rollback_execute', label: '执行回滚', type: 'button', danger: true, help: '此操作不可逆' }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/tools/health.js b/assets/admin/src/schema/sections/tools/health.js new file mode 100644 index 00000000..b439bdc1 --- /dev/null +++ b/assets/admin/src/schema/sections/tools/health.js @@ -0,0 +1,21 @@ +export const healthSchema = { + key: 'tools.health', + title: '健康检查', + icon: 'fas fa-heartbeat', + description: '检查主题运行环境和依赖状态。', + defaults: {}, + groups: [ + { + key: 'check', + title: '环境检查', + icon: 'fas fa-stethoscope', + fields: [ + { key: 'php_version', label: 'PHP 版本', type: 'display', help: '建议 7.4+' }, + { key: 'wp_version', label: 'WordPress 版本', type: 'display', help: '建议 5.8+' }, + { key: 'memory_limit', label: '内存限制', type: 'display', help: '建议 256M+' }, + { key: 'upload_max_size', label: '上传限制', type: 'display' }, + { key: 'extensions', label: '必要扩展', type: 'display', help: 'curl, mbstring, json, gd' }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/tools/import-export.js b/assets/admin/src/schema/sections/tools/import-export.js new file mode 100644 index 00000000..a9d6effb --- /dev/null +++ b/assets/admin/src/schema/sections/tools/import-export.js @@ -0,0 +1,26 @@ +export const importExportSchema = { + key: 'tools.import-export', + title: '导入导出', + icon: 'fas fa-exchange-alt', + description: '主题配置的导入和导出。', + defaults: {}, + groups: [ + { + key: 'export', + title: '导出配置', + icon: 'fas fa-file-export', + fields: [ + { key: 'export_settings', label: '导出主题设置', type: 'button', help: '导出当前所有主题配置为 JSON 文件' }, + { key: 'export_options', label: '导出 WordPress 选项', type: 'button', help: '导出 aksrt_settings 等选项' }, + ], + }, + { + key: 'import', + title: '导入配置', + icon: 'fas fa-file-import', + fields: [ + { key: 'import_settings', label: '导入主题设置', type: 'file', accept: '.json', help: '导入主题配置文件(将覆盖当前设置)' }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/tools/index.js b/assets/admin/src/schema/sections/tools/index.js new file mode 100644 index 00000000..c4adb66b --- /dev/null +++ b/assets/admin/src/schema/sections/tools/index.js @@ -0,0 +1,18 @@ +/** + * Tools & Backup domain schemas. + * Each key corresponds to a sub-page under the "备份&导入" section. + */ + +import { importExportSchema } from './import-export.js' +import { healthSchema } from './health.js' +import { migrationSchema } from './migration.js' +import { emailRollbackSchema } from './email-rollback.js' +import { backupSchema } from './backup.js' + +export const toolsSchemas = { + 'import-export': importExportSchema, + health: healthSchema, + migration: migrationSchema, + 'email-rollback': emailRollbackSchema, + backup: backupSchema, +} diff --git a/assets/admin/src/schema/sections/tools/migration.js b/assets/admin/src/schema/sections/tools/migration.js new file mode 100644 index 00000000..be791aba --- /dev/null +++ b/assets/admin/src/schema/sections/tools/migration.js @@ -0,0 +1,19 @@ +export const migrationSchema = { + key: 'tools.migration', + title: '数据迁移', + icon: 'fas fa-database', + description: '数据库结构迁移和版本升级。', + defaults: {}, + groups: [ + { + key: 'status', + title: '迁移状态', + icon: 'fas fa-info-circle', + fields: [ + { key: 'current_version', label: '当前版本', type: 'display' }, + { key: 'pending_migrations', label: '待执行迁移', type: 'display' }, + { key: 'run_migration', label: '执行迁移', type: 'button', help: '手动触发数据库迁移' }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/users/auth.js b/assets/admin/src/schema/sections/users/auth.js new file mode 100644 index 00000000..3db38214 --- /dev/null +++ b/assets/admin/src/schema/sections/users/auth.js @@ -0,0 +1,160 @@ +export const authSchema = { + key: 'users.auth', + title: '注册登录', + icon: 'fas fa-sign-in-alt', + description: '注册方式、登录限制、密码策略配置。', + defaults: { + close_sign: false, + user_close_signup: false, + close_signin: false, + user_agreement_s: true, + user_agreement_page: '', + user_privacy_s: true, + user_privacy_page: '', + user_agreement_check: true, + no_repetition_name: false, + user_nickname_out: '赌博,博彩,代发,代理,刷单,六合彩,admin,test,管理员,客服', + user_email_limit_type: 'black', + user_email_out: '@tempmail.com\n@throwaway.email\n@guerrillamail.com', + user_close_login: false, + user_sign_type: 'page', + sign_modal_auto_s: false, + sign_modal_auto_waiting_time: 5, + sign_modal_auto_expires: 1, + replace_wp_login: true, + admin_sign_key: '', + user_signin_phone_s: false, + user_signin_nopas_s: false, + user_signin_nopas_type: 'email', + user_signin_nopas_active: 'nopas_first', + user_signup_captch: true, + captch_type: 'email', + user_signup_no_repas: false, + user_repas_captch_type: 'email', + bind_phone: false, + bind_reminder: 'close', + bind_reminder_text: '请绑定手机号以正常使用', + bind_reminder_expires: 24, + mandatory_bind: 'close', + email_set_captch: true, + invit_code_s: false, + invit_code_mode: 'open', + invit_code_open_desc: '邀请码为选填,没有邀请码也可以注册', + invit_code_must_desc: '请输入邀请码', + sign_page_background: [], + sign_theme_mode: 'default', + sign_show_header: true, + sign_card_position: 'center', + sign_show_logo: true, + sign_page_footer: '', + sign_modal_background: [], + sign_modal_show_logo: true, + user_logo: [], + user_logo_dark: [], + }, + groups: [ + { + key: 'register', + title: '注册设置', + icon: 'fas fa-user-plus', + fields: [ + { key: 'close_sign', label: '一键禁用登录注册', type: 'switch', default: false, help: '开启后前台禁用全部注册/登录入口(含社交登录),不影响后台登录' }, + { key: 'user_close_signup', label: '关闭注册', type: 'switch', default: false }, + { key: 'close_signin', label: '禁用账密登录', type: 'switch', default: false, help: '仅允许社交账号登录' }, + { key: 'user_agreement_s', label: '用户协议', type: 'switch', default: true }, + { key: 'user_agreement_page', label: '协议页面', type: 'input', default: '' }, + { key: 'user_privacy_s', label: '隐私政策', type: 'switch', default: true }, + { key: 'user_privacy_page', label: '隐私页面', type: 'input', default: '' }, + { key: 'user_agreement_check', label: '强制勾选协议', type: 'switch', default: true }, + { key: 'no_repetition_name', label: '禁止重复昵称', type: 'switch', default: false }, + { key: 'user_nickname_out', label: '昵称黑名单关键词', type: 'textarea', default: '赌博,博彩,代发,代理,刷单,六合彩,admin,test,管理员,客服', help: '一行一个关键词,注册昵称包含关键词将被拒绝' }, + { key: 'user_email_limit_type', label: '邮箱限制类型', type: 'radio', default: 'black', options: [{ label: '黑名单(拒绝以下后缀)', value: 'black' }, { label: '白名单(仅允许以下后缀)', value: 'white' }] }, + { key: 'user_email_out', label: '邮箱限制后缀', type: 'textarea', default: '@tempmail.com\n@throwaway.email\n@guerrillamail.com', help: '一行一个邮箱后缀,如 @tempmail.com' }, + ], + }, + { + key: 'login', + title: '登录设置', + icon: 'fas fa-sign-in-alt', + fields: [ + { key: 'user_close_login', label: '关闭登录', type: 'switch', default: false }, + { key: 'user_sign_type', label: '登录注册样式', type: 'radio', default: 'page', options: [{ label: '弹窗', value: 'modal' }, { label: '跳转页面', value: 'page' }] }, + { key: 'sign_modal_auto_s', label: '自动弹出登录窗', type: 'switch', default: false, help: '仅在 user_sign_type=modal 时生效' }, + { key: 'sign_modal_auto_waiting_time', label: '弹窗等待秒数', type: 'number', default: 5, min: 0, max: 60, dependsOn: { key: 'sign_modal_auto_s', value: true } }, + { key: 'sign_modal_auto_expires', label: '弹窗周期(小时)', type: 'number', default: 1, min: 0, max: 720, dependsOn: { key: 'sign_modal_auto_s', value: true } }, + { key: 'replace_wp_login', label: '替换WP登录页', type: 'switch', default: true }, + { key: 'admin_sign_key', label: '应急登录 Key', type: 'input', default: '', help: '留空为禁用。管理员可通过 /wp-login.php?aksrt_bypass= 绕过前台替换' }, + { key: 'user_signin_phone_s', label: '手机号登录', type: 'switch', default: false, help: '允许用手机号作为用户名登录' }, + { key: 'user_signin_nopas_s', label: '免密登录', type: 'switch', default: false }, + { key: 'user_signin_nopas_type', label: '免密登录方式', type: 'radio', default: 'email', options: [{ label: '邮箱验证码', value: 'email' }, { label: '手机验证码', value: 'phone' }, { label: '邮箱或手机', value: 'email_phone' }], dependsOn: { key: 'user_signin_nopas_s', value: true } }, + { key: 'user_signin_nopas_active', label: '免密显示模式', type: 'radio', default: 'nopas_first', options: [{ label: '优先免密', value: 'nopas_first' }, { label: '优先密码', value: 'password_first' }, { label: '仅免密', value: 'nopas_only' }], dependsOn: { key: 'user_signin_nopas_s', value: true } }, + ], + }, + { + key: 'signup_captcha', + title: '注册验证', + icon: 'fas fa-user-shield', + fields: [ + { key: 'user_signup_captch', label: '注册需验证', type: 'switch', default: true, help: '注册时需完成邮箱或手机验证' }, + { key: 'captch_type', label: '验证方式', type: 'radio', default: 'email', options: [{ label: '邮箱', value: 'email' }, { label: '手机', value: 'phone' }, { label: '邮箱或手机', value: 'email_phone' }], dependsOn: { key: 'user_signup_captch', value: true } }, + { key: 'user_signup_no_repas', label: '无需重复密码', type: 'switch', default: false, help: '注册时不要求二次确认密码' }, + { key: 'user_repas_captch_type', label: '找回密码验证方式', type: 'radio', default: 'email', options: [{ label: '邮箱', value: 'email' }, { label: '手机', value: 'phone' }, { label: '邮箱或手机', value: 'email_phone' }] }, + ], + }, + { + key: 'bind', + title: '绑定设置', + icon: 'fas fa-link', + fields: [ + { key: 'bind_phone', label: '绑定手机', type: 'switch', default: false }, + { key: 'bind_reminder', label: '绑定提醒', type: 'radio', default: 'close', options: [{ label: '提醒绑定邮箱', value: 'email' }, { label: '提醒绑定手机', value: 'phone' }, { label: '提醒绑定邮箱和手机', value: 'email_phone' }, { label: '关闭', value: 'close' }] }, + { key: 'bind_reminder_text', label: '提醒文案', type: 'input', default: '请绑定手机号以正常使用' }, + { key: 'bind_reminder_expires', label: '提醒周期(小时)', type: 'number', default: 24, min: 1, max: 720, help: '用户关闭提醒后多少小时内不再提醒' }, + { key: 'mandatory_bind', label: '强制绑定', type: 'radio', default: 'close', options: [{ label: '强制绑定邮箱', value: 'email' }, { label: '强制绑定手机', value: 'phone' }, { label: '强制绑定邮箱和手机', value: 'email_phone' }, { label: '关闭', value: 'close' }] }, + { key: 'email_set_captch', label: '绑定邮箱需验证', type: 'switch', default: true, help: '绑定邮箱时需发送验证码确认' }, + ], + }, + { + key: 'invite_inline', + title: '邀请码注册', + icon: 'fas fa-ticket-alt', + fields: [ + { key: 'invit_code_s', label: '启用邀请码', type: 'switch', default: false, help: '与「邀请码」子页联动,此处开关控制注册表单是否显示邀请码字段' }, + { key: 'invit_code_mode', label: '邀请模式', type: 'select', default: 'open', options: [{ label: '开放注册(邀请码选填)', value: 'open' }, { label: '必须邀请码', value: 'must' }], dependsOn: { key: 'invit_code_s', value: true } }, + { key: 'invit_code_open_desc', label: '开放注册提示', type: 'input', default: '邀请码为选填,没有邀请码也可以注册', dependsOn: { key: 'invit_code_s', value: true } }, + { key: 'invit_code_must_desc', label: '必填提示', type: 'input', default: '请输入邀请码', dependsOn: { key: 'invit_code_s', value: true } }, + ], + }, + { + key: 'sign_page_ui', + title: '页面 UI 配置', + icon: 'fas fa-palette', + fields: [ + { key: 'sign_page_background', label: '页面背景图', type: 'gallery', default: [], help: '登录注册页背景图,多图随机' }, + { key: 'sign_theme_mode', label: '主题模式', type: 'radio', default: 'default', options: [{ label: '默认', value: 'default' }, { label: '亮色', value: 'light' }, { label: '暗色', value: 'dark' }] }, + { key: 'sign_show_header', label: '显示顶部导航', type: 'switch', default: true }, + { key: 'sign_card_position', label: '卡片位置', type: 'radio', default: 'center', options: [{ label: '靠左', value: 'left' }, { label: '居中', value: 'center' }, { label: '靠右', value: 'right' }] }, + { key: 'sign_show_logo', label: '显示 LOGO', type: 'switch', default: true }, + { key: 'sign_page_footer', label: '页脚 HTML', type: 'textarea', default: '', help: '登录注册页自定义页脚内容,支持 HTML' }, + ], + }, + { + key: 'modal_ui', + title: '弹窗 UI 配置', + icon: 'fas fa-window-restore', + fields: [ + { key: 'sign_modal_background', label: '弹窗背景图', type: 'gallery', default: [], help: '弹窗左侧背景图,多图随机' }, + { key: 'sign_modal_show_logo', label: '弹窗显示 LOGO', type: 'switch', default: true }, + ], + }, + { + key: 'sign_logo', + title: '登录框 LOGO', + icon: 'fas fa-image', + fields: [ + { key: 'user_logo', label: '日间 LOGO', type: 'gallery', default: [], help: '登录框专用日间 LOGO,留空使用站点全局 LOGO' }, + { key: 'user_logo_dark', label: '夜间 LOGO', type: 'gallery', default: [], help: '登录框专用夜间 LOGO,留空使用站点全局夜间 LOGO' }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/users/badge.js b/assets/admin/src/schema/sections/users/badge.js new file mode 100644 index 00000000..48fc7c91 --- /dev/null +++ b/assets/admin/src/schema/sections/users/badge.js @@ -0,0 +1,32 @@ +export const badgeSchema = { + key: 'users.badge', + title: '用户徽章', + icon: 'fas fa-award', + description: '配置徽章字典、公开展示数量和前台挂点,具体授予逻辑后续扩展。', + defaults: { + user_badge_enabled: false, + user_badge_public_display: true, + user_badge_limit: 3, + user_badge_dictionary: 'founder|创始成员|warning\nstaff|站务团队|info', + }, + groups: [ + { + key: 'display', + title: '徽章展示', + icon: 'fas fa-medal', + fields: [ + { key: 'user_badge_enabled', label: '启用徽章系统', type: 'switch', default: false }, + { key: 'user_badge_public_display', label: '公开页显示徽章', type: 'switch', default: true, dependsOn: { key: 'user_badge_enabled', value: true } }, + { key: 'user_badge_limit', label: '最多显示数量', type: 'number', default: 3, min: 1, max: 8, dependsOn: { key: 'user_badge_enabled', value: true } }, + ], + }, + { + key: 'dictionary', + title: '徽章字典', + icon: 'fas fa-bookmark', + fields: [ + { key: 'user_badge_dictionary', label: '徽章列表', type: 'textarea', default: 'founder|创始成员|warning\nstaff|站务团队|info', help: '每行一个徽章:slug|显示文案|色调(neutral/info/success/warning/brand)', dependsOn: { key: 'user_badge_enabled', value: true } }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/users/credits.js b/assets/admin/src/schema/sections/users/credits.js new file mode 100644 index 00000000..87d11a32 --- /dev/null +++ b/assets/admin/src/schema/sections/users/credits.js @@ -0,0 +1,98 @@ +export const creditsSchema = { + key: 'users.credits', + title: '用户积分', + icon: 'fas fa-coins', + description: '积分规则、积分日志、用户积分管理。', + defaults: { + points_s: true, + points_pay_s: false, + points_rate: 100, + points_name: '积分', + points_decimal: 2, + points_earn_comment: 1, + points_earn_post: 5, + points_earn_login: 1, + points_earn_like_received: 1, + points_earn_invite: 10, + points_earn_daily_limit: 50, + points_spend_comment: 0, + points_spend_download: 0, + points_spend_view_hidden: 0, + points_transfer_s: false, + points_service_charge: 0, + points_transfer_min: 1, + points_deduction_s: false, + points_deduction_rate: 100, + points_deduction_max: 50, + points_show_balance: true, + points_show_log: true, + points_expire_days: 0, + }, + groups: [ + { + key: 'basic', + title: '积分基础', + icon: 'fas fa-coins', + fields: [ + { key: 'points_s', label: '启用积分系统', type: 'switch', default: true }, + { key: 'points_pay_s', label: '积分支付', type: 'switch', default: false }, + { key: 'points_rate', label: '积分兑换比例', type: 'number', default: 100, min: 1, max: 10000 }, + { key: 'points_name', label: '积分名称', type: 'input', default: '积分' }, + { key: 'points_decimal', label: '积分小数位数', type: 'number', default: 2, min: 0, max: 4 }, + ], + }, + { + key: 'earn', + title: '积分获取', + icon: 'fas fa-arrow-up', + fields: [ + { key: 'points_earn_comment', label: '评论奖励', type: 'number', default: 1, min: 0, max: 100 }, + { key: 'points_earn_post', label: '发布文章奖励', type: 'number', default: 5, min: 0, max: 100 }, + { key: 'points_earn_login', label: '每日登录奖励', type: 'number', default: 1, min: 0, max: 100 }, + { key: 'points_earn_like_received', label: '收到点赞奖励', type: 'number', default: 1, min: 0, max: 100 }, + { key: 'points_earn_invite', label: '邀请注册奖励', type: 'number', default: 10, min: 0, max: 1000 }, + { key: 'points_earn_daily_limit', label: '每日获取上限', type: 'number', default: 50, min: 0, max: 9999, help: '0为不限制' }, + ], + }, + { + key: 'spend', + title: '积分消费', + icon: 'fas fa-arrow-down', + fields: [ + { key: 'points_spend_comment', label: '评论消耗', type: 'number', default: 0, min: 0, max: 100 }, + { key: 'points_spend_download', label: '下载消耗', type: 'number', default: 0, min: 0, max: 100 }, + { key: 'points_spend_view_hidden', label: '查看隐藏内容消耗', type: 'number', default: 0, min: 0, max: 100 }, + ], + }, + { + key: 'transfer', + title: '积分转账', + icon: 'fas fa-exchange-alt', + fields: [ + { key: 'points_transfer_s', label: '启用积分转账', type: 'switch', default: false }, + { key: 'points_service_charge', label: '转账手续费(%)', type: 'number', default: 0, min: 0, max: 50, dependsOn: { key: 'points_transfer_s', value: true } }, + { key: 'points_transfer_min', label: '最低转账金额', type: 'number', default: 1, min: 1, max: 9999, dependsOn: { key: 'points_transfer_s', value: true } }, + ], + }, + { + key: 'deduction', + title: '积分抵扣', + icon: 'fas fa-percent', + fields: [ + { key: 'points_deduction_s', label: '启用积分抵扣', type: 'switch', default: false }, + { key: 'points_deduction_rate', label: '抵扣比例(%)', type: 'number', default: 100, min: 1, max: 100, dependsOn: { key: 'points_deduction_s', value: true } }, + { key: 'points_deduction_max', label: '最大抵扣比例(%)', type: 'number', default: 50, min: 1, max: 100, dependsOn: { key: 'points_deduction_s', value: true } }, + ], + }, + { + key: 'display', + title: '展示设置', + icon: 'fas fa-eye', + fields: [ + { key: 'points_show_balance', label: '导航栏显示余额', type: 'switch', default: true }, + { key: 'points_show_log', label: '显示积分记录', type: 'switch', default: true }, + { key: 'points_expire_days', label: '积分有效期(天)', type: 'number', default: 0, min: 0, help: '0为永久有效' }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/users/features.js b/assets/admin/src/schema/sections/users/features.js new file mode 100644 index 00000000..a4efb658 --- /dev/null +++ b/assets/admin/src/schema/sections/users/features.js @@ -0,0 +1,45 @@ +export const featuresSchema = { + key: 'users.features', + title: '用户功能', + icon: 'fas fa-user-cog', + description: '配置用户中心和个人主页的功能模块。', + defaults: { + user_center_tabs: 'profile,posts,comments,credits,favorites', + user_avatar_upload: true, + user_cover_upload: true, + user_social_links: true, + user_desc_std: '这家伙很懒…', + user_city_s: false, + user_join_day_my_name: '本站', + user_avatar_default: '', + user_cover_img: '', + user_rewrite_slug: 'user', + user_follow_s: true, + user_message_s: true, + user_online_s: true, + user_last_active_s: true, + }, + groups: [ + { + key: 'center', + title: '用户中心', + icon: 'fas fa-user-shield', + fields: [ + { key: 'user_center_tabs', label: '用户中心 Tab', type: 'input', default: 'profile,posts,comments,credits,favorites', help: '逗号分隔' }, + { key: 'user_avatar_upload', label: '头像上传', type: 'switch', default: true }, + { key: 'user_cover_upload', label: '封面上传', type: 'switch', default: true }, + { key: 'user_social_links', label: '社交链接', type: 'switch', default: true }, + { key: 'user_desc_std', label: '默认签名', type: 'input', default: '这家伙很懒…', help: '用户未设置个人简介时显示的默认文案' }, + { key: 'user_city_s', label: '显示城市', type: 'switch', default: false }, + { key: 'user_join_day_my_name', label: '站名', type: 'input', default: '本站', help: '用于「已加入 X 天」显示中的站点名称' }, + { key: 'user_avatar_default', label: '默认头像', type: 'media', default: '' }, + { key: 'user_cover_img', label: '默认封面', type: 'media', default: '' }, + { key: 'user_rewrite_slug', label: '用户页 slug', type: 'input', default: 'user' }, + { key: 'user_follow_s', label: '启用关注功能', type: 'switch', default: true }, + { key: 'user_message_s', label: '启用私信功能', type: 'switch', default: true }, + { key: 'user_online_s', label: '显示在线状态', type: 'switch', default: true }, + { key: 'user_last_active_s', label: '显示最后活跃', type: 'switch', default: true }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/users/index.js b/assets/admin/src/schema/sections/users/index.js new file mode 100644 index 00000000..346ad398 --- /dev/null +++ b/assets/admin/src/schema/sections/users/index.js @@ -0,0 +1,33 @@ +/** + * Users & Interaction domain schemas. + */ + +import { authSchema } from './auth.js' +import { oauthSchema } from './oauth.js' +import { notificationsSchema } from './notifications.js' +import { creditsSchema } from './credits.js' +import { inviteSchema } from './invite.js' +import { featuresSchema } from './features.js' +import { signinSchema } from './signin.js' +import { verifySchema } from './verify.js' +import { levelSchema } from './level.js' +import { badgeSchema } from './badge.js' +import { wechatSchema } from './wechat.js' +import { reportSchema } from './report.js' +import { smsSchema } from './sms.js' + +export const usersSchemas = { + auth: authSchema, + oauth: oauthSchema, + notifications: notificationsSchema, + credits: creditsSchema, + invite: inviteSchema, + features: featuresSchema, + signin: signinSchema, + verify: verifySchema, + level: levelSchema, + badge: badgeSchema, + wechat: wechatSchema, + report: reportSchema, + sms: smsSchema, +} diff --git a/assets/admin/src/schema/sections/users/invite.js b/assets/admin/src/schema/sections/users/invite.js new file mode 100644 index 00000000..2cc50e7c --- /dev/null +++ b/assets/admin/src/schema/sections/users/invite.js @@ -0,0 +1,68 @@ +export const inviteSchema = { + key: 'users.invite', + title: '邀请码', + icon: 'fas fa-ticket-alt', + description: '邀请码生成、管理、奖励配置。', + defaults: { + invit_code_s: false, + invit_code_open_desc: '邀请码为选填,没有邀请码也可以注册', + invit_code_must_desc: '请输入邀请码', + invit_code_mode: 'open', + invit_code_length: 8, + invit_code_prefix: '', + invit_code_max_use: 1, + invit_code_expiry: 0, + invit_code_batch: 20, + invit_reward_points: 0, + invit_reward_level: 0, + invit_reward_vip_days: 0, + invit_invitee_reward: false, + invit_code_show_count: true, + invit_code_show_remain: true, + }, + groups: [ + { + key: 'basic', + title: '邀请设置', + icon: 'fas fa-cog', + fields: [ + { key: 'invit_code_s', label: '启用邀请码', type: 'switch', default: false }, + { key: 'invit_code_open_desc', label: '开放注册提示', type: 'input', default: '邀请码为选填,没有邀请码也可以注册' }, + { key: 'invit_code_must_desc', label: '必填提示', type: 'input', default: '请输入邀请码' }, + { key: 'invit_code_mode', label: '邀请模式', type: 'select', default: 'open', options: [{ label: '开放注册(邀请码选填)', value: 'open' }, { label: '必须邀请码', value: 'must' }] }, + ], + }, + { + key: 'format', + title: '码面设置', + icon: 'fas fa-barcode', + fields: [ + { key: 'invit_code_length', label: '邀请码长度', type: 'number', default: 8, min: 4, max: 32 }, + { key: 'invit_code_prefix', label: '邀请码前缀', type: 'input', default: '' }, + { key: 'invit_code_max_use', label: '单码最大使用次数', type: 'number', default: 1, min: 1, max: 9999 }, + { key: 'invit_code_expiry', label: '有效期(天)', type: 'number', default: 0, min: 0, help: '0为永久有效' }, + { key: 'invit_code_batch', label: '批量生成数量', type: 'number', default: 20, min: 1, max: 500 }, + ], + }, + { + key: 'reward', + title: '邀请奖励', + icon: 'fas fa-gift', + fields: [ + { key: 'invit_reward_points', label: '邀请奖励积分', type: 'number', default: 0, min: 0, max: 1000 }, + { key: 'invit_reward_level', label: '邀请奖励等级', type: 'number', default: 0, min: 0, max: 10 }, + { key: 'invit_reward_vip_days', label: '奖励VIP天数', type: 'number', default: 0, min: 0, max: 3650 }, + { key: 'invit_invitee_reward', label: '被邀请人奖励', type: 'switch', default: false }, + ], + }, + { + key: 'display', + title: '展示设置', + icon: 'fas fa-eye', + fields: [ + { key: 'invit_code_show_count', label: '显示已使用次数', type: 'switch', default: true }, + { key: 'invit_code_show_remain', label: '显示剩余次数', type: 'switch', default: true }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/users/level.js b/assets/admin/src/schema/sections/users/level.js new file mode 100644 index 00000000..42f96ce9 --- /dev/null +++ b/assets/admin/src/schema/sections/users/level.js @@ -0,0 +1,34 @@ +export const levelSchema = { + key: 'users.level', + title: '用户等级', + icon: 'fas fa-level-up-alt', + description: '配置等级展示前缀和等级阈值,当前可基于积分余额自动计算。', + defaults: { + user_level_enabled: false, + user_level_public_display: true, + user_level_source: 'credits_balance', + user_level_prefix: 'Lv.', + user_level_thresholds: '1|0\n2|30\n3|100\n4|300', + }, + groups: [ + { + key: 'display', + title: '等级显示', + icon: 'fas fa-signal', + fields: [ + { key: 'user_level_enabled', label: '启用等级系统', type: 'switch', default: false }, + { key: 'user_level_public_display', label: '公开页显示等级', type: 'switch', default: true, dependsOn: { key: 'user_level_enabled', value: true } }, + { key: 'user_level_prefix', label: '等级前缀', type: 'input', default: 'Lv.', dependsOn: { key: 'user_level_enabled', value: true } }, + ], + }, + { + key: 'rules', + title: '等级规则', + icon: 'fas fa-list-ol', + fields: [ + { key: 'user_level_source', label: '等级来源', type: 'radio', default: 'credits_balance', options: [{ label: '积分余额', value: 'credits_balance' }, { label: '手动 Meta', value: 'manual_meta' }], dependsOn: { key: 'user_level_enabled', value: true } }, + { key: 'user_level_thresholds', label: '等级阈值', type: 'textarea', default: '1|0\n2|30\n3|100\n4|300', help: '每行一个等级,格式:等级|所需积分,例如 3|100', dependsOn: { key: 'user_level_enabled', value: true } }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/users/notifications.js b/assets/admin/src/schema/sections/users/notifications.js new file mode 100644 index 00000000..2012ecb7 --- /dev/null +++ b/assets/admin/src/schema/sections/users/notifications.js @@ -0,0 +1,74 @@ +export const notificationsSchema = { + key: 'users.notifications', + title: '消息通知', + icon: 'fas fa-bell', + description: '站内通知、私信、通知策略配置。', + defaults: { + message_s: true, + msg_center_rewrite_slug: 'message', + message_icon_show: ['nav_menu'], + message_paginate_type: 'ajax_lists', + message_user_set: true, + message_close_msg_type: [], + message_auto_clear_s: false, + message_auto_clear_time: 365, + message_auto_clear_types: ['posts', 'comment', 'favorite', 'like', 'hot', 'followed', 'system'], + private_s: true, + private_smilie_s: true, + private_code_s: false, + private_image_s: true, + private_upload_img: false, + private_quick_s: true, + private_quick_often: '你好,请问有什么可以帮你的?\n感谢你的反馈\n已处理,请查收', + private_submit_text: '发送', + private_placeholder: '请输入消息...', + }, + groups: [ + { + key: 'basic', + title: '基础设置', + icon: 'fas fa-cog', + fields: [ + { key: 'message_s', label: '站内通知总开关', type: 'switch', default: true }, + { key: 'msg_center_rewrite_slug', label: '消息中心 URL 别名', type: 'input', default: 'message' }, + { key: 'message_icon_show', label: '通知图标位置', type: 'checkbox', default: ['nav_menu'], options: [{ label: '导航菜单', value: 'nav_menu' }, { label: '用户头像', value: 'avatar' }, { label: '移动端底部', value: 'mobile_bar' }] }, + { key: 'message_paginate_type', label: '列表翻页模式', type: 'radio', default: 'ajax_lists', options: [{ label: 'AJAX 翻页', value: 'ajax_lists' }, { label: '传统翻页', value: 'default' }] }, + { key: 'message_user_set', label: '允许用户设置推送', type: 'switch', default: true, help: '允许用户在前台自行设置通知推送偏好' }, + ], + }, + { + key: 'types', + title: '通知类型', + icon: 'fas fa-list-check', + fields: [ + { key: 'message_close_msg_type', label: '关闭部分推送', type: 'checkbox', default: [], options: [{ label: '文章通知', value: 'posts' }, { label: '评论回复', value: 'comment' }, { label: '收藏通知', value: 'favorite' }, { label: '点赞通知', value: 'like' }, { label: '热门通知', value: 'hot' }, { label: '关注通知', value: 'followed' }, { label: '系统通知', value: 'system' }, { label: '支付通知', value: 'pay' }, { label: '提现回复', value: 'withdraw_reply' }, { label: '勋章通知', value: 'medal' }, { label: '认证回复', value: 'auth_apply_reply' }], help: '勾选的类型将不向用户推送通知' }, + ], + }, + { + key: 'auto_clear', + title: '自动清理', + icon: 'fas fa-broom', + fields: [ + { key: 'message_auto_clear_s', label: '自动清理已读消息', type: 'switch', default: false }, + { key: 'message_auto_clear_time', label: '过期天数', type: 'number', default: 365, min: 1, max: 3650, help: '已读消息超过此天数将被自动清理', dependsOn: { key: 'message_auto_clear_s', value: true } }, + { key: 'message_auto_clear_types', label: '清理类型', type: 'checkbox', default: ['posts', 'comment', 'favorite', 'like', 'hot', 'followed', 'system'], options: [{ label: '文章通知', value: 'posts' }, { label: '评论回复', value: 'comment' }, { label: '收藏通知', value: 'favorite' }, { label: '点赞通知', value: 'like' }, { label: '热门通知', value: 'hot' }, { label: '关注通知', value: 'followed' }, { label: '系统通知', value: 'system' }, { label: '支付通知', value: 'pay' }, { label: '提现回复', value: 'withdraw_reply' }, { label: '勋章通知', value: 'medal' }, { label: '认证回复', value: 'auth_apply_reply' }, { label: '下载通知', value: 'download' }, { label: '积分变动', value: 'credits' }, { label: '私信', value: 'private' }, { label: '举报反馈', value: 'report' }], dependsOn: { key: 'message_auto_clear_s', value: true } }, + ], + }, + { + key: 'private', + title: '私信设置', + icon: 'fas fa-envelope', + fields: [ + { key: 'private_s', label: '启用私信功能', type: 'switch', default: true }, + { key: 'private_smilie_s', label: '支持表情', type: 'switch', default: true, dependsOn: { key: 'private_s', value: true } }, + { key: 'private_code_s', label: '支持代码', type: 'switch', default: false, dependsOn: { key: 'private_s', value: true } }, + { key: 'private_image_s', label: '支持图片', type: 'switch', default: true, dependsOn: { key: 'private_s', value: true } }, + { key: 'private_upload_img', label: '支持上传图片', type: 'switch', default: false, dependsOn: { key: 'private_s', value: true } }, + { key: 'private_quick_s', label: '快捷回复', type: 'switch', default: true, dependsOn: { key: 'private_s', value: true } }, + { key: 'private_quick_often', label: '常用快捷回复', type: 'textarea', default: '你好,请问有什么可以帮你的?\n感谢你的反馈\n已处理,请查收', help: '一行一条快捷回复文案', dependsOn: { key: 'private_s', value: true } }, + { key: 'private_submit_text', label: '发送按钮文案', type: 'input', default: '发送', dependsOn: { key: 'private_s', value: true } }, + { key: 'private_placeholder', label: '输入占位符', type: 'input', default: '请输入消息...', dependsOn: { key: 'private_s', value: true } }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/users/oauth.js b/assets/admin/src/schema/sections/users/oauth.js new file mode 100644 index 00000000..a5687784 --- /dev/null +++ b/assets/admin/src/schema/sections/users/oauth.js @@ -0,0 +1,188 @@ +export const oauthSchema = { + key: 'users.oauth', + title: '第三方登录', + icon: 'fas fa-share-alt', + description: 'GitHub、Gitee、QQ、Google 等第三方登录配置。', + defaults: { + oauth_s: false, + oauth_proxy_mode: 'close', + oauth_proxy_secret: '', + oauth_proxy_server_url: '', + oauth_proxy_server_providers: ['github', 'gitee', 'qq', 'google'], + oauth_proxy_client_providers: ['github', 'gitee', 'qq', 'google'], + oauth_proxy_gzh_type: 'service', + oauth_bind_type: 'auto', + oauth_button_lg: false, + weixingzh_priority: false, + weixingzh_auto: false, + weixingzh_auto_expires: 3600, + oauth_github_s: false, + oauth_gitee_s: false, + oauth_qq_s: false, + oauth_weixingzh_s: false, + oauth_weixin_s: false, + oauth_weibo_s: false, + oauth_baidu_s: false, + oauth_alipay_s: false, + github_appid: '', + github_appkey: '', + gitee_appid: '', + gitee_appkey: '', + qq_appid: '', + qq_appkey: '', + weixin_appid: '', + weixin_appsecret: '', + weixingzh_gzh_type: 'service', + weixingzh_token: '', + weixingzh_token_nocheck: false, + weixingzh_encoding_aes_key: '', + weixingzh_appid: '', + weixingzh_appsecret: '', + weixingzh_code_qrcode: '', + weixingzh_code_keyword: '登录', + weixingzh_code_reply: '您的验证码是:%s,有效期 %d 分钟。', + weixingzh_code_subscribe_reply: '欢迎关注!回复「%s」获取登录验证码。', + weixingzh_subscribe_msg: '', + weixingzh_new_subscribe_msg: '', + weixingzh_scan_msg: '', + weixingzh_auto_reply_text: '', + weixingzh_auto_reply_search: '', + weixingzh_auto_reply_image: '', + weixingzh_auto_reply_voice: '', + weixingzh_auto_reply_default: '暂不支持此类型的消息', + weixingzh_gzh_menu: '', + weibo_appid: '', + weibo_appkey: '', + baidu_appid: '', + baidu_appkey: '', + alipay_appid: '', + alipay_privatekey: '', + alipay_publickey: '', + }, + groups: [ + { + key: 'basic', + title: '基础设置', + icon: 'fas fa-cog', + fields: [ + { key: 'oauth_s', label: '启用第三方登录', type: 'switch', default: false }, + { key: 'oauth_proxy_mode', label: '代理登录模式', type: 'radio', default: 'close', options: [{ label: '关闭', value: 'close' }, { label: '服务端', value: 'server' }, { label: '客户端', value: 'client' }] }, + { key: 'oauth_proxy_secret', label: '代理 HMAC 密钥', type: 'input', default: '', dependsOn: { key: 'oauth_proxy_mode', not: 'close' } }, + { key: 'oauth_proxy_server_url', label: '服务端地址', type: 'input', default: '', dependsOn: { key: 'oauth_proxy_mode', value: 'client' } }, + { key: 'oauth_proxy_server_providers', label: '服务端允许平台', type: 'checkbox', default: ['github', 'gitee', 'qq', 'google'], options: [{ label: 'GitHub', value: 'github' }, { label: 'Gitee', value: 'gitee' }, { label: 'QQ', value: 'qq' }, { label: 'Google', value: 'google' }, { label: '微博', value: 'weibo' }, { label: '微信', value: 'weixin' }, { label: '百度', value: 'baidu' }, { label: '支付宝', value: 'alipay' }], dependsOn: { key: 'oauth_proxy_mode', value: 'server' } }, + { key: 'oauth_proxy_client_providers', label: '客户端启用平台', type: 'checkbox', default: ['github', 'gitee', 'qq', 'google'], options: [{ label: 'GitHub', value: 'github' }, { label: 'Gitee', value: 'gitee' }, { label: 'QQ', value: 'qq' }, { label: 'Google', value: 'google' }, { label: '微博', value: 'weibo' }, { label: '微信', value: 'weixin' }, { label: '百度', value: 'baidu' }, { label: '支付宝', value: 'alipay' }], dependsOn: { key: 'oauth_proxy_mode', value: 'client' } }, + { key: 'oauth_proxy_gzh_type', label: '代理公众号类型', type: 'radio', default: 'service', options: [{ label: '服务号', value: 'service' }, { label: '订阅号', value: 'subscribe' }], dependsOn: { key: 'oauth_proxy_mode', not: 'close' } }, + { key: 'oauth_bind_type', label: '绑定方式', type: 'radio', default: 'auto', options: [{ label: '自动绑定', value: 'auto' }, { label: '手动绑定', value: 'manual' }] }, + { key: 'oauth_button_lg', label: '大按钮样式', type: 'switch', default: false }, + { key: 'weixingzh_priority', label: 'PC端优先微信扫码', type: 'switch', default: false, help: '开启后 PC 端登录框优先显示微信公众号扫码' }, + { key: 'weixingzh_auto', label: '微信APP内自动登录', type: 'switch', default: false, help: '在微信内置浏览器中自动使用公众号授权登录' }, + { key: 'weixingzh_auto_expires', label: '自动登录频率限制 (秒)', type: 'number', default: 3600, min: 0, max: 86400, dependsOn: { key: 'weixingzh_auto', value: true } }, + ], + }, + { + key: 'providers', + title: '登录提供商', + icon: 'fas fa-th', + fields: [ + { key: 'oauth_github_s', label: 'GitHub登录', type: 'switch', default: false }, + { key: 'oauth_gitee_s', label: 'Gitee登录', type: 'switch', default: false }, + { key: 'oauth_qq_s', label: 'QQ登录', type: 'switch', default: false }, + { key: 'oauth_weixingzh_s', label: '微信公众号登录', type: 'switch', default: false }, + { key: 'oauth_weixin_s', label: '微信开放平台登录', type: 'switch', default: false }, + { key: 'oauth_weibo_s', label: '微博登录', type: 'switch', default: false }, + { key: 'oauth_baidu_s', label: '百度登录', type: 'switch', default: false }, + { key: 'oauth_alipay_s', label: '支付宝登录', type: 'switch', default: false }, + ], + }, + { + key: 'github', + title: 'GitHub配置', + icon: 'fab fa-github', + fields: [ + { key: 'github_appid', label: 'Client ID', type: 'input', default: '' }, + { key: 'github_appkey', label: 'Client Secret', type: 'input', default: '' }, + ], + }, + { + key: 'gitee', + title: 'Gitee配置', + icon: 'fas fa-code-branch', + fields: [ + { key: 'gitee_appid', label: 'Client ID', type: 'input', default: '' }, + { key: 'gitee_appkey', label: 'Client Secret', type: 'input', default: '' }, + ], + }, + { + key: 'qq', + title: 'QQ配置', + icon: 'fab fa-qq', + fields: [ + { key: 'qq_appid', label: 'App ID', type: 'input', default: '' }, + { key: 'qq_appkey', label: 'App Key', type: 'input', default: '' }, + ], + }, + { + key: 'weixin', + title: '微信开放平台配置', + icon: 'fab fa-weixin', + fields: [ + { key: 'weixin_appid', label: 'AppID', type: 'input', default: '' }, + { key: 'weixin_appsecret', label: 'AppSecret', type: 'input', default: '' }, + ], + }, + { + key: 'weixingzh', + title: '微信公众号配置', + icon: 'fab fa-weixin', + fields: [ + { key: 'weixingzh_gzh_type', label: '公众号类型', type: 'radio', default: 'service', options: [{ label: '服务号', value: 'service' }, { label: '订阅号', value: 'subscribe' }] }, + { key: 'weixingzh_token', label: 'Token', type: 'input', default: '' }, + { key: 'weixingzh_token_nocheck', label: '不验证Token', type: 'switch', default: false, help: '开发调试时可跳过 Token 验证' }, + { key: 'weixingzh_encoding_aes_key', label: 'EncodingAESKey', type: 'input', default: '' }, + { key: 'weixingzh_appid', label: 'AppID', type: 'input', default: '' }, + { key: 'weixingzh_appsecret', label: 'AppSecret', type: 'input', default: '' }, + { key: 'weixingzh_code_qrcode', label: '公众号二维码', type: 'media', default: '', help: '引导用户扫码关注登录的二维码图片' }, + { key: 'weixingzh_code_keyword', label: '登录回复关键词', type: 'input', default: '登录', help: '用户在公众号内回复此关键词触发登录验证码' }, + { key: 'weixingzh_code_reply', label: '验证码回复模板', type: 'textarea', default: '您的验证码是:%s,有效期 %d 分钟。', help: '%s=验证码,%d=有效分钟数' }, + { key: 'weixingzh_code_subscribe_reply', label: '新关注回复模板', type: 'textarea', default: '欢迎关注!回复「%s」获取登录验证码。', help: '%s=登录关键词' }, + { key: 'weixingzh_subscribe_msg', label: '关注后回复', type: 'textarea', default: '', help: '用户关注公众号后的自动回复文案' }, + { key: 'weixingzh_new_subscribe_msg', label: '新用户关注回复', type: 'textarea', default: '', help: '首次关注用户的专属回复文案,留空使用通用回复' }, + { key: 'weixingzh_scan_msg', label: '扫码回复', type: 'textarea', default: '', help: '用户扫码关注公众号后的回复文案' }, + { key: 'weixingzh_auto_reply_text', label: '文本自动回复', type: 'textarea', default: '', help: '收到文本消息的自动回复' }, + { key: 'weixingzh_auto_reply_search', label: '搜索回复', type: 'textarea', default: '', help: '未匹配关键词时的搜索提示回复' }, + { key: 'weixingzh_auto_reply_image', label: '图片回复', type: 'textarea', default: '', help: '收到图片消息的自动回复' }, + { key: 'weixingzh_auto_reply_voice', label: '语音回复', type: 'textarea', default: '', help: '收到语音消息的自动回复' }, + { key: 'weixingzh_auto_reply_default', label: '默认回复', type: 'textarea', default: '暂不支持此类型的消息', help: '其他未匹配消息类型的兜底回复' }, + { key: 'weixingzh_gzh_menu', label: '自定义菜单 JSON', type: 'textarea', default: '', help: '微信公众号自定义菜单 JSON 配置,留空不创建菜单' }, + ], + }, + { + key: 'weibo', + title: '微博配置', + icon: 'fab fa-weibo', + fields: [ + { key: 'weibo_appid', label: 'App Key', type: 'input', default: '' }, + { key: 'weibo_appkey', label: 'App Secret', type: 'input', default: '' }, + ], + }, + { + key: 'baidu', + title: '百度配置', + icon: 'fas fa-paw', + fields: [ + { key: 'baidu_appid', label: 'API Key', type: 'input', default: '' }, + { key: 'baidu_appkey', label: 'Secret Key', type: 'input', default: '' }, + ], + }, + { + key: 'alipay', + title: '支付宝配置', + icon: 'fab fa-alipay', + fields: [ + { key: 'alipay_appid', label: 'AppID', type: 'input', default: '' }, + { key: 'alipay_privatekey', label: '应用私钥', type: 'input', default: '' }, + { key: 'alipay_publickey', label: '支付宝公钥', type: 'input', default: '' }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/users/report.js b/assets/admin/src/schema/sections/users/report.js new file mode 100644 index 00000000..04b3db5f --- /dev/null +++ b/assets/admin/src/schema/sections/users/report.js @@ -0,0 +1,58 @@ +export const reportSchema = { + key: 'users.report', + title: '禁封/举报', + icon: 'fas fa-flag', + description: '配置举报入口、公开限制提示和后续治理分类,为封禁/举报流程预留稳定结构。', + defaults: { + user_report_enabled: false, + user_report_public_entry: true, + user_report_public_notice: true, + user_report_ban_notice_text: '当前账号处于限制状态,部分互动能力已关闭。', + user_report_categories: 'spam|垃圾信息\nabuse|人身攻击\ncopyright|版权投诉', + ban_preset_reason: 'spam|垃圾信息\nabuse|人身攻击\nviolation|违规操作', + ban_appeal_desc: '', + ban_appeal_keys: '', + user_report_img_s: false, + user_report_desc: '', + }, + groups: [ + { + key: 'entry', + title: '前台入口', + icon: 'fas fa-door-open', + fields: [ + { key: 'user_report_enabled', label: '启用举报/禁封结构', type: 'switch', default: false }, + { key: 'user_report_public_entry', label: '公开主页展示举报入口', type: 'switch', default: true, dependsOn: { key: 'user_report_enabled', value: true } }, + { key: 'user_report_public_notice', label: '公开显示限制提示', type: 'switch', default: true, dependsOn: { key: 'user_report_enabled', value: true } }, + { key: 'user_report_ban_notice_text', label: '限制提示文案', type: 'input', default: '当前账号处于限制状态,部分互动能力已关闭。', dependsOn: { key: 'user_report_public_notice', value: true } }, + ], + }, + { + key: 'taxonomy', + title: '举报分类', + icon: 'fas fa-list', + fields: [ + { key: 'user_report_categories', label: '举报分类', type: 'textarea', default: 'spam|垃圾信息\nabuse|人身攻击\ncopyright|版权投诉', help: '每行一个分类:slug|显示名称', dependsOn: { key: 'user_report_enabled', value: true } }, + ], + }, + { + key: 'ban', + title: '禁封设置', + icon: 'fas fa-ban', + fields: [ + { key: 'ban_preset_reason', label: '预设封禁原因', type: 'textarea', default: 'spam|垃圾信息\nabuse|人身攻击\nviolation|违规操作', help: '每行一条:slug|显示名称', dependsOn: { key: 'user_report_enabled', value: true } }, + { key: 'ban_appeal_desc', label: '申诉说明', type: 'textarea', default: '', dependsOn: { key: 'user_report_enabled', value: true } }, + { key: 'ban_appeal_keys', label: '申诉关键词', type: 'input', default: '', help: '逗号分隔', dependsOn: { key: 'user_report_enabled', value: true } }, + ], + }, + { + key: 'image', + title: '图片举报', + icon: 'fas fa-image', + fields: [ + { key: 'user_report_img_s', label: '启用图片举报', type: 'switch', default: false, dependsOn: { key: 'user_report_enabled', value: true } }, + { key: 'user_report_desc', label: '举报说明', type: 'textarea', default: '', dependsOn: { key: 'user_report_img_s', value: true } }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/users/signin.js b/assets/admin/src/schema/sections/users/signin.js new file mode 100644 index 00000000..8a8c90f7 --- /dev/null +++ b/assets/admin/src/schema/sections/users/signin.js @@ -0,0 +1,27 @@ +export const signinSchema = { + key: 'users.signin', + title: '签到奖励', + icon: 'fas fa-calendar-check', + description: '配置签到规则、连续签到奖励和签到页面。', + defaults: { + signin_enabled: true, + signin_reward: 1, + signin_consecutive_bonus: true, + signin_consecutive_days: 7, + signin_bonus_reward: 5, + }, + groups: [ + { + key: 'rules', + title: '签到规则', + icon: 'fas fa-calendar-alt', + fields: [ + { key: 'signin_enabled', label: '启用签到', type: 'switch', default: true }, + { key: 'signin_reward', label: '每日签到奖励', type: 'number', default: 1, min: 0, max: 100, dependsOn: { key: 'signin_enabled', value: true } }, + { key: 'signin_consecutive_bonus', label: '连续签到加成', type: 'switch', default: true, dependsOn: { key: 'signin_enabled', value: true } }, + { key: 'signin_consecutive_days', label: '加成周期 (天)', type: 'number', default: 7, min: 2, max: 30, dependsOn: { key: 'signin_consecutive_bonus', value: true } }, + { key: 'signin_bonus_reward', label: '周期奖励', type: 'number', default: 5, min: 1, max: 100, dependsOn: { key: 'signin_consecutive_bonus', value: true } }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/users/sms.js b/assets/admin/src/schema/sections/users/sms.js new file mode 100644 index 00000000..eb0bbe0c --- /dev/null +++ b/assets/admin/src/schema/sections/users/sms.js @@ -0,0 +1,144 @@ +export const smsSchema = { + key: 'users.sms', + title: '短信接口', + icon: 'fas fa-sms', + description: '配置短信验证码和短信通知服务的供应商、模板和限流策略。', + defaults: { + sms_enabled: false, + sms_provider: 'aliyun', + sms_number_limit_ch: true, + sms_access_key: '', + sms_access_secret: '', + sms_sign_name: '', + sms_template_code: '', + sms_code_length: 6, + sms_code_expire: 300, + sms_rate_limit: 60, + sms_daily_limit: 10, + sms_notify_bind: true, + sms_notify_login: false, + sms_notify_register: true, + sms_notify_reset_password: true, + sms_ali_api_type: 'sms', + sms_region_id: 'cn-hangzhou', + sms_tencent_app_id: '', + sms_tencent_app_key: '', + sms_tencent_secret_id: '', + sms_tencent_secret_key: '', + sms_tencent_region: 'ap-guangzhou', + sms_huawei_sender: '', + sms_huawei_signature: '', + sms_huawei_endpoint: '', + sms_custom_url: '', + sms_custom_method: 'POST', + sms_custom_auth_type: 'header', + sms_custom_auth_key: '', + sms_custom_auth_value: '', + sms_custom_headers: '', + sms_custom_body: '', + sms_custom_success_rule: '', + }, + groups: [ + { + key: 'provider', + title: '供应商配置', + icon: 'fas fa-plug', + fields: [ + { key: 'sms_enabled', label: '启用短信服务', type: 'switch', default: false, help: '开启后支持短信验证码和短信通知' }, + { + key: 'sms_provider', + label: '短信供应商', + type: 'select', + default: 'aliyun', + options: [ + { label: '阿里云短信', value: 'aliyun' }, + { label: '腾讯云短信', value: 'tencent' }, + { label: '华为云短信', value: 'huawei' }, + { label: '自定义接口', value: 'custom' }, + ], + dependsOn: { key: 'sms_enabled', value: true }, + }, + { key: 'sms_number_limit_ch', label: '仅发国内', type: 'switch', default: true, dependsOn: { key: 'sms_enabled', value: true }, help: '开启后仅支持发送国内短信,关闭后可发送港澳台及国际短信' }, + { key: 'sms_access_key', label: 'AccessKey / SecretId / AppKey', type: 'input', default: '', dependsOn: { key: 'sms_enabled', value: true }, help: '阿里云填 AccessKey,腾讯云填 SecretId,华为云填 AppKey' }, + { key: 'sms_access_secret', label: 'AccessSecret / SecretKey / AppSecret', type: 'input', inputType: 'password', default: '', dependsOn: { key: 'sms_enabled', value: true } }, + ], + }, + { + key: 'aliyun', + title: '阿里云配置', + icon: 'fas fa-cloud', + fields: [ + { key: 'sms_ali_api_type', label: '接口类型', type: 'radio', default: 'sms', options: [{ label: '短信服务', value: 'sms' }, { label: '号码认证', value: 'verify' }], dependsOn: { key: 'sms_provider', value: 'aliyun' } }, + { key: 'sms_region_id', label: 'RegionId', type: 'input', default: 'cn-hangzhou', dependsOn: { key: 'sms_provider', value: 'aliyun' } }, + ], + }, + { + key: 'tencent', + title: '腾讯云配置', + icon: 'fas fa-cloud', + fields: [ + { key: 'sms_tencent_app_id', label: 'SdkAppId', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'tencent' }, help: '腾讯云短信应用 ID' }, + { key: 'sms_tencent_app_key', label: 'AppKey', type: 'input', inputType: 'password', default: '', dependsOn: { key: 'sms_provider', value: 'tencent' } }, + { key: 'sms_tencent_secret_id', label: 'SecretId', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'tencent' } }, + { key: 'sms_tencent_secret_key', label: 'SecretKey', type: 'input', inputType: 'password', default: '', dependsOn: { key: 'sms_provider', value: 'tencent' } }, + { key: 'sms_tencent_region', label: '地域', type: 'input', default: 'ap-guangzhou', dependsOn: { key: 'sms_provider', value: 'tencent' } }, + ], + }, + { + key: 'huawei', + title: '华为云配置', + icon: 'fas fa-cloud', + fields: [ + { key: 'sms_huawei_sender', label: '通道号 (sender)', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'huawei' } }, + { key: 'sms_huawei_signature', label: '短信签名', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'huawei' }, help: '华为云短信签名内容' }, + { key: 'sms_huawei_endpoint', label: 'API Endpoint', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'huawei' }, help: '留空使用默认 endpoint' }, + ], + }, + { + key: 'custom', + title: '自定义接口配置', + icon: 'fas fa-plug', + fields: [ + { key: 'sms_custom_url', label: '请求 URL', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'custom' }, help: '支持 {phone} {code} {time} 变量' }, + { key: 'sms_custom_method', label: '请求方法', type: 'select', default: 'POST', options: [{ label: 'POST', value: 'POST' }, { label: 'GET', value: 'GET' }, { label: 'PUT', value: 'PUT' }], dependsOn: { key: 'sms_provider', value: 'custom' } }, + { key: 'sms_custom_auth_type', label: '鉴权方式', type: 'select', default: 'header', options: [{ label: 'Header', value: 'header' }], dependsOn: { key: 'sms_provider', value: 'custom' } }, + { key: 'sms_custom_auth_key', label: '鉴权 Header 名', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'custom' } }, + { key: 'sms_custom_auth_value', label: '鉴权 Header 值', type: 'input', inputType: 'password', default: '', dependsOn: { key: 'sms_provider', value: 'custom' }, help: '支持 {phone} {code} {time} 变量' }, + { key: 'sms_custom_headers', label: '自定义 Headers', type: 'textarea', default: '', dependsOn: { key: 'sms_provider', value: 'custom' }, help: '每行一个,格式:Key: Value' }, + { key: 'sms_custom_body', label: '请求 Body', type: 'textarea', default: '', dependsOn: { key: 'sms_provider', value: 'custom' }, help: 'JSON 格式,支持 {phone} {code} {time} 变量' }, + { key: 'sms_custom_success_rule', label: '成功判定规则', type: 'input', default: '', dependsOn: { key: 'sms_provider', value: 'custom' }, help: '格式:status:200 或 json:code=0 或 contains:success' }, + ], + }, + { + key: 'template', + title: '短信模板', + icon: 'fas fa-sms', + fields: [ + { key: 'sms_sign_name', label: '短信签名', type: 'input', default: '', dependsOn: { key: 'sms_enabled', value: true }, help: '在短信服务商申请的短信签名(所有供应商共用此字段)' }, + { key: 'sms_template_code', label: '验证码模板 ID', type: 'input', default: '', dependsOn: { key: 'sms_enabled', value: true }, help: '所有供应商共用此模板 ID' }, + { key: 'sms_code_length', label: '验证码长度', type: 'number', default: 6, min: 4, max: 8, dependsOn: { key: 'sms_enabled', value: true } }, + { key: 'sms_code_expire', label: '验证码有效期 (秒)', type: 'number', default: 300, min: 60, max: 600, dependsOn: { key: 'sms_enabled', value: true } }, + ], + }, + { + key: 'rate_limit', + title: '限流策略', + icon: 'fas fa-shield-alt', + fields: [ + { key: 'sms_rate_limit', label: '发送间隔 (秒)', type: 'number', default: 60, min: 10, max: 300, dependsOn: { key: 'sms_enabled', value: true }, help: '同一手机号两次发送的最小间隔' }, + { key: 'sms_daily_limit', label: '每日发送上限', type: 'number', default: 10, min: 1, max: 100, dependsOn: { key: 'sms_enabled', value: true }, help: '同一手机号每天最多发送条数' }, + ], + }, + { + key: 'triggers', + title: '通知场景', + icon: 'fas fa-bell', + fields: [ + { key: 'sms_notify_bind', label: '绑定手机时验证', type: 'switch', default: true, dependsOn: { key: 'sms_enabled', value: true } }, + { key: 'sms_notify_login', label: '短信登录', type: 'switch', default: false, dependsOn: { key: 'sms_enabled', value: true } }, + { key: 'sms_notify_register', label: '注册验证', type: 'switch', default: true, dependsOn: { key: 'sms_enabled', value: true } }, + { key: 'sms_notify_reset_password', label: '找回密码', type: 'switch', default: true, dependsOn: { key: 'sms_enabled', value: true } }, + ], + }, + ], +} \ No newline at end of file diff --git a/assets/admin/src/schema/sections/users/verify.js b/assets/admin/src/schema/sections/users/verify.js new file mode 100644 index 00000000..f6e2a5bc --- /dev/null +++ b/assets/admin/src/schema/sections/users/verify.js @@ -0,0 +1,34 @@ +export const verifySchema = { + key: 'users.verify', + title: '身份认证', + icon: 'fas fa-id-card', + description: '配置公开认证徽标、审核策略,以及是否复用现有邮箱验证状态。', + defaults: { + user_verify_enabled: true, + user_verify_public_display: true, + user_verify_badge_text: '已认证', + user_verify_accept_email_verified: true, + user_verify_require_manual_review: false, + }, + groups: [ + { + key: 'display', + title: '公开展示', + icon: 'fas fa-eye', + fields: [ + { key: 'user_verify_enabled', label: '启用身份认证结构', type: 'switch', default: true }, + { key: 'user_verify_public_display', label: '公开页显示认证徽标', type: 'switch', default: true, dependsOn: { key: 'user_verify_enabled', value: true } }, + { key: 'user_verify_badge_text', label: '认证徽标文案', type: 'input', default: '已认证', dependsOn: { key: 'user_verify_public_display', value: true } }, + ], + }, + { + key: 'workflow', + title: '审核流程', + icon: 'fas fa-tasks', + fields: [ + { key: 'user_verify_accept_email_verified', label: '邮箱验证通过视为已认证', type: 'switch', default: true, dependsOn: { key: 'user_verify_enabled', value: true } }, + { key: 'user_verify_require_manual_review', label: '保留人工审核位', type: 'switch', default: false, dependsOn: { key: 'user_verify_enabled', value: true } }, + ], + }, + ], +} diff --git a/assets/admin/src/schema/sections/users/wechat.js b/assets/admin/src/schema/sections/users/wechat.js new file mode 100644 index 00000000..2ba16fa5 --- /dev/null +++ b/assets/admin/src/schema/sections/users/wechat.js @@ -0,0 +1,51 @@ +export const wechatSchema = { + key: 'users.wechat', + title: '微信通知', + icon: 'fab fa-weixin', + description: '配置微信模板消息、订阅/解绑和站外通知策略。', + defaults: { + wechat_notify_enabled: false, + wechat_app_id: '', + wechat_app_secret: '', + wechat_template_id: '', + wechat_token: '', + wechat_encoding_aes_key: '', + wechat_notify_comment: true, + wechat_notify_reply: true, + wechat_notify_system: true, + wechat_bind_entry: true, + }, + groups: [ + { + key: 'account', + title: '公众号配置', + icon: 'fab fa-weixin', + fields: [ + { key: 'wechat_notify_enabled', label: '启用微信通知', type: 'switch', default: false, help: '开启后用户可绑定微信接收通知' }, + { key: 'wechat_app_id', label: 'AppID', type: 'input', default: '', dependsOn: { key: 'wechat_notify_enabled', value: true } }, + { key: 'wechat_app_secret', label: 'AppSecret', type: 'input', default: '', dependsOn: { key: 'wechat_notify_enabled', value: true } }, + { key: 'wechat_template_id', label: '模板消息 ID', type: 'input', default: '', dependsOn: { key: 'wechat_notify_enabled', value: true }, help: '在微信公众平台申请的模板消息 ID' }, + ], + }, + { + key: 'verify', + title: '服务器验证', + icon: 'fas fa-check-circle', + fields: [ + { key: 'wechat_token', label: 'Token', type: 'input', default: '', dependsOn: { key: 'wechat_notify_enabled', value: true }, help: '微信公众平台服务器配置中的 Token' }, + { key: 'wechat_encoding_aes_key', label: 'EncodingAESKey', type: 'input', default: '', dependsOn: { key: 'wechat_notify_enabled', value: true } }, + ], + }, + { + key: 'triggers', + title: '通知触发器', + icon: 'fas fa-bell', + fields: [ + { key: 'wechat_notify_comment', label: '新评论通知', type: 'switch', default: true, dependsOn: { key: 'wechat_notify_enabled', value: true } }, + { key: 'wechat_notify_reply', label: '评论回复通知', type: 'switch', default: true, dependsOn: { key: 'wechat_notify_enabled', value: true } }, + { key: 'wechat_notify_system', label: '系统通知', type: 'switch', default: true, dependsOn: { key: 'wechat_notify_enabled', value: true } }, + { key: 'wechat_bind_entry', label: '显示绑定入口', type: 'switch', default: true, dependsOn: { key: 'wechat_notify_enabled', value: true }, help: '在用户中心显示微信绑定/解绑按钮' }, + ], + }, + ], +} diff --git a/assets/admin/src/stores/admin-ui.js b/assets/admin/src/stores/admin-ui.js new file mode 100644 index 00000000..d36c266c --- /dev/null +++ b/assets/admin/src/stores/admin-ui.js @@ -0,0 +1,40 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' + +/** + * useAdminUiStore — global UI state for the admin panel. + * + * Tracks sidebar collapse, search, and breadcrumb state. + */ +export const useAdminUiStore = defineStore('admin-ui', () => { + /** Sidebar collapsed state */ + const sidebarCollapsed = ref(false) + + /** Current page search query (for inline page search) */ + const pageSearch = ref('') + + /** Global notification count (for badge display) */ + const notificationCount = ref(0) + + /** Whether any form on the current page has unsaved changes */ + const hasUnsavedChanges = ref(false) + + /** Toggle sidebar */ + function toggleSidebar() { + sidebarCollapsed.value = !sidebarCollapsed.value + } + + /** Clear page search */ + function clearSearch() { + pageSearch.value = '' + } + + return { + sidebarCollapsed, + pageSearch, + notificationCount, + hasUnsavedChanges, + toggleSidebar, + clearSearch, + } +}) diff --git a/assets/admin/src/style-target.js b/assets/admin/src/style-target.js new file mode 100644 index 00000000..5c6d8682 --- /dev/null +++ b/assets/admin/src/style-target.js @@ -0,0 +1,7 @@ +/** + * 共享的 NaiveUI 样式注入目标 + * 必须在 app.mount() 之前设置,确保组件渲染时目标已就绪 + */ +import { ref } from 'vue' + +export const styleMountTarget = ref(null) diff --git a/assets/admin/src/style.css b/assets/admin/src/style.css new file mode 100644 index 00000000..6efddd01 --- /dev/null +++ b/assets/admin/src/style.css @@ -0,0 +1,384 @@ +#aksrt-admin-app, +#aksrt-admin-app * { + box-sizing: border-box; +} + +#aksrt-admin-app { + min-height: 100%; + color: #333; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", sans-serif; + font-size: 14px; + line-height: 1.6; +} + +/* ==================== FA5 Icon in NaiveUI Menu ==================== */ +#aksrt-admin-app .n-menu .n-menu-item-content i[class*="fa-"], +#aksrt-admin-app .n-menu-item-content i[class*="fa-"] { + font-size: 18px; + width: 18px; + text-align: center; + display: inline-flex; + align-items: center; + justify-content: center; + color: inherit; + opacity: 0.75; + transition: opacity 0.15s; +} + +#aksrt-admin-app .n-menu-item-content--selected i[class*="fa-"], +#aksrt-admin-app .n-menu-item-content:hover i[class*="fa-"] { + opacity: 1; +} + +#aksrt-admin-app .n-layout, +#aksrt-admin-app .n-layout-sider { + min-height: 100vh; +} + +#aksrt-admin-app .aksrt-admin-layout { + min-height: 100vh; + background: #f5f7fb; +} + +#aksrt-admin-app .aksrt-page-container, +#aksrt-admin-app .content-page { + max-width: 1100px; + margin: 0 auto; +} + +#aksrt-admin-app .aksrt-page-title { + margin: 0 0 12px; + color: #1a1a2e; + font-size: 22px; + font-weight: 700; + letter-spacing: -0.02em; +} + +#aksrt-admin-app .aksrt-page-desc { + margin: 0 0 24px; + color: #7a7f8a; + font-size: 13px; + line-height: 1.7; +} + +#aksrt-admin-app .aksrt-section-card { + margin-bottom: 18px; + border-radius: 12px !important; + border: 1px solid #eceff5 !important; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04); +} + +#aksrt-admin-app .aksrt-section-card .n-card__content { + padding: 18px 20px 20px !important; +} + +#aksrt-admin-app .aksrt-section-card .n-card-header { + padding: 18px 20px 0 !important; +} + +#aksrt-admin-app .aksrt-section-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 14px; +} + +#aksrt-admin-app .aksrt-section-header h3 { + margin: 0; + color: #1a1a2e; + font-size: 15px; + font-weight: 600; +} + +#aksrt-admin-app .section-icon, +#aksrt-admin-app .header-icon { + font-size: 17px; + opacity: 0.8; + display: inline-flex; + align-items: center; +} + +#aksrt-admin-app .section-desc, +#aksrt-admin-app .aksrt-hint-text { + color: #8a909c; + font-size: 12px; + line-height: 1.6; +} + +#aksrt-admin-app .section-desc { + margin: 0 0 14px; +} + +#aksrt-admin-app .aksrt-form .n-form-item { + margin-bottom: 18px; +} + +#aksrt-admin-app .aksrt-form .n-form-item:last-child { + margin-bottom: 0; +} + +#aksrt-admin-app .aksrt-form .n-form-item-label { + color: #555 !important; + font-size: 13px; + font-weight: 500; +} + +#aksrt-admin-app .aksrt-form .n-input, +#aksrt-admin-app .aksrt-form .n-select .n-base-selection, +#aksrt-admin-app .aksrt-form .n-input-number { + min-height: 38px; + --n-height: 38px !important; +} + +#aksrt-admin-app .aksrt-form .n-input--textarea textarea { + min-height: 84px; +} + +#aksrt-admin-app .aksrt-form .n-switch { + --n-rail-height: 26px !important; + --n-rail-width: 48px !important; +} + +#aksrt-admin-app .aksrt-form-actions { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 12px; + padding: 24px 0 8px; + margin-top: 28px; + border-top: 1px solid #eef1f6; +} + +#aksrt-admin-app .aksrt-form-actions .n-button { + min-width: 120px; + height: 40px; + font-weight: 500; +} + +#aksrt-admin-app .aksrt-tabs .n-tabs-nav { + margin-bottom: 20px !important; +} + +#aksrt-admin-app .aksrt-tabs .n-tabs-tab { + padding: 10px 20px !important; + font-size: 14px !important; + font-weight: 500; +} + +#aksrt-admin-app .n-data-table { + border-radius: 10px; + overflow: hidden; +} + +#aksrt-admin-app .n-data-table .n-data-table-th { + background: #f8fafc; +} + +#aksrt-admin-app .n-empty { + padding: 24px 0; +} + +/* ==================== 卡片 header 行(标题 + 按钮) ==================== */ +#aksrt-admin-app .aksrt-card-header-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; + flex-wrap: wrap; +} + +#aksrt-admin-app .aksrt-card-header-row__left { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +#aksrt-admin-app .aksrt-card-header-row__title { + margin: 0; + font-size: 15px; + font-weight: 600; + color: #1a1a2e; +} + +/* ==================== 过渡 ==================== */ +.fade-slide-enter-active, +.fade-slide-leave-active { + transition: opacity 0.24s ease, transform 0.24s ease; +} + +.fade-slide-enter-from { + opacity: 0; + transform: translateY(12px); +} + +.fade-slide-leave-to { + opacity: 0; + transform: translateY(-8px); +} + +/* ==================== Modal 样式(弹层) ==================== */ +.aksrt-modal-card { + width: 100%; + max-width: 520px; +} + +/* ==================== 移动端适配 ==================== */ +@media (max-width: 768px) { + /* 容器 */ + #aksrt-admin-app .aksrt-page-container, + #aksrt-admin-app .content-page { + max-width: 100%; + padding: 0; + } + + #aksrt-admin-app .aksrt-page-title { + font-size: 18px; + margin-bottom: 8px; + } + + #aksrt-admin-app .aksrt-page-desc { + font-size: 12px; + margin-bottom: 14px; + } + + /* 卡片紧凑 */ + #aksrt-admin-app .aksrt-section-card { + margin-bottom: 12px; + border-radius: 10px !important; + } + + #aksrt-admin-app .aksrt-section-card .n-card__content { + padding: 14px !important; + } + + #aksrt-admin-app .aksrt-section-card .n-card-header { + padding: 14px 14px 0 !important; + } + + #aksrt-admin-app .aksrt-section-header { + margin-bottom: 10px; + } + + /* 表单:label 改为顶部排列(覆盖 left 模式) */ + #aksrt-admin-app .aksrt-form .n-form-item { + margin-bottom: 14px; + } + + #aksrt-admin-app .aksrt-form .n-form-item .n-form-item-label { + text-align: left !important; + padding: 0 0 6px !important; + width: auto !important; + height: auto !important; + line-height: 1.5 !important; + justify-content: flex-start !important; + } + + #aksrt-admin-app .aksrt-form .n-form-item-blank { + width: 100%; + } + + /* 强制行内表单单列 */ + #aksrt-admin-app .aksrt-form .n-form-item .n-form-item-label, + #aksrt-admin-app .aksrt-form .n-form-item-row { + display: block !important; + } + + /* radio button 自动换行 */ + #aksrt-admin-app .aksrt-form .n-radio-group { + width: 100%; + } + + #aksrt-admin-app .aksrt-form .n-radio-group .n-space { + flex-wrap: wrap !important; + gap: 6px 8px !important; + } + + #aksrt-admin-app .aksrt-form .n-radio-button { + flex: 1 1 auto; + min-width: 0; + } + + /* 输入组在窄屏堆叠 */ + #aksrt-admin-app .aksrt-form .n-input-group { + flex-wrap: wrap; + gap: 6px; + } + + #aksrt-admin-app .aksrt-form .n-input-group > .n-input, + #aksrt-admin-app .aksrt-form .n-input-group > .n-button { + border-radius: 6px !important; + } + + /* 表单底部按钮区 */ + #aksrt-admin-app .aksrt-form-actions { + flex-direction: column-reverse; + align-items: stretch; + padding: 16px 0 8px; + margin-top: 20px; + gap: 8px; + } + + #aksrt-admin-app .aksrt-form-actions .n-button { + width: 100%; + min-width: 0; + height: 42px; + } + + /* 卡片 header 行:换行排列 */ + #aksrt-admin-app .aksrt-card-header-row { + gap: 8px; + } + + /* Naive 弹窗全屏 */ + .n-modal-mask + .n-modal-body-wrapper .n-modal, + .n-modal-body-wrapper > .n-modal { + width: 100vw !important; + max-width: 100vw !important; + border-radius: 0 !important; + margin: 0 !important; + min-height: 100vh; + } + + .aksrt-modal-card { + max-width: 100vw !important; + width: 100vw !important; + min-height: 100vh; + border-radius: 0 !important; + } + + /* 数据表格隐藏次要列 */ + .aksrt-table-mobile-hide, + #aksrt-admin-app .n-data-table .aksrt-table-mobile-hide { + display: none !important; + } + + /* 表格内边距收紧 */ + #aksrt-admin-app .n-data-table .n-data-table-td, + #aksrt-admin-app .n-data-table .n-data-table-th { + padding: 8px 10px !important; + } + + /* tabs */ + #aksrt-admin-app .aksrt-tabs .n-tabs-tab { + padding: 8px 12px !important; + font-size: 13px !important; + } + + /* drawer 内菜单 */ + .n-drawer .n-menu .n-menu-item-content { + padding-left: 18px !important; + } +} + +/* ==================== 超窄设备(<= 480px)追加 ==================== */ +@media (max-width: 480px) { + #aksrt-admin-app .aksrt-section-card .n-card__content { + padding: 12px !important; + } + + #aksrt-admin-app .aksrt-section-card .n-card-header { + padding: 12px 12px 0 !important; + } +} diff --git a/assets/admin/src/utils/home-module-schema.js b/assets/admin/src/utils/home-module-schema.js new file mode 100644 index 00000000..0a2b771f --- /dev/null +++ b/assets/admin/src/utils/home-module-schema.js @@ -0,0 +1,535 @@ +/** + * AKSRT CMS Home Module Schema + * + * 定义每类模块的: + * - 元数据(label / icon / desc) + * - 默认 config + * - 表单字段(用于配置抽屉自动生成 UI) + * + * 字段类型 type: + * - text n-input + * - number n-input-number + * - select n-select (options) + * - radio n-radio-group (options) + * - switch n-switch + * - cats n-select (multiple, 动态加载分类) + * - cat n-select (single, 动态加载分类) + * - textarea n-input type=textarea + */ + +export const MODULE_TYPES = [ + 'hero', + 'banner', + 'category', + 'tabs', + 'ranking', + 'recent', + 'tag_cloud', + 'friend_links', + 'html', + 'hotspot', + 'category_recommend_cards', + 'text_title', + 'button_group', + 'spacing', + 'faq', + 'data_grid', + 'highlight_cards', + 'partner_marquee', + 'product_showcase', + 'user', + 'more', +] + +export const MODULE_LIBRARY = [ + { + type: 'hero', + label: 'Hero 首屏', + icon: 'image', + desc: '大图轮播首屏,支持叠加搜索框和图标卡片', + defaultConfig: { with_search: true, show_cards: false, card_columns: 4, icon_cards: [] }, + fields: [ + { key: 'with_search', type: 'switch', label: '显示搜索覆盖框' }, + { key: 'show_cards', type: 'switch', label: '显示图标卡片覆盖' }, + { key: 'card_columns', type: 'number', label: '卡片列数', min: 2, max: 6 }, + ], + }, + { + type: 'banner', + label: 'Banner 轮播', + icon: 'rectangle', + desc: '正文区轮播条', + defaultConfig: {}, + fields: [], + }, + { + type: 'category', + label: '分类块', + icon: 'folder', + desc: '指定分类的最新内容网格', + defaultConfig: { cat_id: 0, count: 8, layout: 'card', columns: 4, title: '' }, + fields: [ + { key: 'title', type: 'text', label: '标题(留空用分类名)' }, + { key: 'cat_id', type: 'cat', label: '分类' }, + { key: 'count', type: 'number', label: '数量', min: 1, max: 20 }, + { key: 'columns', type: 'number', label: '列数', min: 1, max: 6 }, + { + key: 'layout', type: 'select', label: '布局', + options: [ + { label: '卡片', value: 'card' }, + { label: '列表', value: 'list' }, + { label: '瀑布流', value: 'masonry' }, + { label: '资源网格', value: 'grid-cms' }, + ], + }, + ], + }, + { + type: 'tabs', + label: '分类 Tabs', + icon: 'switcher', + desc: '多分类 Tab 切换', + defaultConfig: { cat_ids: [], count: 8, layout: 'grid-cms', title: '' }, + fields: [ + { key: 'title', type: 'text', label: '标题' }, + { key: 'cat_ids', type: 'cats', label: '分类列表' }, + { key: 'count', type: 'number', label: '每个 Tab 数量', min: 1, max: 20 }, + { + key: 'layout', type: 'select', label: '布局', + options: [ + { label: '卡片', value: 'card' }, + { label: '列表', value: 'list' }, + { label: '瀑布流', value: 'masonry' }, + { label: '资源网格', value: 'grid-cms' }, + ], + }, + ], + }, + { + type: 'ranking', + label: '排行榜', + icon: 'trophy', + desc: '按下载量 / 阅读量 / 评论数排序', + defaultConfig: { metric: 'downloads', count: 6, post_type: 'aksrt_resource', title: '' }, + fields: [ + { key: 'title', type: 'text', label: '标题' }, + { + key: 'metric', type: 'radio', label: '排序依据', + options: [ + { label: '下载量', value: 'downloads' }, + { label: '阅读量', value: 'views' }, + { label: '评论数', value: 'comments' }, + ], + }, + { + key: 'post_type', type: 'select', label: '内容类型', + options: [ + { label: '资源', value: 'aksrt_resource' }, + { label: '文章', value: 'post' }, + ], + }, + { key: 'count', type: 'number', label: '数量', min: 1, max: 20 }, + ], + }, + { + type: 'recent', + label: '最新内容', + icon: 'clock', + desc: '按时间倒序的最新文章 / 资源', + defaultConfig: { post_type: 'post', count: 6, layout: 'card', title: '' }, + fields: [ + { key: 'title', type: 'text', label: '标题' }, + { + key: 'post_type', type: 'select', label: '内容类型', + options: [ + { label: '文章', value: 'post' }, + { label: '资源', value: 'aksrt_resource' }, + ], + }, + { key: 'count', type: 'number', label: '数量', min: 1, max: 20 }, + { + key: 'layout', type: 'select', label: '布局', + options: [ + { label: '卡片', value: 'card' }, + { label: '列表', value: 'list' }, + { label: '瀑布流', value: 'masonry' }, + { label: '资源网格', value: 'grid-cms' }, + ], + }, + ], + }, + { + type: 'tag_cloud', + label: '标签云', + icon: 'tag', + desc: '按使用频率展示热门标签', + defaultConfig: { limit: 30, title: '' }, + fields: [ + { key: 'title', type: 'text', label: '标题' }, + { key: 'limit', type: 'number', label: '数量', min: 5, max: 100 }, + ], + }, + { + type: 'friend_links', + label: '友情链接', + icon: 'link', + desc: '展示已配置的友链', + defaultConfig: { category: '', title: '' }, + fields: [ + { key: 'title', type: 'text', label: '标题' }, + { key: 'category', type: 'text', label: '分类筛选(可空)' }, + ], + }, + { + type: 'html', + label: '自定义 HTML', + icon: 'code', + desc: '自由插入 HTML 片段(受 wp_kses_post 清洗)', + defaultConfig: { html: '', title: '' }, + fields: [ + { key: 'title', type: 'text', label: '标题(可空)' }, + { key: 'html', type: 'textarea', label: 'HTML 内容', rows: 6 }, + ], + }, + { + type: 'hotspot', + label: '热点推荐', + icon: 'fire', + desc: '文章推荐列表(纯文本,仅桌面端显示)', + defaultConfig: { count: 6, post_type: 'post', source: 'auto', title: '' }, + fields: [ + { key: 'title', type: 'text', label: '标题' }, + { key: 'count', type: 'number', label: '数量', min: 3, max: 20 }, + { + key: 'source', type: 'select', label: '数据来源', + options: [ + { label: '自动推荐(推荐文章优先)', value: 'auto' }, + { label: '最新文章', value: 'recent' }, + { label: '最热文章(按阅读量)', value: 'views' }, + ], + }, + { + key: 'post_type', type: 'select', label: '内容类型', + options: [ + { label: '文章', value: 'post' }, + { label: '资源', value: 'aksrt_resource' }, + ], + }, + ], + }, + { + type: 'category_recommend_cards', + label: '分类推荐卡片', + icon: 'grid', + desc: '4 张分类卡片 2×2 布局,含封面图、浏览量胶囊和文章列表', + defaultConfig: { + title: '分类推荐', + cat_ids: [], + show_views: true, + show_dates: true, + }, + fields: [ + { key: 'title', type: 'text', label: '标题' }, + { key: 'cat_ids', type: 'cats', label: '分类(按选择顺序对应卡片 1-4)' }, + { key: 'show_views', type: 'switch', label: '显示浏览量胶囊' }, + { key: 'show_dates', type: 'switch', label: '显示发布时间' }, + ], + }, + { + type: 'text_title', + label: '标题文案', + icon: 'font-colors', + desc: '带标题、副标题和可选按钮的文案区块', + defaultConfig: { + title: '', + desc: '', + align: 'left', + style: 'default', + button_text: '', + button_url: '', + button_style: 'primary', + }, + fields: [ + { key: 'title', type: 'text', label: '标题' }, + { key: 'desc', type: 'textarea', label: '副标题/描述', rows: 2 }, + { key: 'align', type: 'radio', label: '对齐', options: [ + { label: '左对齐', value: 'left' }, + { label: '居中', value: 'center' }, + { label: '右对齐', value: 'right' }, + ]}, + { key: 'style', type: 'select', label: '样式', options: [ + { label: '默认', value: 'default' }, + { label: '带左边框', value: 'bordered' }, + { label: '带背景', value: 'bg' }, + ]}, + { key: 'button_text', type: 'text', label: '按钮文字(可空)' }, + { key: 'button_url', type: 'text', label: '按钮链接' }, + { key: 'button_style', type: 'select', label: '按钮样式', options: [ + { label: '主色', value: 'primary' }, + { label: '轮廓', value: 'outline' }, + { label: '文字', value: 'text' }, + ]}, + ], + }, + { + type: 'button_group', + label: '按钮组', + icon: 'th-large', + desc: '多按钮横向排列,适合快捷入口', + defaultConfig: { + title: '', + buttons: [], + columns: 4, + style: 'card', + }, + fields: [ + { key: 'title', type: 'text', label: '标题(可空)' }, + { key: 'columns', type: 'number', label: '每行列数', min: 2, max: 6 }, + { key: 'style', type: 'select', label: '按钮样式', options: [ + { label: '卡片', value: 'card' }, + { label: '图标', value: 'icon' }, + { label: '文字', value: 'text' }, + ]}, + ], + }, + { + type: 'spacing', + label: '间距分隔', + icon: 'swap', + desc: '模块间间距或分隔线', + defaultConfig: { + height: 40, + divider: false, + divider_style: 'solid', + }, + fields: [ + { key: 'height', type: 'number', label: '高度 (px)', min: 0, max: 200 }, + { key: 'divider', type: 'switch', label: '显示分隔线' }, + { key: 'divider_style', type: 'select', label: '分隔线样式', options: [ + { label: '实线', value: 'solid' }, + { label: '虚线', value: 'dashed' }, + { label: '点线', value: 'dotted' }, + ]}, + ], + }, + { + type: 'faq', + label: '常见问题', + icon: 'question-circle', + desc: '折叠式 FAQ 列表', + defaultConfig: { + title: '常见问题', + items: [], + style: 'accordion', + }, + fields: [ + { key: 'title', type: 'text', label: '标题' }, + { key: 'style', type: 'radio', label: '样式', options: [ + { label: '手风琴', value: 'accordion' }, + { label: '卡片', value: 'card' }, + ]}, + ], + }, + { + type: 'data_grid', + label: '数据网格', + icon: 'table', + desc: '多列数据卡片展示(统计、特征等)', + defaultConfig: { + title: '', + columns: 4, + items: [], + style: 'number', + }, + fields: [ + { key: 'title', type: 'text', label: '标题(可空)' }, + { key: 'columns', type: 'number', label: '列数', min: 2, max: 6 }, + { key: 'style', type: 'select', label: '样式', options: [ + { label: '数字突出', value: 'number' }, + { label: '图标突出', value: 'icon' }, + { label: '简洁', value: 'minimal' }, + ]}, + ], + }, + { + type: 'highlight_cards', + label: '高亮卡片', + icon: 'star', + desc: '带配色的特色卡片网格', + defaultConfig: { + title: '', + columns: 3, + cards: [], + }, + fields: [ + { key: 'title', type: 'text', label: '标题(可空)' }, + { key: 'columns', type: 'number', label: '列数', min: 2, max: 4 }, + ], + }, + { + type: 'partner_marquee', + label: '合作伙伴', + icon: 'handshake', + desc: 'Logo 滚动展示(合作伙伴/品牌墙)', + defaultConfig: { + title: '合作伙伴', + logos: [], + speed: 30, + pause_on_hover: true, + columns: 5, + }, + fields: [ + { key: 'title', type: 'text', label: '标题' }, + { key: 'speed', type: 'number', label: '滚动速度', min: 10, max: 100 }, + { key: 'pause_on_hover', type: 'switch', label: '鼠标悬停暂停' }, + { key: 'columns', type: 'number', label: '可见列数', min: 3, max: 8 }, + ], + }, + { + type: 'product_showcase', + label: '产品展示', + icon: 'shopping-bag', + desc: '产品/商品卡片网格(为商城 Phase 预留)', + defaultConfig: { + title: '产品展示', + columns: 3, + count: 6, + source: 'manual', + show_price: true, + show_button: true, + }, + fields: [ + { key: 'title', type: 'text', label: '标题' }, + { key: 'columns', type: 'number', label: '列数', min: 2, max: 4 }, + { key: 'count', type: 'number', label: '数量', min: 1, max: 12 }, + { key: 'source', type: 'select', label: '数据来源', options: [ + { label: '手动配置', value: 'manual' }, + { label: '最新商品', value: 'latest' }, + { label: '热门商品', value: 'popular' }, + ]}, + { key: 'show_price', type: 'switch', label: '显示价格' }, + { key: 'show_button', type: 'switch', label: '显示购买按钮' }, + ], + }, + { + type: 'user', + label: '用户卡片', + icon: 'user', + desc: '推荐用户卡片(头像、简介、关注按钮)', + defaultConfig: { + title: '推荐用户', + user_ids: [], + count: 4, + columns: 4, + order: 'recent', + show_stats: true, + }, + fields: [ + { key: 'title', type: 'text', label: '标题' }, + { key: 'count', type: 'number', label: '用户数量', min: 1, max: 12 }, + { key: 'columns', type: 'number', label: '列数', min: 2, max: 6 }, + { key: 'order', type: 'select', label: '排序', options: [ + { label: '最近注册', value: 'recent' }, + { label: '文章最多', value: 'posts' }, + { label: '手动指定', value: 'manual' }, + ]}, + { key: 'show_stats', type: 'switch', label: '显示统计数据' }, + ], + }, + { + type: 'more', + label: '发现更多', + icon: 'th', + desc: '分类卡片网格(封面、文章数、最近文章)', + defaultConfig: { + title: '发现更多', + cat_ids: [], + count: 6, + columns: 3, + show_post_count: true, + show_latest_posts: true, + latest_count: 5, + }, + fields: [ + { key: 'title', type: 'text', label: '标题' }, + { key: 'count', type: 'number', label: '分类数量', min: 1, max: 12 }, + { key: 'columns', type: 'number', label: '列数', min: 2, max: 4 }, + { key: 'show_post_count', type: 'switch', label: '显示文章数' }, + { key: 'show_latest_posts', type: 'switch', label: '显示最近文章' }, + { key: 'latest_count', type: 'number', label: '最近文章数', min: 1, max: 10 }, + ], + }, +] + +export function getModuleMeta(type) { + return MODULE_LIBRARY.find(m => m.type === type) || null +} + +export function makeDefaultModule(type) { + const meta = getModuleMeta(type) + if (!meta) return null + return { + type, + config: JSON.parse(JSON.stringify(meta.defaultConfig || {})), + } +} + +export const PRESET_TEMPLATES = [ + { + key: 'resource_standard', + label: '资源站标准', + desc: 'Hero + Tabs + 最新资源 + 下载排行', + modules: [ + { type: 'hero', config: {} }, + { type: 'tabs', config: { count: 8, layout: 'grid-cms' } }, + { type: 'recent', config: { post_type: 'aksrt_resource', count: 8, layout: 'grid-cms' } }, + { type: 'ranking', config: { metric: 'downloads', post_type: 'aksrt_resource', count: 6 } }, + ], + }, + { + key: 'news', + label: '资讯站', + desc: 'Banner + 多个分类块 + 标签云', + modules: [ + { type: 'banner', config: {} }, + { type: 'category', config: { count: 8, columns: 4, layout: 'card' } }, + { type: 'category', config: { count: 8, columns: 4, layout: 'card' } }, + { type: 'tag_cloud', config: { limit: 30 } }, + ], + }, + { + key: 'mixed', + label: '混合站', + desc: 'Hero + 最新文章 + 多个分类块', + modules: [ + { type: 'hero', config: {} }, + { type: 'recent', config: { post_type: 'post', count: 6, layout: 'card' } }, + { type: 'category', config: { count: 6, columns: 3, layout: 'card' } }, + { type: 'category', config: { count: 6, columns: 3, layout: 'card' } }, + ], + }, + { + key: 'landing', + label: '落地页', + desc: 'Hero + 标题 + 高亮卡片 + FAQ + 按钮组', + modules: [ + { type: 'hero', config: {} }, + { type: 'text_title', config: { title: '为什么选择我们', align: 'center', style: 'default' } }, + { type: 'highlight_cards', config: { columns: 3 } }, + { type: 'spacing', config: { height: 40 } }, + { type: 'faq', config: { title: '常见问题' } }, + { type: 'button_group', config: { columns: 3, style: 'card' } }, + ], + }, + { + key: 'corporate', + label: '企业站', + desc: 'Hero + 数据网格 + 产品展示 + 合作伙伴', + modules: [ + { type: 'hero', config: {} }, + { type: 'data_grid', config: { title: '核心数据', columns: 4, style: 'number' } }, + { type: 'product_showcase', config: { title: '产品展示', columns: 3 } }, + { type: 'partner_marquee', config: { title: '合作伙伴', speed: 30 } }, + ], + }, +] diff --git a/assets/admin/src/utils/media.js b/assets/admin/src/utils/media.js new file mode 100644 index 00000000..1e779986 --- /dev/null +++ b/assets/admin/src/utils/media.js @@ -0,0 +1,40 @@ +export function openMediaPicker(options = {}) { + const wpMedia = window.wp?.media + + if (!wpMedia) { + throw new Error('当前后台未加载 WordPress 媒体库。') + } + + const frame = wpMedia({ + title: options.title || '选择图片', + button: { + text: options.buttonText || '使用此图片', + }, + library: { + type: options.type || 'image', + }, + multiple: false, + }) + + return new Promise((resolve, reject) => { + frame.on('select', () => { + const attachment = frame.state().get('selection').first()?.toJSON() + + if (!attachment?.url) { + reject(new Error('未选择有效的媒体资源。')) + return + } + + resolve(attachment) + }) + + frame.on('close', () => { + const hasSelection = frame.state().get('selection').length > 0 + if (!hasSelection) { + resolve(null) + } + }) + + frame.open() + }) +} diff --git a/assets/admin/tsconfig.json b/assets/admin/tsconfig.json new file mode 100644 index 00000000..3d23e6b2 --- /dev/null +++ b/assets/admin/tsconfig.json @@ -0,0 +1 @@ +{"compilerOptions": {"target": "ES2020", "module": "ESNext", "moduleResolution": "bundler", "jsx": "preserve", "strict": true, "noEmit": true, "baseUrl": ".", "paths": {"@/*": ["src/*"]}}, "include": ["src/**/*.ts", "src/**/*.vue"]} \ No newline at end of file diff --git a/assets/admin/vite.config.ts b/assets/admin/vite.config.ts new file mode 100644 index 00000000..95871cfe --- /dev/null +++ b/assets/admin/vite.config.ts @@ -0,0 +1,75 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { resolve } from 'path' + +export default defineConfig({ + plugins: [vue()], + root: __dirname, + base: '', + build: { + outDir: 'build', + emptyOutDir: true, + minify: 'esbuild', + // 注意:不 drop console/debugger,保留错误信息便于排查 + esbuild: { + // drop: ['console', 'debugger'], ← 生产环境可开启 + }, + rollupOptions: { + output: { + // 入口文件带 hash,避免与动态导入的 chunk 产生命名/导出冲突 + entryFileNames: (chunkInfo) => { + // 入口 chunk 统一命名为 index.js,确保 PHP 端稳定引用 + return 'index.js' + }, + chunkFileNames: (chunkInfo) => { + // vendor chunk 用稳定前缀+hash;其他 chunk(含被提取的 index 共享模块)也用固定名 + if (chunkInfo.name.includes('node_modules')) return 'vendor-[hash].js' + // 防止 Rollup 提取的共享 chunk 名为 "index" 时与入口冲突 + if (chunkInfo.name === 'index') return 'shared-api.[hash].js' + return '[name].[hash].js' + }, + assetFileNames: (assetInfo) => { + // CSS 和其他资源保持固定名 + if (assetInfo.name && assetInfo.name.endsWith('.css')) return '[name][extname]' + return '[name].[hash][extname]' + }, + format: 'es', + // 手动拆分:将框架依赖独立为 vendor chunk + // Naive UI 单包 1.3MB+,按内部子目录粗拆,提升首屏并行下载速度。 + manualChunks(id) { + if (!id.includes('node_modules')) return undefined + if (id.includes('node_modules/vue/') + || id.includes('node_modules/@vue/') + || id.includes('node_modules/vue-router/') + || id.includes('node_modules/pinia/')) { + return 'vendor-vue' + } + if (id.includes('node_modules/@vicons/')) { + return 'vendor-icons' + } + if (id.includes('node_modules/vuedraggable/') || id.includes('node_modules/sortablejs/')) { + return 'vendor-draggable' + } + // Naive UI:内部子目录之间存在大量交叉依赖(form ⇄ core 等), + // 按子目录拆分会产生 ESM 循环依赖,导致 "Cannot access 'X' before initialization"。 + // 因此整体打成单个 chunk,避免 TDZ 运行时错误。 + if (id.includes('node_modules/naive-ui/')) { + return 'vendor-naive' + } + // 其它依赖归为通用 vendor + return 'vendor-misc' + }, + }, + }, + cssCodeSplit: true, + // Naive UI 整体打成单个 vendor-naive chunk(避免子目录间循环依赖导致的 TDZ 错误)。 + // 组件库本身约 1.16MB(gzip 后 ~304KB),属于该 UI 库的合理体积, + // 不再尝试进一步拆分,故将告警阈值上调以避免误导。 + chunkSizeWarningLimit: 1500, + }, + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + }, + }, +}) diff --git a/author.php b/author.php new file mode 100644 index 00000000..fda9ff30 --- /dev/null +++ b/author.php @@ -0,0 +1,118 @@ +ID; +$dashboard = function_exists('aksrt_get_user_dashboard_view') + ? aksrt_get_user_dashboard_view($user_id, ['viewer_id' => get_current_user_id()]) + : []; + +$user_home_settings = class_exists('AKSRT_Settings') + ? AKSRT_Settings::get_user_home_settings() + : [ + 'tabs' => ['posts', 'comments', 'favorites'], + 'sidebar' => true, + 'cover' => true, + 'follow' => true, + 'posts_per_page' => 10, + ]; + +require_once AKSRT_DIR . '/templates/parts/user-shared-helpers.php'; +?> + +
+
+
+
+ + + + + +
+
+

+ + +

+
+ + +
+ + + +
+ + max_num_pages; + $current_page = max(1, (int) get_query_var('paged')); + if ($total_pages > 1) : + ?> +
+ $total_pages, + 'current' => $current_page, + 'mid_size' => 2, + 'prev_text' => '', + 'next_text' => '', + ]); + ?> +
+ + + +
+
+ +
+

+

display_name)); ?>

+
+ +
+ + +
+ + + + +
+
+
+ + diff --git a/comments.php b/comments.php new file mode 100644 index 00000000..9a08a2a4 --- /dev/null +++ b/comments.php @@ -0,0 +1,206 @@ + 'desc', + 'nested_depth' => 3, + 'require_login' => false, + 'allow_reply' => true, + 'avatar_size' => 48, + 'empty_text' => __('暂无评论,来做第一个评论的人吧~', 'aksrt'), + ]; +$require_login = !empty($comment_settings['require_login']); +$default_comment_order = ($comment_settings['order'] ?? 'desc') === 'asc' ? 'oldest' : 'newest'; +$comment_order = isset($_GET['aksrt_comment_order']) ? sanitize_key((string) wp_unslash($_GET['aksrt_comment_order'])) : $default_comment_order; +$comment_order = in_array($comment_order, ['newest', 'oldest', 'hottest'], true) ? $comment_order : 'newest'; +$reverse_comments = ($comment_order === 'newest'); +$is_hottest = ($comment_order === 'hottest'); + +if ($is_hottest) { + add_filter('comments_clauses', function ($clauses) { + global $wpdb; + $clauses['join'] .= " LEFT JOIN {$wpdb->commentmeta} AS aksrt_cm ON ({$wpdb->comments}.comment_ID = aksrt_cm.comment_id AND aksrt_cm.meta_key = 'aksrt_comment_likes')"; + $clauses['orderby'] = 'CAST(aksrt_cm.meta_value AS UNSIGNED) DESC, ' . $clauses['orderby']; + return $clauses; + }); +} +$author_only = isset($_GET['aksrt_author_only']) && (int) $_GET['aksrt_author_only'] === 1; +$post_author_id = (int) get_the_author_meta('ID'); +$avatar_size = max(24, (int) ($comment_settings['avatar_size'] ?? 48)); +$max_depth = max(1, (int) ($comment_settings['nested_depth'] ?? 3)); +$allow_reply = !empty($comment_settings['allow_reply']); +$empty_text = (string) ($comment_settings['empty_text'] ?? __('暂无评论,来做第一个评论的人吧~', 'aksrt')); +$comment_toolbar = is_array($comment_settings['toolbar'] ?? null) ? $comment_settings['toolbar'] : ['smilie' => true, 'code' => true, 'image' => true]; +$comment_upload_enabled = !empty($comment_settings['toolbar']['upload_img']); +$comment_box_placeholder = (string) ($comment_settings['box_placeholder'] ?? __('说点什么吧...', 'aksrt')); +$comment_submit_text = (string) ($comment_settings['submit_text'] ?? __('提交评论', 'aksrt')); +$comment_title_text = (string) ($comment_settings['title'] ?? __('评论', 'aksrt')); +set_query_var('aksrt_comment_order', $comment_order); +set_query_var('aksrt_comment_settings', $comment_settings); +?> + +get_auth_providers_data(); +} +?> + +
+
+
+ + +
+
+ + + +
+ + + + +
+ + + +
+ +
+ +
+
+
+
+
+ + + +
+ +
+ +
+ + +
+ +
+ + + + + +
+
+ +
+ +
+ +
+
+
+ + + + +
+
+ + + + + + +
+
    +
  • +
  • +
+ 0) : ?> + + +
+ + +
+
    + 'ol', + 'short_ping' => true, + 'avatar_size' => $avatar_size, + 'max_depth' => $max_depth, + 'callback' => 'aksrt_comment_callback', + 'reverse_top_level' => $reverse_comments, + 'reverse_children' => $reverse_comments, + ]); + ?> +
+
+ + + + +
+
+ +
+

+

+
+ + + +

+ +
diff --git a/composer.json b/composer.json new file mode 100644 index 00000000..c77803dd --- /dev/null +++ b/composer.json @@ -0,0 +1,18 @@ +{ + "name": "aksrt/theme", + "description": "AKSRT WordPress Theme - OAuth dependencies", + "type": "wordpress-theme", + "require": { + "php": ">=7.4", + "stripe/stripe-php": "^16.1", + "yurunsoft/pay-sdk": "~2.0", + "yurunsoft/yurun-oauth-login": "~2.0" + }, + "config": { + "allow-plugins": false, + "sort-packages": true, + "audit": { + "block-insecure": false + } + } +} \ No newline at end of file diff --git a/composer.lock b/composer.lock new file mode 100644 index 00000000..f8f7b3c0 --- /dev/null +++ b/composer.lock @@ -0,0 +1,272 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "0ee87e87d94466ca134aa2d9e8bac1c3", + "packages": [ + { + "name": "psr/http-message", + "version": "1.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/cb6ce4845ce34a8ad9e68117c10ee90a29919eba", + "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba", + "shasum": "", + "mirrors": [ + { + "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", + "preferred": true + } + ] + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/1.1" + }, + "time": "2023-04-04T09:50:52+00:00" + }, + { + "name": "stripe/stripe-php", + "version": "v16.1.0", + "source": { + "type": "git", + "url": "https://github.com/stripe/stripe-php.git", + "reference": "47c55a5a8414d1d7584e6bbfb3e8179f3c43d83a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stripe/stripe-php/zipball/47c55a5a8414d1d7584e6bbfb3e8179f3c43d83a", + "reference": "47c55a5a8414d1d7584e6bbfb3e8179f3c43d83a", + "shasum": "", + "mirrors": [ + { + "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", + "preferred": true + } + ] + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "php": ">=5.6.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "3.5.0", + "phpstan/phpstan": "^1.2", + "phpunit/phpunit": "^5.7 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Stripe\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Stripe and contributors", + "homepage": "https://github.com/stripe/stripe-php/contributors" + } + ], + "description": "Stripe PHP Library", + "homepage": "https://stripe.com/", + "keywords": [ + "api", + "payment processing", + "stripe" + ], + "support": { + "issues": "https://github.com/stripe/stripe-php/issues", + "source": "https://github.com/stripe/stripe-php/tree/v16.1.0" + }, + "time": "2024-10-03T22:48:28+00:00" + }, + { + "name": "yurunsoft/pay-sdk", + "version": "v2.2.0", + "source": { + "type": "git", + "url": "https://github.com/Yurunsoft/PaySDK.git", + "reference": "344fde9df7c3ed2e0242759a6de3761519c42507" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Yurunsoft/PaySDK/zipball/344fde9df7c3ed2e0242759a6de3761519c42507", + "reference": "344fde9df7c3ed2e0242759a6de3761519c42507", + "shasum": "", + "mirrors": [ + { + "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", + "preferred": true + } + ] + }, + "require": { + "php": "^5.4|^7.0", + "yurunsoft/yurun-http": "~3.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Yurun\\PaySDK\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHP 集成支付 SDK ,集成了支付宝、微信支付的支付接口和其它相关接口的操作。可以轻松嵌入支持 PHP >= 5.4 的任何系统中。", + "support": { + "issues": "https://github.com/Yurunsoft/PaySDK/issues", + "source": "https://github.com/Yurunsoft/PaySDK/tree/v2.2.0" + }, + "time": "2020-03-13T05:05:47+00:00" + }, + { + "name": "yurunsoft/yurun-http", + "version": "v3.5.3", + "source": { + "type": "git", + "url": "https://github.com/Yurunsoft/YurunHttp.git", + "reference": "665b70c7e95abfa6d9b621abf78ccfd7b32da3e8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Yurunsoft/YurunHttp/zipball/665b70c7e95abfa6d9b621abf78ccfd7b32da3e8", + "reference": "665b70c7e95abfa6d9b621abf78ccfd7b32da3e8", + "shasum": "", + "mirrors": [ + { + "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", + "preferred": true + } + ] + }, + "require": { + "php": ">=5.5.0", + "psr/http-message": "~1.0" + }, + "require-dev": { + "phpunit/phpunit": ">=4 <8", + "swoft/swoole-ide-helper": "~2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Yurun\\Util\\": "./src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "一个基于PHP cURL的开源HTTP类库,支持链式操作,省去繁杂的cURL使用方法。(An open source HTTP class library based on PHP cURL, support chain operations, eliminating the use of miscellaneous cURL methods.)", + "support": { + "issues": "https://github.com/Yurunsoft/YurunHttp/issues", + "source": "https://github.com/Yurunsoft/YurunHttp/tree/v3.5.3" + }, + "time": "2020-02-17T11:11:19+00:00" + }, + { + "name": "yurunsoft/yurun-oauth-login", + "version": "v2.1.0", + "source": { + "type": "git", + "url": "https://github.com/Yurunsoft/YurunOAuthLogin.git", + "reference": "e9c264d4bc1a3d0556d1c5bbef83039992add62d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Yurunsoft/YurunOAuthLogin/zipball/e9c264d4bc1a3d0556d1c5bbef83039992add62d", + "reference": "e9c264d4bc1a3d0556d1c5bbef83039992add62d", + "shasum": "", + "mirrors": [ + { + "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", + "preferred": true + } + ] + }, + "require": { + "php": ">=5.4", + "yurunsoft/yurun-http": "~3.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Yurun\\OAuthLogin\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "YurunOAuthLogin是一个PHP 第三方登录授权 SDK,集成了QQ、微信、微博、Github等常用接口。可以轻松嵌入支持 PHP >= 5.4 的任何系统中。", + "support": { + "issues": "https://github.com/Yurunsoft/YurunOAuthLogin/issues", + "source": "https://github.com/Yurunsoft/YurunOAuthLogin/tree/dev" + }, + "time": "2020-03-13T05:01:41+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=7.4" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/footer.php b/footer.php new file mode 100644 index 00000000..2ae0e109 --- /dev/null +++ b/footer.php @@ -0,0 +1,476 @@ + true, 'icp_number' => '', 'police_enabled' => true, 'police_number' => '']; +$icp_number = $filing['icp_enabled'] ? $filing['icp_number'] : ''; +$police_number = $filing['police_enabled'] ? $filing['police_number'] : ''; +$footer_text = $has_settings ? AKSRT_Settings::footer_text() : ''; + +// 版权文案 +$copyright_text = $has_settings ? AKSRT_Settings::get_copyright_text() : ''; + +// 页脚布局模板 +$footer_layout = $has_settings ? AKSRT_Settings::get_footer_layout_template() : 'default'; + +// §2.3 三列结构化字段 +$footer_brand = $has_settings ? AKSRT_Settings::get_footer_brand_settings() : ['m_s' => ['pc', 'mobile'], 'img' => '', 'img_dark' => '', 'title' => '', 'custom_code' => '']; +$footer_contact_ext = $has_settings ? AKSRT_Settings::get_footer_contact_extended_settings() : ['m_s' => ['pc', 'mobile'], 'wechat_img' => '', 'qq' => '', 'weibo' => '', 'email' => '', 'more' => [], 'code_1' => '', 'code_2' => '']; +$footer_mini = $has_settings ? AKSRT_Settings::get_footer_mini_img_settings() : ['m_s' => ['pc', 'mobile'], 'items' => []]; +$footer_customize_code = $has_settings ? AKSRT_Settings::get_footer_customize_code() : ''; + +// 品牌列设备可见性 +$_brand_show_pc = in_array('pc', $footer_brand['m_s'], true); +$_brand_show_sm = in_array('mobile', $footer_brand['m_s'], true); +$_brand_vis_cls = ''; +if ($_brand_show_pc && !$_brand_show_sm) $_brand_vis_cls = ' hide-on-mobile'; +elseif (!$_brand_show_pc && $_brand_show_sm) $_brand_vis_cls = ' hide-on-desktop'; + +// 联系区设备可见性 +$_contact_show_pc = in_array('pc', $footer_contact_ext['m_s'], true); +$_contact_show_sm = in_array('mobile', $footer_contact_ext['m_s'], true); +$_contact_vis_cls = ''; +if ($_contact_show_pc && !$_contact_show_sm) $_contact_vis_cls = ' hide-on-mobile'; +elseif (!$_contact_show_pc && $_contact_show_sm) $_contact_vis_cls = ' hide-on-desktop'; + +// 二维码组设备可见性 +$_mini_show_pc = in_array('pc', $footer_mini['m_s'], true); +$_mini_show_sm = in_array('mobile', $footer_mini['m_s'], true); +$_mini_vis_cls = ''; +if ($_mini_show_pc && !$_mini_show_sm) $_mini_vis_cls = ' hide-on-mobile'; +elseif (!$_mini_show_pc && $_mini_show_sm) $_mini_vis_cls = ' hide-on-desktop'; + +/** + * 渲染快速链接 URL:支持外链 / 站内相对路径 + */ +$render_link_url = function ($raw) { + if (empty($raw)) return '#'; + if (strpos($raw, 'http') === 0) return esc_url($raw); + return esc_url(home_url($raw)); +}; +?> + + +
+ +
+ + + +
+ +
+ + +
+ + + + + + + + + + +
+ + + + + + + + + + + + +
+ +
+ + + + + + diff --git a/front-page.php b/front-page.php new file mode 100644 index 00000000..f07386cc --- /dev/null +++ b/front-page.php @@ -0,0 +1,43 @@ + + +
+ +
+
+ +
> +
+

+
+
+ +
+
+ +
+ + + + +
+
+ +init(); +AKSRT_Scripts::get_instance()->init(); +AKSRT_Admin::get_instance()->init(); +AKSRT_OAuth::get_instance()->init(); + +// 初始化代理登录(根据配置模式选择性加载) +$aksrt_proxy_mode = AKSRT_Settings::oauth_proxy_mode(); +if ($aksrt_proxy_mode === 'client' && class_exists('AKSRT_OAuth_Proxy_Client')) { + AKSRT_OAuth_Proxy_Client::get_instance()->init(); +} +if ($aksrt_proxy_mode === 'server' && class_exists('AKSRT_OAuth_Proxy_Server')) { + AKSRT_OAuth_Proxy_Server::get_instance()->init(); +} + +AKSRT_Settings::get_instance()->init(); +AKSRT_Auth_Router::get_instance()->init(); +AKSRT_Templates::get_instance()->init(); +AKSRT_Moderation::get_instance()->init(); + +// 短代码统一注册入口(registry) +if (class_exists('AKSRT_Shortcodes')) { + add_action('init', function () { + AKSRT_Shortcodes::get_instance()->register(); + }); +} + +// 用户限制/封禁系统 +if (class_exists('AKSRT_User_Restriction')) { + AKSRT_User_Restriction::get_instance()->init(); +} + +// 用户中心 & 公开主页协调类 +if (class_exists('AKSRT_User_Center')) { + AKSRT_User_Center::get_instance()->init(); +} +if (class_exists('AKSRT_User_Home')) { + AKSRT_User_Home::get_instance()->init(); +} + +// 数据表迁移器:注册 hook(after_switch_theme + admin_init 双触发) +if (class_exists('AKSRT_DB_Migrator')) { + AKSRT_DB_Migrator::init(); +} + +// 积分获取入口(注册赠送 / 评论奖励 / 签到 / 邀请) +if (class_exists('AKSRT_Credits_Earn')) { + AKSRT_Credits_Earn::get_instance()->init(); +} +if (class_exists('AKSRT_Signin')) { + AKSRT_Signin::get_instance()->init(); +} +if (class_exists('AKSRT_Invite')) { + AKSRT_Invite::get_instance()->init(); +} +if (class_exists('AKSRT_Download')) { + AKSRT_Download::get_instance()->init(); +} +// Download State & Docs Presenter are pure service singletons (no hooks) +if (class_exists('AKSRT_Download_State')) { + AKSRT_Download_State::get_instance(); +} +if (class_exists('AKSRT_Docs_Presenter')) { + AKSRT_Docs_Presenter::get_instance(); +} +if (class_exists('AKSRT_Content_Visibility')) { + AKSRT_Content_Visibility::get_instance()->init(); +} +if (class_exists('AKSRT_SMS')) { + AKSRT_SMS::get_instance()->init(); +} +if (class_exists('AKSRT_WeChat_Notify')) { + AKSRT_WeChat_Notify::get_instance()->init(); +} +if (class_exists('AKSRT_Message')) { + AKSRT_Message::get_instance()->init(); +} +if (class_exists('AKSRT_Private_Message')) { + AKSRT_Private_Message::get_instance()->init(); +} +if (class_exists('AKSRT_Report_Center')) { + AKSRT_Report_Center::get_instance()->init(); +} +if (class_exists('AKSRT_Comment_Actions')) { + AKSRT_Comment_Actions::get_instance()->init(); +} +if (class_exists('AKSRT_Go_Redirect')) { + AKSRT_Go_Redirect::get_instance()->init(); +} +if (class_exists('AKSRT_Site_Notice')) { + AKSRT_Site_Notice::get_instance()->init(); +} +if (class_exists('AKSRT_API_Audit')) { + AKSRT_API_Audit::get_instance()->init(); +} +if (class_exists('AKSRT_MeiliSearch')) { + AKSRT_MeiliSearch::get_instance()->init(); + // Async single post index (debounced) + add_action('aksrt_meilisearch_index_single', function (int $post_id) { + AKSRT_MeiliSearch::get_instance()->index_post($post_id); + }); +} +if (class_exists('AKSRT_Search_Push')) { + AKSRT_Search_Push::get_instance()->init(); +} +if (class_exists('AKSRT_SEO_Head')) { + AKSRT_SEO_Head::get_instance()->init(); +} +if (class_exists('AKSRT_Update_Checker')) { + AKSRT_Update_Checker::get_instance()->init(); +} +if (class_exists('AKSRT_Category_Rewrite')) { + AKSRT_Category_Rewrite::get_instance()->init(); +} +if (class_exists('AKSRT_Front_Submit')) { + AKSRT_Front_Submit::get_instance()->init(); +} +if (class_exists('AKSRT_Order')) { + AKSRT_Order::get_instance()->init(); +} + +// Register built-in payment gateways +if (class_exists('AKSRT_Payment_Gateway')) { + $gw = AKSRT_Payment_Gateway::get_instance(); + // 注册支付回调路由(/payment/{gateway}/notify|return|cancel)。 + $gw->init(); + if (class_exists('AKSRT_Balance_Gateway')) { + $gw->register('balance', new AKSRT_Balance_Gateway()); + } + if (class_exists('AKSRT_Alipay_Gateway')) { + $gw->register('alipay', new AKSRT_Alipay_Gateway()); + } + if (class_exists('AKSRT_WechatPay_Gateway')) { + $gw->register('wechatpay', new AKSRT_WechatPay_Gateway()); + } + if (class_exists('AKSRT_PayPal_Gateway')) { + $gw->register('paypal', new AKSRT_PayPal_Gateway()); + } + if (class_exists('AKSRT_Stripe_Gateway')) { + $gw->register('stripe', new AKSRT_Stripe_Gateway()); + } + do_action('aksrt_register_payment_gateways', $gw); +} + +if (class_exists('AKSRT_Forum')) { + AKSRT_Forum::get_instance()->init(); +} +if (is_admin() && class_exists('AKSRT_Resource_Meta_Box')) { + AKSRT_Resource_Meta_Box::get_instance()->init(); +} + +// 分类/标签局部配置(term meta 字段) +if (class_exists('AKSRT_Term_Meta')) { + AKSRT_Term_Meta::get_instance()->init(); +} + +// 商城 taxonomy term meta + shop_discount 优惠活动 +if (class_exists('AKSRT_Shop_Term_Meta')) { + AKSRT_Shop_Term_Meta::get_instance()->init(); +} + +// 商品后台列表自定义列(销量/综合数据/商家/优惠活动) +if (is_admin() && class_exists('AKSRT_Admin_Shop_Controller')) { + AKSRT_Admin_Shop_Controller::get_instance()->init_product_admin_columns(); +} + +// 商品评价/评分系统 +if (class_exists('AKSRT_Product_Review')) { + AKSRT_Product_Review::get_instance()->init(); +} + +// 文章局部配置 meta box(仅后台) +if (is_admin() && class_exists('AKSRT_Post_Meta_Box')) { + AKSRT_Post_Meta_Box::get_instance()->init(); +} + +// 绔欑偣璁块棶閲忕粺璁? +if (class_exists('AKSRT_Stats_Tracker')) { + AKSRT_Stats_Tracker::init(); +} + +// Email rollback window (24h): user list row action + admin-post handler. +if (class_exists('AKSRT_Email_Rollback')) { + AKSRT_Email_Rollback::get_instance()->init(); +} + +// WordPress 原生"用户"菜单:积分管理 +if (class_exists('AKSRT_User_Credits_Admin')) { + AKSRT_User_Credits_Admin::get_instance()->init(); +} + +// ============================================================ +// Homepage data model migration: legacy module tabs -> structured homepage_tabs +// Idempotent; runs once on first admin visit. +// ============================================================ +if (is_admin() && class_exists('AKSRT_Settings')) { + add_action('admin_init', function () { + AKSRT_Settings::maybe_migrate_homepage_tabs(); + }, 5); +} + +// ============================================================ +// 主题删除时的数据库清理(仅在用户启用清理选项时生效) +// uninstall.php 会检查 aksrt_cleanup_on_uninstall 选项 +// ============================================================ +register_deactivation_hook(__FILE__, function () { + // 主题停用时不做任何清理,仅在删除时由 uninstall.php 处理。 + // 自定义表(aksrt_*)在主题切换后保留数据,确保重新启用时不丢失。 +}); diff --git a/go.php b/go.php new file mode 100644 index 00000000..0f352384 --- /dev/null +++ b/go.php @@ -0,0 +1,56 @@ + + +
+
+
+
+ +
+
+
+
+ + true, + 'search_enabled' => true, + 'user_menu' => true, + 'publish_btn' => true, + 'mobile_drawer' => true, + 'nav_style' => 'default', + 'max_width' => 1200, + 'scroll_down_hide' => false, + 'layout' => '2', + 'mobile_layout' => 'center', + 'mobile_align' => 'left', + ]; + +$aksrt_initial_theme_mode = ''; +if (class_exists('AKSRT_Settings') && method_exists('AKSRT_Settings', 'initial_theme_mode')) { + $aksrt_initial_theme_mode = AKSRT_Settings::initial_theme_mode(); +} +$aksrt_has_initial_theme = in_array($aksrt_initial_theme_mode, ['dark', 'light'], true); + +$aksrt_body_classes = ['header-nav-style-' . $aksrt_header_settings['nav_style']]; +if ($aksrt_initial_theme_mode === 'dark') { + $aksrt_body_classes[] = 'theme-dark'; + $aksrt_body_classes[] = 'dark-theme'; +} elseif ($aksrt_initial_theme_mode === 'light') { + $aksrt_body_classes[] = 'white-theme'; +} +if (!$aksrt_header_settings['fixed']) { + $aksrt_body_classes[] = 'has-static-nav'; +} +if (!empty($aksrt_header_settings['scroll_down_hide'])) { + $aksrt_body_classes[] = 'scroll-down-hide'; +} +$aksrt_body_classes[] = 'header-layout-' . $aksrt_header_settings['layout']; +$aksrt_body_classes[] = 'mobile-header-' . $aksrt_header_settings['mobile_layout']; +$aksrt_body_classes[] = 'mobile-nav-' . $aksrt_header_settings['mobile_align']; + +// 全局背景系统 +$aksrt_bg_settings = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_global_background_settings() : []; +$aksrt_bg_styles = []; +if (!empty($aksrt_bg_settings['bg_color'])) { + $aksrt_bg_styles[] = '--aksrt-bg-color:' . sanitize_hex_color($aksrt_bg_settings['bg_color']); +} +if (!empty($aksrt_bg_settings['bg_image'])) { + $aksrt_bg_styles[] = '--aksrt-bg-image:url(' . esc_url($aksrt_bg_settings['bg_image']) . ')'; + $aksrt_bg_styles[] = '--aksrt-bg-size:' . sanitize_key($aksrt_bg_settings['bg_size'] ?: '100%'); + $aksrt_bg_styles[] = '--aksrt-bg-position:' . sanitize_text_field($aksrt_bg_settings['bg_position'] ?: 'center center'); + $aksrt_bg_styles[] = '--aksrt-bg-repeat:' . sanitize_key($aksrt_bg_settings['bg_repeat'] ?: 'no-repeat'); + $aksrt_bg_styles[] = '--aksrt-bg-attachment:' . sanitize_key($aksrt_bg_settings['bg_attachment'] ?: 'fixed'); +} +if (!empty($aksrt_bg_settings['bg_dark_color'])) { + $aksrt_bg_styles[] = '--aksrt-bg-dark-color:' . sanitize_hex_color($aksrt_bg_settings['bg_dark_color']); +} +if (!empty($aksrt_bg_settings['bg_dark_image'])) { + $aksrt_bg_styles[] = '--aksrt-bg-dark-image:url(' . esc_url($aksrt_bg_settings['bg_dark_image']) . ')'; +} +if (!empty($aksrt_bg_settings['bg_overlay'])) { + $aksrt_bg_styles[] = '--aksrt-bg-overlay-color:' . sanitize_text_field($aksrt_bg_settings['bg_overlay_color'] ?: 'rgba(0, 0, 0, 0.3)'); +} +if (!empty($aksrt_bg_settings['page_bg_color'])) { + $aksrt_bg_styles[] = '--aksrt-page-bg-color:' . sanitize_hex_color($aksrt_bg_settings['page_bg_color']); +} +if (!empty($aksrt_bg_settings['page_bg_image'])) { + $aksrt_bg_styles[] = '--aksrt-page-bg-image:url(' . esc_url($aksrt_bg_settings['page_bg_image']) . ')'; +} +if (!empty($aksrt_bg_styles)) { + $aksrt_body_classes[] = 'aksrt-bg-body'; + if (!empty($aksrt_bg_settings['bg_overlay'])) { + $aksrt_body_classes[] = 'aksrt-bg-overlay'; + } +} +?> +> + + + + + +> + + + + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + +get_unread_count(get_current_user_id()) : 0; + $aksrt_user_credit = function_exists('aksrt_user_credits') && is_user_logged_in() ? (int) aksrt_user_credits(get_current_user_id()) : 0; + $aksrt_currency_name = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_currency_name() : __('积分', 'aksrt'); + $aksrt_messages_url = function_exists('aksrt_get_messages_url') ? aksrt_get_messages_url() : home_url('/messages/'); + $aksrt_orders_url = home_url('/orders/'); + $aksrt_publish_url = admin_url('post-new.php'); + $aksrt_theme_toggle_positions = class_exists('AKSRT_Settings') + ? AKSRT_Settings::get_theme_mode_button_positions() + : ['pc_nav', 'm_menu']; + $aksrt_can_publish = !empty($aksrt_header_settings['publish_btn']) && ( + current_user_can('manage_options') + || (class_exists('AKSRT_Front_Submit') && AKSRT_Front_Submit::get_instance()->can_submit()) + ); + $aksrt_nav_classes = [ + 'site-nav', + 'site-nav--' . $aksrt_header_settings['nav_style'], + $aksrt_header_settings['fixed'] ? 'site-nav--fixed' : 'site-nav--static', + ]; + $aksrt_nav_styles = []; + if ((int) $aksrt_header_settings['max_width'] > 0) { + $aksrt_nav_styles[] = '--nav-max-width:' . (int) $aksrt_header_settings['max_width'] . 'px'; + } + $aksrt_logo_parts = []; + if ($aksrt_logo_url !== '') { + $aksrt_logo_parts[] = '' . esc_attr($aksrt_brand_name) . ' 0 ? ' style="width:' . (int) $aksrt_logo_width . 'px"' : '') . '>'; + $aksrt_brand_logo_src = $aksrt_logo_url; + } + if ($aksrt_logo_dark_url !== '') { + $aksrt_logo_parts[] = '' . esc_attr($aksrt_brand_name) . ' 0 ? ' style="width:' . (int) $aksrt_logo_width . 'px"' : '') . '>'; + if ($aksrt_brand_logo_src === '') { + $aksrt_brand_logo_src = $aksrt_logo_dark_url; + } + } + $aksrt_has_brand_logo = !empty($aksrt_logo_parts); + if ($aksrt_has_brand_logo) { + $aksrt_brand_logo_html = implode('', $aksrt_logo_parts); + } + $aksrt_brand_mark = strtoupper(substr(preg_replace('/\s+/', '', wp_strip_all_tags($aksrt_brand_name)), 0, 1)); + if ($aksrt_brand_mark === '') { + $aksrt_brand_mark = 'A'; + } + $aksrt_brand_class = 'site-brand'; + if ($aksrt_has_brand_logo) { + $aksrt_brand_class .= ' site-brand--has-logo'; + } + $aksrt_brand_drawer_class = $aksrt_brand_class . ' site-brand--drawer'; + $aksrt_header_classes = [ + 'header', + 'header-layout-' . $aksrt_header_settings['layout'], + $aksrt_header_settings['fixed'] ? 'nav-fixed' : 'nav-static', + ]; + if (!empty($aksrt_header_settings['scroll_down_hide'])) { + $aksrt_header_classes[] = 'show-slide'; + } +?> +
+ +
+ + + + + + + diff --git a/home.php b/home.php new file mode 100644 index 00000000..011baf24 --- /dev/null +++ b/home.php @@ -0,0 +1,16 @@ + true]; + +if (!empty($system_tools['close_attachment_page'])) { + $attachment_id = get_queried_object_id(); + $parent_id = (int) wp_get_post_parent_id($attachment_id); + $redirect_url = $parent_id > 0 ? get_permalink($parent_id) : home_url('/'); + + wp_safe_redirect($redirect_url, 301); + exit; +} + +get_header(); + +$attachment_id = get_queried_object_id(); +$parent_id = (int) wp_get_post_parent_id($attachment_id); +$image_url = wp_get_attachment_url($attachment_id); +$image_alt = trim((string) get_post_meta($attachment_id, '_wp_attachment_image_alt', true)); +$image_caption = wp_get_attachment_caption($attachment_id); +$image_title = get_the_title($attachment_id); +$parent_title = $parent_id > 0 ? get_the_title($parent_id) : ''; +?> + +
+
+
+
+ + + +
+
+
+
+ + 50000 时使用) +add_action('aksrt_email_verification_backfill_batch', function ($offset = 0) { + if (class_exists('AKSRT_Auth_Service')) { + AKSRT_Auth_Service::run_async_backfill_batch((int) $offset); + } +}, 10, 1); + +// ============================================================ +// 阶段6:设置迁移 — 将旧 option key 映射到新 Schema 结构 +// 仅在后台上下文执行,前台不触发。 +// ============================================================ +add_action('admin_init', function () { + if (class_exists('AKSRT_Settings_Migration')) { + AKSRT_Settings_Migration::get_instance()->maybe_migrate(); + } +}, 5); + diff --git a/inc/classes/admin/class-aksrt-admin-ai-controller.php b/inc/classes/admin/class-aksrt-admin-ai-controller.php new file mode 100644 index 00000000..65cec3dc --- /dev/null +++ b/inc/classes/admin/class-aksrt-admin-ai-controller.php @@ -0,0 +1,201 @@ + 'GET', + 'callback' => [$this, 'get_status'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // AI settings update + register_rest_route(self::API_NAMESPACE, '/ai/settings', [ + 'methods' => 'POST', + 'callback' => [$this, 'update_settings'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // AI SEO suggest + register_rest_route(self::API_NAMESPACE, '/ai/seo-suggest', [ + 'methods' => 'POST', + 'callback' => [$this, 'seo_suggest'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // AI summary suggest + register_rest_route(self::API_NAMESPACE, '/ai/summary-suggest', [ + 'methods' => 'POST', + 'callback' => [$this, 'summary_suggest'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // AI tags suggest + register_rest_route(self::API_NAMESPACE, '/ai/tags-suggest', [ + 'methods' => 'POST', + 'callback' => [$this, 'tags_suggest'], + 'permission_callback' => [$this, 'check_permission'], + ]); + } + + /** + * GET /ai/status — AI 状态 + */ + public function get_status(): WP_REST_Response { + if (!class_exists('AKSRT_AI')) { + return $this->success(['data' => [ + 'enabled' => false, + 'provider' => 'none', + 'configured' => false, + 'available' => false, + ]]); + } + + return $this->success(['data' => AKSRT_AI::get_instance()->get_status()]); + } + + /** + * POST /ai/settings — 更新 AI 设置 + */ + public function update_settings(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_AI')) { + return $this->error('AI 模块未加载', 500); + } + + $params = $this->get_json_params($request); + + $allowed = [ + 'enabled' => 'bool', + 'provider' => 'string', + 'api_key' => 'string', + 'api_base' => 'url', + 'model' => 'string', + 'max_tokens' => 'int', + 'rate_limit' => 'int', + ]; + + $config = []; + foreach ($allowed as $key => $type) { + if (!isset($params[$key])) continue; + switch ($type) { + case 'bool': + $config[$key] = !empty($params[$key]); + break; + case 'int': + $config[$key] = (int) $params[$key]; + break; + case 'url': + $config[$key] = esc_url_raw((string) $params[$key]); + break; + default: + $config[$key] = sanitize_text_field((string) $params[$key]); + } + } + + $success = AKSRT_AI::get_instance()->update_config($config); + + if ($success) { + return $this->success(['message' => __('AI 设置已保存', 'aksrt')]); + } + return $this->error(__('保存失败', 'aksrt')); + } + + /** + * POST /ai/seo-suggest — SEO 建议 + */ + public function seo_suggest(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_AI_SEO')) { + return $this->error('AI SEO 模块未加载', 500); + } + + $params = $this->get_json_params($request); + $title = sanitize_text_field((string) ($params['title'] ?? '')); + $content = wp_kses_post((string) ($params['content'] ?? '')); + + if (empty($title)) { + return $this->error('标题不能为空'); + } + + $result = AKSRT_AI_SEO::get_instance()->suggest($title, $content); + + if ($result['success']) { + return $this->success(['data' => $result['data']]); + } + + return $this->error($result['message'] ?? '生成失败'); + } + + /** + * POST /ai/summary-suggest — 摘要建议 + */ + public function summary_suggest(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_AI') || !class_exists('AKSRT_AI_Prompt')) { + return $this->error('AI 模块未加载', 500); + } + + $params = $this->get_json_params($request); + $title = sanitize_text_field((string) ($params['title'] ?? '')); + $content = wp_kses_post((string) ($params['content'] ?? '')); + $max_len = max(50, min(500, (int) ($params['max_length'] ?? 200))); + + if (empty($content)) { + return $this->error('内容不能为空'); + } + + $prompt = AKSRT_AI_Prompt::get_instance()->build_summary_prompt($content, $max_len); + $result = AKSRT_AI::get_instance()->generate($prompt); + + if ($result['success']) { + return $this->success(['data' => ['summary' => $result['content']]]); + } + + return $this->error($result['message'] ?? '生成失败'); + } + + /** + * POST /ai/tags-suggest — 标签建议 + */ + public function tags_suggest(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_AI') || !class_exists('AKSRT_AI_Prompt')) { + return $this->error('AI 模块未加载', 500); + } + + $params = $this->get_json_params($request); + $title = sanitize_text_field((string) ($params['title'] ?? '')); + $content = wp_kses_post((string) ($params['content'] ?? '')); + + if (empty($title) && empty($content)) { + return $this->error('标题或内容不能为空'); + } + + $prompt = AKSRT_AI_Prompt::get_instance()->build_tags_prompt($title, $content); + $result = AKSRT_AI::get_instance()->generate($prompt); + + if ($result['success']) { + $tags = json_decode($result['content'], true); + if (!is_array($tags)) { + $tags = array_filter(array_map('trim', explode(',', $result['content']))); + } + return $this->success(['data' => ['tags' => $tags]]); + } + + return $this->error($result['message'] ?? '生成失败'); + } +} diff --git a/inc/classes/admin/class-aksrt-admin-comment-controller.php b/inc/classes/admin/class-aksrt-admin-comment-controller.php new file mode 100644 index 00000000..0b61d4a5 --- /dev/null +++ b/inc/classes/admin/class-aksrt-admin-comment-controller.php @@ -0,0 +1,424 @@ + 'GET', + 'callback' => [$this, 'list_comments'], + 'permission_callback' => [$this, 'check_permission'], + 'args' => [ + 'page' => ['default' => 1, 'sanitize_callback' => 'absint'], + 'per_page' => ['default' => 20, 'sanitize_callback' => 'absint'], + 'post_id' => ['default' => 0, 'sanitize_callback' => 'absint'], + 'status' => ['default' => '', 'sanitize_callback' => 'sanitize_text_field'], + 'search' => ['default' => '', 'sanitize_callback' => 'sanitize_text_field'], + ], + ]); + + // Create comment (admin or frontend) + register_rest_route(self::API_NAMESPACE, '/comments', [ + 'methods' => 'POST', + 'callback' => [$this, 'create_comment'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + 'args' => [ + 'post_id' => ['required' => true, 'sanitize_callback' => 'absint'], + 'content' => ['required' => true, 'sanitize_callback' => 'sanitize_textarea_field'], + 'parent' => ['default' => 0, 'sanitize_callback' => 'absint'], + ], + ]); + + // Update comment status (admin) + register_rest_route(self::API_NAMESPACE, '/comments/(?P\d+)/status', [ + 'methods' => 'POST', + 'callback' => [$this, 'update_comment_status'], + 'permission_callback' => [$this, 'check_permission'], + 'args' => [ + 'id' => ['required' => true, 'sanitize_callback' => 'absint'], + 'status' => ['required' => true, 'sanitize_callback' => 'sanitize_text_field'], + ], + ]); + + // Edit comment content (admin) + register_rest_route(self::API_NAMESPACE, '/comments/(?P\d+)', [ + 'methods' => 'POST', + 'callback' => [$this, 'update_comment'], + 'permission_callback' => [$this, 'check_permission'], + 'args' => [ + 'id' => ['required' => true, 'sanitize_callback' => 'absint'], + 'content' => ['required' => true, 'sanitize_callback' => 'sanitize_textarea_field'], + ], + ]); + + // Delete comment (admin) + register_rest_route(self::API_NAMESPACE, '/comments/(?P\d+)', [ + 'methods' => 'DELETE', + 'callback' => [$this, 'delete_comment'], + 'permission_callback' => [$this, 'check_permission'], + 'args' => [ + 'id' => ['required' => true, 'sanitize_callback' => 'absint'], + ], + ]); + + // Bulk action (admin) + register_rest_route(self::API_NAMESPACE, '/comments/bulk', [ + 'methods' => 'POST', + 'callback' => [$this, 'bulk_action'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Frontend: list comments for a post (public) + register_rest_route(self::API_NAMESPACE, '/comments/post/(?P\d+)', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_post_comments'], + 'permission_callback' => '__return_true', + 'args' => [ + 'post_id' => ['required' => true, 'sanitize_callback' => 'absint'], + 'page' => ['default' => 1, 'sanitize_callback' => 'absint'], + 'per_page' => ['default' => 10, 'sanitize_callback' => 'absint'], + 'order' => ['default' => 'DESC', 'sanitize_callback' => 'sanitize_text_field'], + ], + ]); + } + + /** + * GET /comments — List comments with filtering. + */ + public function list_comments(WP_REST_Request $request): WP_REST_Response { + $args = [ + 'number' => min(100, (int) $request->get_param('per_page')), + 'offset' => ((int) $request->get_param('page') - 1) * (int) $request->get_param('per_page'), + 'orderby' => 'comment_date_gmt', + 'order' => 'DESC', + ]; + + $post_id = (int) $request->get_param('post_id'); + if ($post_id > 0) { + $args['post_id'] = $post_id; + } + + $status = $request->get_param('status'); + if ($status !== '') { + $args['status'] = $status; + } + + $search = $request->get_param('search'); + if ($search !== '') { + $args['search'] = $search; + } + + $query = new WP_Comment_Query($args); + + // Batch prefetch to avoid N+1 queries + $user_ids = array_unique(array_filter(array_map(fn($c) => (int) $c->user_id, $query->comments))); + $post_ids = array_unique(array_map(fn($c) => (int) $c->comment_post_ID, $query->comments)); + $users_map = []; + $posts_map = []; + if (!empty($user_ids)) { + $users = get_users(['include' => $user_ids, 'fields' => ['ID', 'display_name']]); + foreach ($users as $u) $users_map[$u->ID] = $u; + } + if (!empty($post_ids)) { + global $wpdb; + $ids_csv = implode(',', array_map('intval', $post_ids)); + $titles = $wpdb->get_results("SELECT ID, post_title FROM {$wpdb->posts} WHERE ID IN ({$ids_csv})", OBJECT_K); + foreach ($titles as $t) $posts_map[$t->ID] = $t->post_title; + } + + $comments = []; + foreach ($query->comments as $comment) { + $comments[] = $this->format_comment($comment, $users_map, $posts_map); + } + + // Total count for pagination + $count_args = $args; + unset($count_args['number'], $count_args['offset'], $count_args['orderby'], $count_args['order']); + $count_args['count'] = true; + $total = (int) (new WP_Comment_Query($count_args))->get_comments('count'); + + return $this->success([ + 'data' => $comments, + 'total' => $total, + 'pages' => ceil($total / (int) $request->get_param('per_page')), + ]); + } + + /** + * POST /comments — Create a comment. + */ + public function create_comment(WP_REST_Request $request): WP_REST_Response { + $user_id = get_current_user_id(); + $post_id = (int) $request->get_param('post_id'); + $content = trim((string) $request->get_param('content')); + $parent = (int) $request->get_param('parent'); + + if (!$content) { + return $this->error('content_required', '评论内容不能为空。', 400); + } + + $post = get_post($post_id); + if (!$post || $post->post_status !== 'publish') { + return $this->error('invalid_post', '文章不存在或未发布。', 400); + } + + $comment_data = [ + 'comment_post_ID' => $post_id, + 'comment_content' => $content, + 'user_id' => $user_id, + 'comment_parent' => $parent, + 'comment_approved'=> 1, // Admin-created comments are auto-approved + ]; + + $comment_id = wp_insert_comment($comment_data); + if (is_wp_error($comment_id)) { + return $this->error('create_failed', $comment_id->get_error_message(), 500); + } + + $comment = get_comment($comment_id); + return $this->success(['data' => $this->format_comment($comment)]); + } + + /** + * POST /comments/{id}/status — Approve/unapprove/spam/trash. + */ + public function update_comment_status(WP_REST_Request $request): WP_REST_Response { + $comment_id = (int) $request->get_param('id'); + $new_status = $request->get_param('status'); + $comment = get_comment($comment_id); + + if (!$comment) { + return $this->error('not_found', '评论不存在。', 404); + } + + $valid_statuses = ['approve', 'hold', 'spam', 'trash']; + if (!in_array($new_status, $valid_statuses, true)) { + return $this->error('invalid_status', '无效的状态值。', 400); + } + + $result = wp_set_comment_status($comment_id, $new_status); + if (is_wp_error($result)) { + return $this->error('update_failed', $result->get_error_message(), 500); + } + + // Handle spam/trash + if ($new_status === 'spam') { + wp_spam_comment($comment_id); + } elseif ($new_status === 'trash') { + wp_trash_comment($comment_id); + } + + $comment = get_comment($comment_id); + return $this->success(['data' => $this->format_comment($comment)]); + } + + /** + * POST /comments/{id} — Edit comment content. + */ + public function update_comment(WP_REST_Request $request): WP_REST_Response { + $comment_id = (int) $request->get_param('id'); + $content = trim((string) $request->get_param('content')); + $comment = get_comment($comment_id); + + if (!$comment) { + return $this->error('not_found', '评论不存在。', 404); + } + + if (!$content) { + return $this->error('content_required', '评论内容不能为空。', 400); + } + + $result = wp_update_comment(['comment_ID' => $comment_id, 'comment_content' => $content]); + if (is_wp_error($result)) { + return $this->error('update_failed', $result->get_error_message(), 500); + } + + $comment = get_comment($comment_id); + return $this->success(['data' => $this->format_comment($comment)]); + } + + /** + * DELETE /comments/{id} — Delete a comment. + */ + public function delete_comment(WP_REST_Request $request): WP_REST_Response { + $comment_id = (int) $request->get_param('id'); + $comment = get_comment($comment_id); + + if (!$comment) { + return $this->error('not_found', '评论不存在。', 404); + } + + $result = wp_delete_comment($comment_id, true); + if (!$result) { + return $this->error('delete_failed', '删除评论失败。', 500); + } + + return $this->success(['data' => ['deleted' => true, 'id' => $comment_id]]); + } + + /** + * POST /comments/bulk — Bulk action on comments. + */ + public function bulk_action(WP_REST_Request $request): WP_REST_Response { + $body = $request->get_json_params(); + $ids = array_map('absint', $body['ids'] ?? []); + $action = sanitize_text_field($body['action'] ?? ''); + + if (empty($ids) || !$action) { + return $this->error('missing_params', '缺少参数。', 400); + } + + $success = 0; + $failed = 0; + + foreach ($ids as $id) { + $comment = get_comment($id); + if (!$comment) { $failed++; continue; } + + switch ($action) { + case 'approve': + wp_set_comment_status($id, 'approve'); + $success++; + break; + case 'hold': + wp_set_comment_status($id, 'hold'); + $success++; + break; + case 'spam': + wp_spam_comment($id); + $success++; + break; + case 'trash': + wp_trash_comment($id); + $success++; + break; + case 'delete': + wp_delete_comment($id, true); + $success++; + break; + default: + $failed++; + } + } + + return $this->success(['data' => ['success' => $success, 'failed' => $failed]]); + } + + /** + * GET /comments/post/{post_id} — Public: list comments for a post with nesting. + */ + public function get_post_comments(WP_REST_Request $request): WP_REST_Response { + $post_id = (int) $request->get_param('post_id'); + $page = max(1, (int) $request->get_param('page')); + $per_page = min(50, max(1, (int) $request->get_param('per_page'))); + $order = in_array(strtoupper((string) $request->get_param('order')), ['ASC', 'DESC'], true) + ? strtoupper((string) $request->get_param('order')) + : 'DESC'; + + $args = [ + 'post_id' => $post_id, + 'status' => 'approve', + 'number' => $per_page, + 'offset' => ($page - 1) * $per_page, + 'orderby' => 'comment_date_gmt', + 'order' => $order, + 'hierarchical' => 'threaded', + ]; + + $query = new WP_Comment_Query($args); + + // Batch prefetch users + $user_ids = array_unique(array_filter(array_map(fn($c) => (int) $c->user_id, $query->comments))); + $users_map = []; + if (!empty($user_ids)) { + $users = get_users(['include' => $user_ids, 'fields' => ['ID', 'display_name']]); + foreach ($users as $u) $users_map[$u->ID] = $u; + } + + $comments = []; + foreach ($query->comments as $comment) { + $comments[] = $this->format_comment_public($comment, $users_map); + } + + // Total approved comments for this post + $total = (int) get_comments_number($post_id); + $total_approved = get_comments([ + 'post_id' => $post_id, + 'status' => 'approve', + 'count' => true, + ]); + + return $this->success([ + 'data' => $comments, + 'total' => (int) $total_approved, + 'pages' => ceil((int) $total_approved / $per_page), + ]); + } + + /** + * Format comment for admin views. + */ + private function format_comment(WP_Comment $comment, array $users_map = [], array $posts_map = []): array { + $uid = (int) $comment->user_id; + $pid = (int) $comment->comment_post_ID; + $user = $users_map[$uid] ?? ($uid ? get_userdata($uid) : null); + return [ + 'id' => (int) $comment->comment_ID, + 'post_id' => $pid, + 'post_title' => $posts_map[$pid] ?? get_the_title($pid), + 'author' => $comment->comment_author, + 'author_email' => $comment->comment_author_email, + 'user_id' => (int) $comment->user_id, + 'user_name' => $user ? $user->display_name : '', + 'content' => $comment->comment_content, + 'status' => $comment->comment_approved, + 'parent' => (int) $comment->comment_parent, + 'date' => $comment->comment_date_gmt, + 'author_ip' => $comment->comment_author_IP, + 'avatar_url' => get_avatar_url($comment->comment_author_email, ['size' => 48]), + ]; + } + + /** + * Format comment for public views (no email/IP). + */ + private function format_comment_public(WP_Comment $comment, array $users_map = []): array { + $uid = (int) $comment->user_id; + $user = $users_map[$uid] ?? ($uid ? get_userdata($uid) : null); + return [ + 'id' => (int) $comment->comment_ID, + 'author' => $comment->comment_author, + 'user_id' => (int) $comment->user_id, + 'content' => $comment->comment_content, + 'date' => $comment->comment_date_gmt, + 'parent' => (int) $comment->comment_parent, + 'avatar_url' => get_avatar_url($comment->comment_author_email, ['size' => 48]), + 'children' => [], // Will be populated by JS if threaded + ]; + } + + /** + * Helper: check logged-in permission (any authenticated user). + */ + public function check_logged_in_permission(): bool { + return is_user_logged_in(); + } +} diff --git a/inc/classes/admin/class-aksrt-admin-controller-base.php b/inc/classes/admin/class-aksrt-admin-controller-base.php new file mode 100644 index 00000000..3f3001d2 --- /dev/null +++ b/inc/classes/admin/class-aksrt-admin-controller-base.php @@ -0,0 +1,173 @@ + true], $data), $status); + } + + /** + * Create a standard error response. + */ + protected function error(string $message, int $status = 400): WP_REST_Response { + return new WP_REST_Response([ + 'success' => false, + 'message' => $message, + ], $status); + } + + /** + * Read JSON request body safely. + */ + protected function get_json_params(WP_REST_Request $request): array { + $params = $request->get_json_params(); + return is_array($params) ? $params : []; + } + + /** + * Sanitize a settings array using a whitelist of allowed keys. + * + * @param array $input Raw input data. + * @param array $allowed Allowed keys with their types (string|int|bool|float|array). + * @return array Sanitized data. + */ + protected function sanitize_fields(array $input, array $allowed): array { + $out = []; + foreach ($allowed as $key => $type) { + if (!array_key_exists($key, $input)) continue; + $val = $input[$key]; + switch ($type) { + case 'string': + $out[$key] = sanitize_text_field($val); + break; + case 'html': + $out[$key] = wp_kses_post($val); + break; + case 'code': + $out[$key] = is_string($val) ? wp_unslash($val) : ''; + break; + case 'url': + $out[$key] = esc_url_raw($val); + break; + case 'int': + $out[$key] = (int) $val; + break; + case 'bool': + $out[$key] = !empty($val); + break; + case 'float': + $out[$key] = (float) $val; + break; + case 'array': + $out[$key] = is_array($val) ? $val : []; + break; + default: + $out[$key] = sanitize_text_field($val); + } + } + return $out; + } + + protected function register_section_routes(string $route_slug, string $domain): void { + register_rest_route(self::API_NAMESPACE, '/settings/' . $route_slug . '/(?P
[a-z0-9_-]+)', [ + 'methods' => 'GET', + 'callback' => function (WP_REST_Request $request) use ($domain) { + return $this->get_section_settings($request, $domain); + }, + 'permission_callback' => [$this, 'check_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/settings/' . $route_slug . '/(?P
[a-z0-9_-]+)', [ + 'methods' => 'POST', + 'callback' => function (WP_REST_Request $request) use ($domain) { + return $this->save_section_settings($request, $domain); + }, + 'permission_callback' => [$this, 'check_permission'], + ]); + } + + protected function get_section_settings(WP_REST_Request $request, string $domain): WP_REST_Response { + $provider = AKSRT_Admin_Schema_Provider::get_instance(); + $section = (string) $request->get_param('section'); + $schema = $provider->get_section_schema($domain, $section); + + if (!$schema) { + return $this->error('未知的设置分组: ' . esc_html($section), 404); + } + + return $this->success([ + 'data' => $provider->read_settings($domain, $section), + 'schema' => $schema, + 'payload' => [ + 'domain' => $provider->normalize_domain($domain), + 'section' => $provider->normalize_section($section), + 'version' => $schema['version'] ?? '1.0.0', + ], + ]); + } + + protected function save_section_settings(WP_REST_Request $request, string $domain): WP_REST_Response { + $provider = AKSRT_Admin_Schema_Provider::get_instance(); + $section = (string) $request->get_param('section'); + $schema = $provider->get_section_schema($domain, $section); + + if (!$schema) { + return $this->error('未知的设置分组: ' . esc_html($section), 404); + } + + $params = $this->get_json_params($request); + $payload = $params['data'] ?? $params; + if (!is_array($payload)) { + return $this->error('无效的设置载荷'); + } + + $sanitized = $this->sanitize_fields($payload, $provider->get_allowed_fields($domain, $section)); + $validation = $provider->validate($domain, $sanitized, $section); + if (!$validation['valid']) { + return $this->error(implode('; ', $validation['errors']), 422); + } + + $provider->write_settings($domain, $sanitized, $section); + + return $this->success([ + 'message' => sprintf('%s/%s 设置已保存', $provider->normalize_domain($domain), $provider->normalize_section($section)), + 'data' => $provider->read_settings($domain, $section), + 'payload' => [ + 'domain' => $provider->normalize_domain($domain), + 'section' => $provider->normalize_section($section), + 'version' => $schema['version'] ?? '1.0.0', + ], + ]); + } +} diff --git a/inc/classes/admin/class-aksrt-admin-extension-controller.php b/inc/classes/admin/class-aksrt-admin-extension-controller.php new file mode 100644 index 00000000..8680edab --- /dev/null +++ b/inc/classes/admin/class-aksrt-admin-extension-controller.php @@ -0,0 +1,456 @@ +admin = $admin; + } + + public function register_routes(): void { + // Extensions overview + register_rest_route(self::API_NAMESPACE, '/extensions/overview', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_overview'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Docs page stats + register_rest_route(self::API_NAMESPACE, '/docs/stats', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_docs_stats'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Docs search (public, for frontend AJAX) + register_rest_route(self::API_NAMESPACE, '/docs/search', [ + 'methods' => 'GET', + 'callback' => [$this, 'search_docs'], + 'permission_callback' => '__return_true', + ]); + + // Extensions service status summary (aggregated) + register_rest_route(self::API_NAMESPACE, '/settings/extensions', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_extensions_status'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Security settings summary + register_rest_route(self::API_NAMESPACE, '/settings/security', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_security_settings'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Security settings — save risk switches + register_rest_route(self::API_NAMESPACE, '/settings/security', [ + 'methods' => 'POST', + 'callback' => [$this, 'save_security_settings'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Security audit log + register_rest_route(self::API_NAMESPACE, '/security/audit-log', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_audit_log'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // IP geo test tool + register_rest_route(self::API_NAMESPACE, '/extensions/ip-test', [ + 'methods' => 'POST', + 'callback' => [$this, 'test_ip_geo'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Content audit test tool + register_rest_route(self::API_NAMESPACE, '/extensions/audit-test', [ + 'methods' => 'POST', + 'callback' => [$this, 'test_content_audit'], + 'permission_callback' => [$this, 'check_permission'], + ]); + } + + /** + * GET /extensions/overview — real extension status aggregation. + */ + public function get_overview(): WP_REST_Response { + $services = $this->collect_service_statuses(); + + $installed = 0; + $available = count($services); + $unconfigured = 0; + + foreach ($services as $svc) { + if (!empty($svc['enabled'])) $installed++; + if (!empty($svc['enabled']) && !empty($svc['configured']) && empty($svc['verified'])) { + $unconfigured++; + } + } + + // Build message + if ($installed === 0) { + $message = sprintf(__('已注册 %d 项扩展,均未启用。', 'aksrt'), $available); + } elseif ($unconfigured > 0) { + $message = sprintf(__('已启用 %d/%d 项扩展,%d 项待补全配置。', 'aksrt'), $installed, $available, $unconfigured); + } else { + $message = sprintf(__('已启用 %d/%d 项扩展,全部就绪。', 'aksrt'), $installed, $available); + } + + return $this->success([ + 'data' => [ + 'installed' => $installed, + 'available' => $available, + 'message' => $message, + 'services' => $services, + ], + ]); + } + + public function get_docs_stats(): WP_REST_Response { + $nav_count = 0; + $special_count = 0; + + $nav_query = new WP_Query([ + 'post_type' => 'page', + 'post_status' => 'publish', + 'meta_key' => '_wp_page_template', + 'meta_value' => 'templates/pages/page-documentnav.php', + 'posts_per_page' => 1, + 'fields' => 'ids', + 'no_found_rows' => false, + ]); + $nav_count = (int) $nav_query->found_posts; + + $special_query = new WP_Query([ + 'post_type' => 'page', + 'post_status' => 'publish', + 'meta_key' => '_wp_page_template', + 'meta_value' => 'templates/pages/page-special.php', + 'posts_per_page' => 1, + 'fields' => 'ids', + 'no_found_rows' => false, + ]); + $special_count = (int) $special_query->found_posts; + + return $this->success([ + 'data' => [ + 'total' => $nav_count + $special_count, + 'nav_count' => $nav_count, + 'special_count' => $special_count, + ], + ]); + } + + /** + * GET /docs/search — search document pages by keyword. + */ + public function search_docs(WP_REST_Request $request): WP_REST_Response { + $keyword = trim(sanitize_text_field((string) $request->get_param('keyword'))); + $root_id = (int) $request->get_param('root_id'); + $limit = max(1, min(50, (int) ($request->get_param('limit') ?: 20))); + + if ($keyword === '') { + return $this->success(['data' => ['results' => [], 'total' => 0]]); + } + + if (!class_exists('AKSRT_Docs_Presenter')) { + return $this->success(['data' => ['results' => [], 'total' => 0]]); + } + + $results = AKSRT_Docs_Presenter::get_instance()->search_pages($keyword, $root_id, $limit); + + return $this->success([ + 'data' => [ + 'results' => $results, + 'total' => count($results), + ], + ]); + } + + /** + * GET /settings/extensions — aggregated extension service status. + */ + public function get_extensions_status(): WP_REST_Response { + $services = $this->collect_service_statuses(); + $flat = []; + + foreach ($services as $key => $svc) { + $flat["{$key}_enabled"] = !empty($svc['enabled']); + $flat["{$key}_configured"] = !empty($svc['configured']); + $flat["{$key}_available"] = !empty($svc['available']); + $flat["{$key}_verified"] = !empty($svc['verified']); + $flat["{$key}_provider"] = (string) ($svc['provider'] ?? ''); + } + + // 保留旧布尔字段兼容 + $flat['sms_enabled'] = !empty($services['sms']['enabled']); + $flat['wechat_enabled'] = !empty($services['wechat']['enabled']); + $flat['captcha_enabled'] = !empty($services['captcha']['enabled']); + $flat['baidu_push_enabled'] = !empty($services['push']['enabled']); + $flat['content_audit_enabled'] = !empty($services['audit']['enabled']); + + return $this->success([ + 'data' => $flat, + 'services' => $services, + ]); + } + + /** + * GET /settings/security — security settings summary with real service status. + */ + public function get_security_settings(): WP_REST_Response { + $services = $this->collect_service_statuses(); + $settings = AKSRT_Settings::get_all(); + + $email_gate_enabled = true; + if (defined('AKSRT_NO_EMAIL_GATE') && AKSRT_NO_EMAIL_GATE) { + $email_gate_enabled = false; + } + + $captcha_comment_enabled = !empty($settings['captcha_comment_enabled']); + $captcha_download_enabled = !empty($settings['captcha_download_enabled']); + + $risk_switches = [ + 'show_unapproved_comments' => !empty($settings['show_unapproved_comments']), + 'skip_submit_moderation' => !empty($settings['skip_submit_moderation']), + 'guest_comment' => !empty($settings['guest_comment']), + ]; + + return $this->success([ + 'data' => [ + 'email_gate_enabled' => $email_gate_enabled, + 'captcha_comment_enabled' => $captcha_comment_enabled, + 'captcha_download_enabled' => $captcha_download_enabled, + 'content_audit_enabled' => !empty($services['audit']['enabled']), + 'content_audit_configured' => !empty($services['audit']['configured']), + 'ip_geo_enabled' => !empty($services['ip_geo']['enabled']), + 'ip_geo_configured' => !empty($services['ip_geo']['configured']), + 'captcha_enabled' => $captcha_comment_enabled || $captcha_download_enabled, + 'risk_switches' => $risk_switches, + 'services' => $services, + ], + ]); + } + + /** + * POST /settings/security — save risk switches. + */ + public function save_security_settings(WP_REST_Request $request): WP_REST_Response { + $params = $this->get_json_params($request); + $risk_switches = $params['risk_switches'] ?? []; + + if (!is_array($risk_switches)) { + return $this->error('无效的风险设置数据'); + } + + $allowed = [ + 'show_unapproved_comments' => 'bool', + 'skip_submit_moderation' => 'bool', + 'guest_comment' => 'bool', + ]; + + $sanitized = $this->sanitize_fields($risk_switches, $allowed); + + // Merge into main settings + $all = AKSRT_Settings::get_all(); + foreach ($sanitized as $key => $val) { + $all[$key] = $val; + } + + update_option('aksrt_settings', $all); + + return $this->success([ + 'message' => __('风险设置已保存', 'aksrt'), + 'data' => $sanitized, + ]); + } + + /** + * GET /security/audit-log — recent security audit entries. + */ + public function get_audit_log(): WP_REST_Response { + global $wpdb; + + $limit = 20; + $logs = []; + + // Recent user registrations + $recent_users = get_users([ + 'number' => $limit, + 'orderby' => 'registered', + 'order' => 'DESC', + 'fields' => ['ID', 'display_name', 'user_email', 'user_registered'], + ]); + + foreach ($recent_users as $user) { + $logs[] = [ + 'id' => 'reg_' . $user->ID, + 'action' => '用户注册', + 'detail' => sprintf('%s (%s) 完成注册', $user->display_name, $user->user_email), + 'time' => $user->user_registered, + 'type' => 'auth', + ]; + } + + // Content audit log + $audit_log = get_option('aksrt_content_audit_log', []); + foreach (array_slice(array_reverse($audit_log), 0, 10) as $i => $entry) { + $logs[] = [ + 'id' => 'audit_' . $i, + 'action' => '内容审核', + 'detail' => sprintf('%s — %s', $entry['content'] ?? '', ($entry['passed'] ?? false) ? '通过' : '拦截'), + 'time' => $entry['time'] ?? '', + 'type' => 'audit', + ]; + } + + // Sort by time descending + usort($logs, function ($a, $b) { + return strcmp($b['time'] ?? '', $a['time'] ?? ''); + }); + + return $this->success([ + 'data' => [ + 'logs' => array_slice($logs, 0, $limit), + ], + ]); + } + + /** + * POST /extensions/ip-test — test current IP provider with a given IP. + */ + public function test_ip_geo(WP_REST_Request $request): WP_REST_Response { + $params = $this->get_json_params($request); + $ip = trim((string) ($params['ip'] ?? '')); + + if ($ip === '' || !filter_var($ip, FILTER_VALIDATE_IP)) { + return $this->error(__('请输入有效的 IP 地址', 'aksrt'), 422); + } + + if (!class_exists('AKSRT_IP_Geo')) { + return $this->error(__('IP 归属地服务不可用', 'aksrt'), 500); + } + + $result = AKSRT_IP_Geo::get_instance()->lookup($ip); + + return $this->success([ + 'data' => [ + 'ip' => $ip, + 'result' => $result, + ], + ]); + } + + /** + * POST /extensions/audit-test — test text content against current audit provider. + */ + public function test_content_audit(WP_REST_Request $request): WP_REST_Response { + $params = $this->get_json_params($request); + $text = trim((string) ($params['text'] ?? '')); + + if ($text === '') { + return $this->error(__('请输入要测试的文本内容', 'aksrt'), 422); + } + + if (!class_exists('AKSRT_Content_Audit')) { + return $this->error(__('内容审核服务不可用', 'aksrt'), 500); + } + + $result = AKSRT_Content_Audit::get_instance()->audit_text($text); + + return $this->success([ + 'data' => [ + 'text' => $text, + 'result' => $result, + ], + ]); + } + + /** + * 收集所有扩展服务的运行时状态 + */ + private function collect_service_statuses(): array { + $services = []; + + // SMS + if (class_exists('AKSRT_SMS')) { + $services['sms'] = AKSRT_SMS::get_instance()->get_provider_status(); + } else { + $services['sms'] = ['enabled' => false, 'provider' => 'none', 'configured' => false, 'available' => false, 'verified' => false, 'transport' => '', 'missing_fields' => []]; + } + + // WeChat + if (class_exists('AKSRT_WeChat_Notify')) { + $services['wechat'] = AKSRT_WeChat_Notify::get_instance()->get_status(); + } else { + $services['wechat'] = ['enabled' => false, 'provider' => '', 'configured' => false, 'available' => false, 'verified' => false, 'transport' => '', 'missing_fields' => []]; + } + + // Captcha + $settings = AKSRT_Settings::get_all(); + $captcha_en = class_exists('AKSRT_Captcha') && !empty($settings['captcha_enabled']); + $services['captcha'] = [ + 'enabled' => $captcha_en, + 'provider' => sanitize_key((string) ($settings['captcha_provider'] ?? '')), + 'configured' => $captcha_en, + 'available' => $captcha_en, + 'verified' => false, + 'transport' => 'http_api', + 'missing_fields' => [], + ]; + + // IP Geo + if (class_exists('AKSRT_IP_Geo')) { + $services['ip_geo'] = AKSRT_IP_Geo::get_instance()->get_status(); + } else { + $services['ip_geo'] = ['enabled' => false, 'provider' => '', 'configured' => false, 'available' => false, 'verified' => false, 'transport' => '', 'missing_fields' => []]; + } + + // Content Audit + if (class_exists('AKSRT_Content_Audit')) { + $services['audit'] = AKSRT_Content_Audit::get_instance()->get_status(); + } else { + $services['audit'] = ['enabled' => false, 'provider' => '', 'configured' => false, 'available' => false, 'verified' => false, 'transport' => '', 'missing_fields' => []]; + } + + // Search Push + if (class_exists('AKSRT_Search_Push')) { + $services['push'] = AKSRT_Search_Push::get_instance()->get_status(); + } else { + $services['push'] = ['enabled' => false, 'provider' => '', 'configured' => false, 'available' => false, 'verified' => false, 'transport' => '', 'missing_fields' => []]; + } + + // Docs Mode + $docs_en = !empty($settings['docs_mode_enabled']); + $services['docs_mode'] = [ + 'enabled' => $docs_en, + 'provider' => 'builtin', + 'configured' => $docs_en, + 'available' => $docs_en, + 'verified' => true, + 'transport' => 'builtin', + 'missing_fields' => [], + ]; + + return $services; + } +} diff --git a/inc/classes/admin/class-aksrt-admin-forum-controller.php b/inc/classes/admin/class-aksrt-admin-forum-controller.php new file mode 100644 index 00000000..cf36d407 --- /dev/null +++ b/inc/classes/admin/class-aksrt-admin-forum-controller.php @@ -0,0 +1,411 @@ +admin = $admin; + } + + public function register_routes(): void { + register_rest_route(self::API_NAMESPACE, '/forum/overview', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_overview'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/forum/boards', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_boards'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/forum/boards', [ + 'methods' => 'POST', + 'callback' => [$this, 'create_board'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/forum/boards/(?P\d+)', [ + 'methods' => 'POST', + 'callback' => [$this, 'update_board'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/forum/boards/(?P\d+)', [ + 'methods' => 'DELETE', + 'callback' => [$this, 'delete_board'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/forum/boards/reorder', [ + 'methods' => 'POST', + 'callback' => [$this, 'reorder_boards'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/forum/topics', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_topics'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/forum/topics/(?P\d+)', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_topic_detail'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/forum/topics/(?P\d+)/moderate', [ + 'methods' => 'POST', + 'callback' => [$this, 'moderate_topic'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/forum/topics/(?P\d+)/vote', [ + 'methods' => 'POST', + 'callback' => [$this, 'vote_topic'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/forum/update-topic', [ + 'methods' => 'POST', + 'callback' => [$this, 'update_topic_legacy'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/forum/moderators', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_moderators'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/forum/moderators', [ + 'methods' => 'POST', + 'callback' => [$this, 'update_moderators'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Change moderator role + register_rest_route(self::API_NAMESPACE, '/forum/moderators/role', [ + 'methods' => 'POST', + 'callback' => [$this, 'change_moderator_role'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/forum/moderator/apply', [ + 'methods' => 'POST', + 'callback' => [$this, 'apply_moderator'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/forum/moderator/applications', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_moderator_applications'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/forum/moderator/applications/(?P[A-Za-z0-9-]+)/review', [ + 'methods' => 'POST', + 'callback' => [$this, 'review_moderator_application'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/forum/topics/(?P\d+)/favorite', [ + 'methods' => 'POST', + 'callback' => [$this, 'toggle_favorite'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/forum/topics/(?P\d+)/score', [ + 'methods' => 'POST', + 'callback' => [$this, 'score_topic'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/forum/topics/(?P\d+)/adopt', [ + 'methods' => 'POST', + 'callback' => [$this, 'adopt_answer'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + ]); + + // §7.0 Plate CPT endpoints + register_rest_route(self::API_NAMESPACE, '/forum/plates', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_plates'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/forum/plates', [ + 'methods' => 'POST', + 'callback' => [$this, 'create_plate'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/forum/plates/(?P\d+)', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_plate'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/forum/plates/(?P\d+)', [ + 'methods' => 'POST', + 'callback' => [$this, 'update_plate'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/forum/plates/(?P\d+)', [ + 'methods' => 'DELETE', + 'callback' => [$this, 'delete_plate'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/forum/plates/reorder', [ + 'methods' => 'POST', + 'callback' => [$this, 'reorder_plates'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // §7.2 Follow plate + register_rest_route(self::API_NAMESPACE, '/forum/plates/(?P\d+)/follow', [ + 'methods' => 'POST', + 'callback' => [$this, 'toggle_follow_plate'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + ]); + + // §7.2 Plate categories (分区) + register_rest_route(self::API_NAMESPACE, '/forum/plate-categories', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_plate_categories'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // §7.2 Sorted plates + register_rest_route(self::API_NAMESPACE, '/forum/plates/sorted', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_sorted_plates'], + 'permission_callback' => '__return_true', + ]); + + // §7.2 Front-end plate CRUD (forum admin/moderator) + register_rest_route(self::API_NAMESPACE, '/forum/front/plates', [ + 'methods' => 'POST', + 'callback' => [$this, 'frontend_create_plate'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/forum/front/plates/(?P\d+)', [ + 'methods' => 'POST', + 'callback' => [$this, 'frontend_update_plate'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/forum/front/plates/(?P\d+)', [ + 'methods' => 'DELETE', + 'callback' => [$this, 'frontend_delete_plate'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + ]); + } + + public function get_overview(): WP_REST_Response { + $forum = $this->get_forum_service(); + if (!$forum) { + return $this->success(['data' => ['stats' => [], 'boards' => [], 'recent_topics' => [], 'pending_applications' => []]]); + } + + $pending_topics = $forum->get_topic_list([ + 'limit' => 6, + 'status' => 'pending', + 'review_status' => AKSRT_Forum::REVIEW_PENDING, + 'viewer_user_id' => get_current_user_id(), + 'include_hidden' => true, + ]); + + return $this->success([ + 'data' => [ + 'stats' => $forum->get_stats(), + 'boards' => array_slice($forum->get_boards(), 0, 6), + 'pending_topics' => $pending_topics['items'], + 'pending_applications' => array_slice($forum->get_moderator_application_list(['status' => AKSRT_Forum::APPLICATION_PENDING]), 0, 6), + ], + ]); + } + + public function get_boards(): WP_REST_Response { + $forum = $this->get_forum_service(); + $items = $forum ? $forum->get_boards() : []; + + return $this->success([ + 'data' => [ + 'items' => $items, + 'total' => count($items), + ], + ]); + } + + public function create_board(WP_REST_Request $request): WP_REST_Response { + $forum = $this->get_forum_service(); + if (!$forum) { + return $this->error(__('论坛系统未启用', 'aksrt')); + } + + $params = $this->get_json_params($request); + $name = sanitize_text_field((string) ($params['name'] ?? '')); + if ($name === '') { + return $this->error(__('版块名称不能为空', 'aksrt')); + } + + $term_id = $forum->create_board($params); + if ($term_id <= 0) { + return $this->error(__('创建版块失败', 'aksrt')); + } + + return $this->success([ + 'message' => __('版块已创建', 'aksrt'), + 'data' => ['id' => $term_id], + ], 201); + } + + public function update_board(WP_REST_Request $request): WP_REST_Response { + $forum = $this->get_forum_service(); + if (!$forum) { + return $this->error(__('论坛系统未启用', 'aksrt')); + } + + $board_id = (int) $request->get_param('id'); + if ($board_id <= 0) { + return $this->error(__('参数无效', 'aksrt')); + } + + $success = $forum->update_board($board_id, $this->get_json_params($request)); + if (!$success) { + return $this->error(__('版块更新失败', 'aksrt')); + } + + return $this->success(['message' => __('版块已更新', 'aksrt')]); + } + + public function delete_board(WP_REST_Request $request): WP_REST_Response { + $forum = $this->get_forum_service(); + if (!$forum) { + return $this->error(__('论坛系统未启用', 'aksrt')); + } + + $board_id = (int) $request->get_param('id'); + if ($board_id <= 0) { + return $this->error(__('参数无效', 'aksrt')); + } + + $success = $forum->delete_board($board_id); + if (!$success) { + return $this->error(__('版块删除失败', 'aksrt')); + } + + return $this->success(['message' => __('版块已删除', 'aksrt')]); + } + + public function reorder_boards(WP_REST_Request $request): WP_REST_Response { + $forum = $this->get_forum_service(); + if (!$forum) { + return $this->error(__('论坛系统未启用', 'aksrt')); + } + + $params = $this->get_json_params($request); + $order = $params['order'] ?? []; + if (!is_array($order) || empty($order)) { + return $this->error(__('排序参数无效', 'aksrt')); + } + + $forum->reorder_boards($order); + + return $this->success(['message' => __('排序已更新', 'aksrt')]); + } + + // ================================================================ + // §7.0 Plate CPT handlers + // ================================================================ + + public function get_plates(WP_REST_Request $request): WP_REST_Response { + $forum = $this->get_forum_service(); + if (!$forum) { + return $this->success(['data' => ['items' => [], 'total' => 0]]); + } + + $args = []; + $parent = $request->get_param('parent'); + if ($parent !== null) { + $args['parent'] = (int) $parent; + } + $plate_cat = $request->get_param('plate_cat'); + if ($plate_cat) { + $args['plate_cat'] = (int) $plate_cat; + } + + $items = $forum->get_plates($args); + + return $this->success([ + 'data' => [ + 'items' => $items, + 'total' => count($items), + ], + ]); + } + + public function get_plate(WP_REST_Request $request): WP_REST_Response { + $forum = $this->get_forum_service(); + if (!$forum) { + return $this->error(__('论坛系统未启用', 'aksrt')); + } + + $plate_id = (int) $request->get_param('id'); + $plate = $forum->get_plate($plate_id); + if (!$plate) { + return $this->error(__('版块不存在', 'aksrt'), 404); + } + + return $this->success(['data' => $plate]); + } + + public function create_plate(WP_REST_Request $request): WP_REST_Response { + $forum = $this->get_forum_service(); + if (!$forum) { + return $this->error(__('论坛系统未启用', 'aksrt')); + } + $params = $this->get_json_params($request); + $name = sanitize_text_field((string) ($params['name'] ?? '')); + if ($name === '') { + return $this->error(__('版块名称不能为空', 'aksrt')); + } + + $plate_id = $forum->create_plate($params); + if ($plate_id <= 0) { + return $this->error(__('创建版块失败', 'aksrt')); + } + + return $this->success([ + 'message' => __('版块已创建', 'aksrt'), + 'data' => ['id' => $plate_id], + ], 201); + } + +} diff --git a/inc/classes/admin/class-aksrt-admin-global-controller.php b/inc/classes/admin/class-aksrt-admin-global-controller.php new file mode 100644 index 00000000..a908023d --- /dev/null +++ b/inc/classes/admin/class-aksrt-admin-global-controller.php @@ -0,0 +1,575 @@ + ['GET', 'POST'], + 'callback' => [$this, 'rest_handle_mail'], + 'permission_callback' => [$this, 'check_permission'], + ]); + register_rest_route(self::API_NAMESPACE, '/mail/test', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_test_mail'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Footer + register_rest_route(self::API_NAMESPACE, '/settings/footer', [ + 'methods' => ['GET', 'POST'], + 'callback' => [$this, 'rest_handle_footer_settings'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Footer Tabbar + register_rest_route(self::API_NAMESPACE, '/settings/footer-tabbar', [ + 'methods' => ['GET', 'POST'], + 'callback' => [$this, 'rest_handle_footer_tabbar'], + 'permission_callback' => [$this, 'check_permission'], + ]); + register_rest_route(self::API_NAMESPACE, '/settings/footer-tabbar/reorder', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_reorder_footer_tabbar'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Floating Buttons + register_rest_route(self::API_NAMESPACE, '/settings/floating-buttons', [ + 'methods' => ['GET', 'POST'], + 'callback' => [$this, 'rest_handle_floating_buttons'], + 'permission_callback' => [$this, 'check_permission'], + ]); + register_rest_route(self::API_NAMESPACE, '/settings/floating-buttons/reorder', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_reorder_floating_buttons'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Friend Links + register_rest_route(self::API_NAMESPACE, '/friend-links', [ + 'methods' => ['GET', 'POST'], + 'callback' => [$this, 'rest_handle_friend_links'], + 'permission_callback' => [$this, 'check_permission'], + ]); + register_rest_route(self::API_NAMESPACE, '/friend-links/(?P\d+)', [ + 'methods' => 'DELETE', + 'callback' => [$this, 'rest_delete_friend_link'], + 'permission_callback' => [$this, 'check_permission'], + ]); + register_rest_route(self::API_NAMESPACE, '/friend-links/reorder', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_reorder_friend_links'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // CMS Features + register_rest_route(self::API_NAMESPACE, '/settings/cms-features', [ + 'methods' => ['GET', 'POST'], + 'callback' => [$this, 'rest_handle_cms_features'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Homepage settings + register_rest_route(self::API_NAMESPACE, '/settings/homepage', [ + 'methods' => ['GET', 'POST'], + 'callback' => [$this, 'rest_handle_homepage_settings'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Category recommend + register_rest_route(self::API_NAMESPACE, '/settings/category-recommend', [ + 'methods' => ['GET', 'POST'], + 'callback' => [$this, 'rest_handle_category_recommend'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Categories list (for admin selectors) + register_rest_route(self::API_NAMESPACE, '/categories', [ + 'methods' => 'GET', + 'callback' => [$this, 'rest_get_categories'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Category term meta + register_rest_route(self::API_NAMESPACE, '/categories/(?P\d+)/meta', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_update_category_meta'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Homepage tab posts (public) + register_rest_route(self::API_NAMESPACE, '/homepage/tab-posts', [ + 'methods' => 'GET', + 'callback' => [$this, 'rest_homepage_tab_posts'], + 'permission_callback' => '__return_true', + 'args' => [ + 'term_id' => ['type' => 'integer', 'required' => true, 'sanitize_callback' => 'absint'], + 'page' => ['type' => 'integer', 'default' => 1, 'sanitize_callback' => 'absint'], + 'per_page' => ['type' => 'integer', 'default' => 10, 'sanitize_callback' => 'absint'], + 'sort' => ['type' => 'string', 'default' => 'modified', 'sanitize_callback' => 'sanitize_text_field'], + 'view_mode' => ['type' => 'string', 'default' => 'list', 'sanitize_callback' => 'sanitize_text_field'], + 'page_url' => ['type' => 'string', 'default' => '', 'sanitize_callback' => 'esc_url_raw'], + 'panel' => ['type' => 'string', 'default' => 'latest', 'sanitize_callback' => 'sanitize_key'], + 'source' => ['type' => 'string', 'default' => 'home', 'sanitize_callback' => 'sanitize_key'], + 'tab_orderby' => ['type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_key'], + ], + ]); + + // Archive AJAX filter (public) — §2.5/§2.6/§2.7 + register_rest_route(self::API_NAMESPACE, '/archive/posts', [ + 'methods' => 'GET', + 'callback' => [$this, 'rest_archive_posts'], + 'permission_callback' => '__return_true', + 'args' => [ + 'archive_type' => ['type' => 'string', 'default' => 'category', 'sanitize_callback' => 'sanitize_key'], + 'term_id' => ['type' => 'integer', 'default' => 0, 'sanitize_callback' => 'absint'], + 'filter_cat' => ['type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_text_field'], + 'filter_tag' => ['type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_text_field'], + 'sort' => ['type' => 'string', 'default' => 'date', 'sanitize_callback' => 'sanitize_key'], + 'order' => ['type' => 'string', 'default' => 'DESC', 'sanitize_callback' => 'sanitize_key'], + 'page' => ['type' => 'integer', 'default' => 1, 'sanitize_callback' => 'absint'], + 'per_page' => ['type' => 'integer', 'default' => 10, 'sanitize_callback' => 'absint'], + 'custom_filters' => ['type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_text_field'], + ], + ]); + + // Homepage categories (for admin tab editor) + register_rest_route(self::API_NAMESPACE, '/homepage/categories', [ + 'methods' => 'GET', + 'callback' => [$this, 'rest_homepage_categories'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // User search (for admin user selectors) + register_rest_route(self::API_NAMESPACE, '/users/search', [ + 'methods' => 'GET', + 'callback' => [$this, 'rest_search_users'], + 'permission_callback' => [$this, 'check_permission'], + 'args' => [ + 'search' => ['type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_text_field'], + 'number' => ['type' => 'integer', 'default' => 20, 'sanitize_callback' => 'absint'], + ], + ]); + } + + public function rest_handle_cms_features(WP_REST_Request $request): WP_REST_Response { + if ($request->get_method() === 'GET') { + return $this->success(['cms_features' => AKSRT_Settings::get_cms_features()]); + } + + $params = $this->get_json_params($request); + $allowed = [ + 'resource_cpt_enabled' => 'bool', + 'download_login' => 'bool', + 'download_credit' => 'bool', + 'download_captcha' => 'bool', + 'search_include_resource' => 'bool', + ]; + $sanitized = $this->sanitize_fields($params, $allowed); + $settings = AKSRT_Settings::get_all(); + $settings['cms_features'] = array_merge( + is_array($settings['cms_features'] ?? null) ? $settings['cms_features'] : [], + $sanitized + ); + update_option('aksrt_settings', $settings); + AKSRT_Settings::flush_settings_cache(); + + return $this->success(['message' => 'CMS 功能已保存', 'cms_features' => AKSRT_Settings::get_cms_features()]); + } + + public function rest_handle_mail(WP_REST_Request $request): WP_REST_Response { + $option_key = 'aksrt_mail_config'; + + if ($request->get_method() === 'GET') { + $config = get_option($option_key, [ + 'smtp_host' => '', + 'smtp_port' => 465, + 'smtp_user' => '', + 'smtp_pass' => '', + 'smtp_secure' => 'ssl', + 'from_email' => '', + 'from_name' => '', + ]); + return $this->success(['data' => $config, 'code' => 'success']); + } + + $params = $this->get_json_params($request); + $current = get_option($option_key, []); + $merged = array_merge(is_array($current) ? $current : [], $params); + update_option($option_key, $merged); + + return $this->success(['message' => '邮件配置已保存', 'code' => 'success']); + } + + public function rest_test_mail(WP_REST_Request $request): WP_REST_Response { + $params = $this->get_json_params($request); + $to = sanitize_email($params['to'] ?? ''); + if (!is_email($to)) { + return $this->error(__('请输入有效的邮箱地址', 'aksrt')); + } + + $sent = wp_mail($to, __('AKSRT 邮件测试', 'aksrt'), __('如果你收到这封邮件,说明 SMTP 配置正确。', 'aksrt')); + if ($sent) { + return $this->success(['message' => '测试邮件已发送']); + } + return $this->error(__('邮件发送失败,请检查 SMTP 配置', 'aksrt')); + } + + public function rest_homepage_tab_posts(WP_REST_Request $request): WP_REST_Response { + $term_id = (int) $request->get_param('term_id'); + $page = max(1, (int) $request->get_param('page')); + $per_page = min(50, max(1, (int) $request->get_param('per_page'))); + $sort = sanitize_key($request->get_param('sort') ?: 'modified'); + $view_mode = sanitize_key($request->get_param('view_mode') ?: 'list'); + $tab_orderby = sanitize_key($request->get_param('tab_orderby') ?: ''); + $panel = sanitize_key($request->get_param('panel') ?: 'latest'); + + $post_types = function_exists('aksrt_homepage_get_post_types') + ? aksrt_homepage_get_post_types() + : ['post']; + + $args = [ + 'post_type' => $post_types, + 'post_status' => 'publish', + 'posts_per_page' => $per_page, + 'paged' => $page, + 'ignore_sticky_posts' => $term_id > 0, + ]; + + if ($term_id > 0) { + $args['cat'] = $term_id; + } + + $exclude_ids = class_exists('AKSRT_Settings') + ? AKSRT_Settings::home_exclude_post_ids() + : []; + if (!empty($exclude_ids)) { + $args['post__not_in'] = $exclude_ids; + } + + if ($panel === 'latest' || $term_id <= 0) { + $exclude_cat_ids = class_exists('AKSRT_Settings') + ? AKSRT_Settings::home_exclude_category_ids() + : []; + if (!empty($exclude_cat_ids)) { + $args['category__not_in'] = $exclude_cat_ids; + } + } + + $effective_sort = $tab_orderby !== '' ? $tab_orderby : $sort; + if (function_exists('aksrt_homepage_apply_named_sort_args')) { + $args = aksrt_homepage_apply_named_sort_args($args, $effective_sort); + } else { + $args['orderby'] = 'date'; + $args['order'] = 'DESC'; + } + + $query = new WP_Query($args); + + $mode = in_array($view_mode, ['card', 'list'], true) ? $view_mode : 'list'; + $html = ''; + if (function_exists('aksrt_render_post_list')) { + $html = aksrt_render_post_list($query, [ + 'mode' => $mode, + 'context' => 'home', + ], false); + } elseif ($query->have_posts()) { + $html = '
'; + while ($query->have_posts()) { + $query->the_post(); + if (function_exists('aksrt_homepage_render_post_card')) { + ob_start(); + aksrt_homepage_render_post_card(); + $html .= ob_get_clean(); + } + } + $html .= '
'; + wp_reset_postdata(); + } + + $has_more = $page < (int) $query->max_num_pages; + + return $this->success([ + 'html' => $html, + 'total' => (int) $query->found_posts, + 'has_more' => $has_more, + ]); + } + + public function rest_archive_posts(WP_REST_Request $request): WP_REST_Response { + return $this->rest_homepage_tab_posts($request); + } + + public function rest_homepage_categories(): WP_REST_Response { + $cats = get_categories(['hide_empty' => false, 'orderby' => 'name', 'order' => 'ASC']); + $data = []; + foreach ($cats as $cat) { + $data[] = ['id' => $cat->term_id, 'name' => $cat->name, 'slug' => $cat->slug, 'count' => $cat->count, 'parent' => $cat->parent]; + } + return $this->success(['data' => $data]); + } + + public function rest_handle_friend_links(WP_REST_Request $request): WP_REST_Response { + if ($request->get_method() === 'GET') { + $links = aksrt_get_friend_links(); + return rest_ensure_response(['code' => 'success', 'data' => $links]); + } + + $params = $this->get_json_params($request); + $links = get_option('aksrt_friend_links', []); + $new_link = [ + 'id' => uniqid('fl_'), + 'name' => sanitize_text_field($params['name'] ?? ''), + 'url' => esc_url_raw($params['url'] ?? ''), + 'logo' => esc_url_raw($params['logo'] ?? ''), + 'description' => sanitize_textarea_field($params['description'] ?? ''), + 'sort_order' => (int) ($params['sort_order'] ?? 0), + 'active' => 1, + ]; + $links[] = $new_link; + usort($links, function ($a, $b) { + return ($a['sort_order'] ?? 0) <=> ($b['sort_order'] ?? 0); + }); + update_option('aksrt_friend_links', $links); + + return rest_ensure_response(['code' => 'success', 'data' => $new_link, 'message' => __('Friend link added.', 'aksrt')]); + } + + public function rest_delete_friend_link(WP_REST_Request $request): WP_REST_Response { + $id = $request->get_param('id'); + $links = get_option('aksrt_friend_links', []); + $links = array_filter($links, function ($link) use ($id) { + return ($link['id'] ?? '') !== $id; + }); + $links = array_values($links); + update_option('aksrt_friend_links', $links); + return rest_ensure_response(['code' => 'success', 'message' => __('Friend link deleted.', 'aksrt')]); + } + + public function rest_reorder_friend_links(WP_REST_Request $request): WP_REST_Response { + $ordered_ids = $request->get_param('ids'); + if (!is_array($ordered_ids)) { + return new WP_REST_Response(['code' => 'error', 'message' => __('参数错误', 'aksrt')], 400); + } + $links = get_option('aksrt_friend_links', []); + $order_map = array_flip($ordered_ids); + usort($links, function ($a, $b) use ($order_map) { + $idx_a = $order_map[($a['id'] ?? '')] ?? 999; + $idx_b = $order_map[($b['id'] ?? '')] ?? 999; + return $idx_a <=> $idx_b; + }); + foreach ($links as $index => &$link) { + $link['sort_order'] = $index; + } + update_option('aksrt_friend_links', $links); + return rest_ensure_response(['code' => 'success', 'message' => __('Order saved.', 'aksrt')]); + } + + public function rest_handle_footer_settings(WP_REST_Request $request): WP_REST_Response { + if ($request->get_method() === 'GET') { + $footer = get_option('aksrt_footer_settings', []); + $defaults = [ + 'contacts' => [], + 'quick_links' => [], + 'more_links' => [], + 'hot_categories' => 5, + 'copyright_text' => '', + 'layout_template' => 'default', + 'footer_t1_m_s' => ['pc', 'mobile'], + 'footer_t1_img' => '', + 'footer_t1_img_dark' => '', + 'footer_t1_t' => '', + 'fcode_t1_code' => '', + 'footer_contact_m_s' => ['pc', 'mobile'], + 'footer_contact_wechat_img' => '', + 'footer_contact_qq' => '', + 'footer_contact_weibo' => '', + 'footer_contact_email' => '', + 'footer_contact_more' => '', + 'fcode_t2_code_1' => '', + 'fcode_t2_code_2' => '', + 'footer_mini_img_m_s' => ['pc', 'mobile'], + 'footer_mini_img' => [], + 'fcode_customize_code' => '', + ]; + $data = array_merge($defaults, $footer); + return rest_ensure_response(['code' => 'success', 'data' => $data]); + } + + $params = $this->get_json_params($request); + $footer = [ + 'contacts' => isset($params['contacts']) ? $this->sanitize_contacts($params['contacts']) : [], + 'quick_links' => isset($params['quick_links']) ? $this->sanitize_link_list($params['quick_links']) : [], + 'more_links' => isset($params['more_links']) ? $this->sanitize_link_list($params['more_links']) : [], + 'hot_categories' => (int) ($params['hot_categories'] ?? 5), + 'copyright_text' => sanitize_textarea_field($params['copyright_text'] ?? ''), + 'layout_template' => in_array($params['layout_template'] ?? '', ['default', 'compact', 'centered'], true) + ? $params['layout_template'] : 'default', + 'footer_t1_m_s' => $this->sanitize_device_visibility($params['footer_t1_m_s'] ?? ['pc', 'mobile']), + 'footer_t1_img' => esc_url_raw($params['footer_t1_img'] ?? ''), + 'footer_t1_img_dark' => esc_url_raw($params['footer_t1_img_dark'] ?? ''), + 'footer_t1_t' => sanitize_text_field($params['footer_t1_t'] ?? ''), + 'fcode_t1_code' => wp_kses_post($params['fcode_t1_code'] ?? ''), + 'footer_contact_m_s' => $this->sanitize_device_visibility($params['footer_contact_m_s'] ?? ['pc', 'mobile']), + 'footer_contact_wechat_img' => esc_url_raw($params['footer_contact_wechat_img'] ?? ''), + 'footer_contact_qq' => sanitize_text_field($params['footer_contact_qq'] ?? ''), + 'footer_contact_weibo' => sanitize_text_field($params['footer_contact_weibo'] ?? ''), + 'footer_contact_email' => sanitize_email($params['footer_contact_email'] ?? ''), + 'footer_contact_more' => isset($params['footer_contact_more']) && is_array($params['footer_contact_more']) + ? $this->sanitize_contact_more($params['footer_contact_more']) : [], + 'fcode_t2_code_1' => wp_kses_post($params['fcode_t2_code_1'] ?? ''), + 'fcode_t2_code_2' => wp_kses_post($params['fcode_t2_code_2'] ?? ''), + 'footer_mini_img_m_s' => $this->sanitize_device_visibility($params['footer_mini_img_m_s'] ?? ['pc', 'mobile']), + 'footer_mini_img' => isset($params['footer_mini_img']) && is_array($params['footer_mini_img']) + ? $this->sanitize_mini_img_group($params['footer_mini_img']) : [], + 'fcode_customize_code' => wp_kses_post($params['fcode_customize_code'] ?? ''), + ]; + update_option('aksrt_footer_settings', $footer); + return rest_ensure_response(['code' => 'success', 'message' => __('Footer settings saved.', 'aksrt')]); + } + + private function sanitize_contacts(array $contacts): array { + $allowed_icons = ['github', 'x', 'twitter', 'email', 'wechat', 'qq', 'weibo', + 'telegram', 'discord', 'bilibili', 'linkedin', 'facebook', + 'youtube', 'instagram', 'rss', 'web', 'globe']; + $clean = []; + foreach ($contacts as $item) { + $icon = sanitize_text_field($item['icon'] ?? ''); + if (!in_array($icon, $allowed_icons)) { + continue; + } + $clean[] = [ + 'icon' => $icon, + 'url' => esc_url_raw($item['url'] ?? ''), + 'title' => sanitize_text_field($item['title'] ?? ''), + 'enabled' => !empty($item['enabled']), + ]; + } + return $clean; + } + + private function sanitize_link_list(array $links): array { + $clean = []; + foreach ($links as $item) { + $clean[] = [ + 'id' => isset($item['id']) ? sanitize_text_field($item['id']) : uniqid('link_'), + 'name' => sanitize_text_field($item['name'] ?? ''), + 'url' => esc_url_raw($item['url'] ?? '#'), + 'enabled' => !empty($item['enabled']), + 'sort_order' => (int) ($item['sort_order'] ?? 0), + ]; + } + return $clean; + } + + private function sanitize_device_visibility($value): array { + $arr = is_array($value) ? $value : [$value]; + $allowed = ['pc', 'mobile']; + $clean = array_values(array_intersect(array_map('sanitize_key', $arr), $allowed)); + return $clean ?: ['pc', 'mobile']; + } + + private function sanitize_contact_more(array $items): array { + $clean = []; + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + $clean[] = [ + 'desc' => sanitize_text_field($item['desc'] ?? ''), + 'icon' => sanitize_text_field($item['icon'] ?? ''), + 'link' => esc_url_raw($item['link'] ?? ''), + 'img' => esc_url_raw($item['img'] ?? ''), + ]; + } + return $clean; + } + + private function sanitize_mini_img_group(array $items): array { + $clean = []; + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + if (count($clean) >= 4) { + break; + } + $clean[] = [ + 'text' => sanitize_text_field($item['text'] ?? ''), + 'img' => esc_url_raw($item['img'] ?? ''), + ]; + } + return $clean; + } + + public function rest_handle_footer_tabbar(WP_REST_Request $request): WP_REST_Response { + if ($request->get_method() === 'GET') { + $settings = class_exists('AKSRT_Settings') + ? AKSRT_Settings::get_footer_tabbar_settings() + : ['enabled' => true, 'scroll_hide' => false, 'scroll_down_hide' => true, 'items' => []]; + return rest_ensure_response(['code' => 'success', 'data' => $settings]); + } + + $params = $this->get_json_params($request); + $items = isset($params['items']) ? array_map([$this, 'sanitize_tabbar_item'], (array) $params['items']) : []; + + $max_items = max(3, min(7, (int) ($params['max_items'] ?? 5))); + if (count($items) > $max_items) { + return new WP_REST_Response([ + 'code' => 'error', + 'message' => sprintf(__('Footer tabbar supports at most %d items.', 'aksrt'), $max_items), + ], 400); + } + + $tabbar = [ + 'enabled' => !empty($params['enabled']), + 'scroll_hide' => !empty($params['scroll_hide']), + 'scroll_down_hide' => !empty($params['scroll_down_hide']), + 'max_items' => $max_items, + 'icon_size' => max(16, min(32, (int) ($params['icon_size'] ?? 22))), + 'text_size' => max(8, min(14, (int) ($params['text_size'] ?? 10))), + 'show_text' => !empty($params['show_text']), + 'active_color' => sanitize_hex_color($params['active_color'] ?? '') ?: '#f04494', + 'bg_color' => sanitize_hex_color($params['bg_color'] ?? '') ?: '', + 'border' => !empty($params['border']), + 'shadow' => !empty($params['shadow']), + 'animation' => !empty($params['animation']), + 'double_click_home' => !empty($params['double_click_home']), + 'badge_dot' => !empty($params['badge_dot']), + 'items' => $items, + ]; + update_option('aksrt_footer_tabbar', $tabbar); + + if (class_exists('AKSRT_Settings')) { + AKSRT_Settings::flush_settings_cache(); + } + + return rest_ensure_response(['code' => 'success', 'message' => __('Footer tabbar settings saved.', 'aksrt')]); + } + +} diff --git a/inc/classes/admin/class-aksrt-admin-import-export-controller.php b/inc/classes/admin/class-aksrt-admin-import-export-controller.php new file mode 100644 index 00000000..c8594592 --- /dev/null +++ b/inc/classes/admin/class-aksrt-admin-import-export-controller.php @@ -0,0 +1,408 @@ + 'GET', + 'callback' => [$this, 'rest_export_settings'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Import settings + register_rest_route(self::API_NAMESPACE, '/settings/import', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_import_settings'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Reset a domain to defaults + register_rest_route(self::API_NAMESPACE, '/settings/reset/(?P[a-z_]+)', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_reset_domain'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Reset all schema-managed settings to defaults + register_rest_route(self::API_NAMESPACE, '/settings/reset-all', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_reset_all'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Health check / diagnostics + register_rest_route(self::API_NAMESPACE, '/settings/health', [ + 'methods' => 'GET', + 'callback' => [$this, 'rest_health_check'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Migration status + register_rest_route(self::API_NAMESPACE, '/settings/migration-status', [ + 'methods' => 'GET', + 'callback' => [$this, 'rest_migration_status'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Schema introspection (for frontend to discover available fields) + register_rest_route(self::API_NAMESPACE, '/settings/schema/(?P[a-z_]+)', [ + 'methods' => 'GET', + 'callback' => [$this, 'rest_get_schema'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/settings/schema/(?P[a-z_]+)/(?P
[a-z0-9_-]+)', [ + 'methods' => 'GET', + 'callback' => [$this, 'rest_get_schema_section'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/settings/schema/(?P[a-z_]+)/(?P
[a-z0-9_-]+)', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_save_schema_section'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Backup snapshots + register_rest_route(self::API_NAMESPACE, '/settings/backups', [ + 'methods' => 'GET', + 'callback' => [$this, 'list_setting_backups'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/settings/backups', [ + 'methods' => 'POST', + 'callback' => [$this, 'create_setting_backup'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/settings/backups/(?P\d+)/restore', [ + 'methods' => 'POST', + 'callback' => [$this, 'restore_setting_backup'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/settings/backups/(?P\d+)', [ + 'methods' => 'DELETE', + 'callback' => [$this, 'delete_setting_backup'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // ── Widget Template Management ── + + // Export current widget configuration + register_rest_route(self::API_NAMESPACE, '/widgets/export', [ + 'methods' => 'GET', + 'callback' => [$this, 'export_widgets'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Import widget configuration + register_rest_route(self::API_NAMESPACE, '/widgets/import', [ + 'methods' => 'POST', + 'callback' => [$this, 'import_widgets'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // List saved widget templates + register_rest_route(self::API_NAMESPACE, '/widgets/templates', [ + 'methods' => 'GET', + 'callback' => [$this, 'list_widget_templates'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Save current layout as a named template + register_rest_route(self::API_NAMESPACE, '/widgets/templates', [ + 'methods' => 'POST', + 'callback' => [$this, 'save_widget_template'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Apply a saved template + register_rest_route(self::API_NAMESPACE, '/widgets/templates/(?P\d+)/apply', [ + 'methods' => 'POST', + 'callback' => [$this, 'apply_widget_template'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Delete a saved template + register_rest_route(self::API_NAMESPACE, '/widgets/templates/(?P\d+)', [ + 'methods' => 'DELETE', + 'callback' => [$this, 'delete_widget_template'], + 'permission_callback' => [$this, 'check_permission'], + ]); + } + + /* ------------------------------------------------------------------ */ + /* Handlers */ + /* ------------------------------------------------------------------ */ + + /** + * Export all AKSRT settings as a JSON-serializable array. + */ + public function rest_export_settings(WP_REST_Request $request): WP_REST_Response { + $schema_provider = AKSRT_Admin_Schema_Provider::get_instance(); + $domains = $schema_provider->get_domain_keys(); + + $export = [ + 'version' => AKSRT_VERSION ?? '3.4.0', + 'exported' => gmdate('Y-m-d H:i:s'), + 'domains' => [], + ]; + + foreach ($domains as $domain) { + $export['domains'][$domain] = [ + 'option_key' => $schema_provider->get_option_key($domain), + 'data' => $schema_provider->read_settings($domain), + ]; + } + + // Also export standalone options from the registry + $standalone_keys = AKSRT_Settings::STANDALONE_OPTION_KEYS; + + $export['standalone'] = []; + foreach ($standalone_keys as $key) { + $val = get_option($key); + if ($val !== false) { + $export['standalone'][$key] = $val; + } + } + + return $this->success(['data' => $export]); + } + + /** + * Import settings from a JSON payload. + * + * Request body: { domains: { domain: { data: {...} } }, standalone: { key: value } } + * Optional: { dry_run: true } to validate without saving. + */ + public function rest_import_settings(WP_REST_Request $request): WP_REST_Response { + $params = $this->get_json_params($request); + $dry_run = !empty($params['dry_run']); + + if (empty($params['domains']) && empty($params['standalone'])) { + return $this->error('没有可导入的数据'); + } + + $schema_provider = AKSRT_Admin_Schema_Provider::get_instance(); + $results = []; + + // Import domain settings + if (!$dry_run) { + $backup = $this->create_backup_record('manual', __('导入前自动快照', 'aksrt')); + $this->persist_setting_backup($backup); + } + + // Import domain settings + if (!empty($params['domains']) && is_array($params['domains'])) { + foreach ($params['domains'] as $domain => $payload) { + $data = $payload['data'] ?? $payload; + if (!is_array($data)) { + $results[$domain] = ['status' => 'error', 'message' => 'Invalid data format']; + continue; + } + + // Validate + $validation = $schema_provider->validate($domain, $data); + if (!$validation['valid']) { + $results[$domain] = ['status' => 'error', 'errors' => $validation['errors']]; + continue; + } + + if (!$dry_run) { + $schema_provider->write_settings($domain, $data); + $results[$domain] = ['status' => 'imported']; + } else { + $results[$domain] = ['status' => 'valid']; + } + } + } + + // Import standalone options + if (!empty($params['standalone']) && is_array($params['standalone'])) { + foreach ($params['standalone'] as $key => $value) { + if (!preg_match('/^aksrt_/', $key)) { + $results[$key] = ['status' => 'skipped', 'message' => 'Not an AKSRT option']; + continue; + } + if (!$dry_run) { + update_option($key, $value); + $results[$key] = ['status' => 'imported']; + } else { + $results[$key] = ['status' => 'valid']; + } + } + } + + if (!$dry_run) { + AKSRT_Settings::flush_settings_cache(); + } + + return $this->success([ + 'dry_run' => $dry_run, + 'results' => $results, + ]); + } + + /** + * Reset a domain's settings to defaults. + */ + public function rest_reset_domain(WP_REST_Request $request): WP_REST_Response { + $domain = $request->get_param('domain'); + $schema_provider = AKSRT_Admin_Schema_Provider::get_instance(); + + if (!in_array($domain, $schema_provider->get_domain_keys(), true)) { + return $this->error('未知的设置域: ' . esc_html($domain), 404); + } + + $backup = $this->create_backup_record('reset_domain', sprintf(__('重置 %s 前快照', 'aksrt'), $domain)); + $this->persist_setting_backup($backup); + + $schema_provider->reset_to_defaults($domain); + + return $this->success([ + 'message' => sprintf('%s 域设置已重置为默认值', $domain), + 'data' => [ + 'backup_id' => $backup['id'], + ], + ]); + } + + public function rest_reset_all(WP_REST_Request $request): WP_REST_Response { + $schema_provider = AKSRT_Admin_Schema_Provider::get_instance(); + $backup = $this->create_backup_record('reset_all', __('重置全部前快照', 'aksrt')); + $this->persist_setting_backup($backup); + + foreach ($schema_provider->get_domain_keys() as $domain) { + $schema_provider->reset_to_defaults($domain); + } + + AKSRT_Settings::flush_settings_cache(); + + return $this->success([ + 'message' => __('全部 schema 托管设置已重置为默认值', 'aksrt'), + 'data' => [ + 'backup_id' => $backup['id'], + ], + ]); + } + + /** + * Health check — returns system diagnostics. + */ + public function rest_health_check(WP_REST_Request $request): WP_REST_Response { + global $wpdb; + + $checks = []; + + // PHP version + $checks['php_version'] = [ + 'label' => 'PHP 版本', + 'value' => PHP_VERSION, + 'status' => version_compare(PHP_VERSION, '8.1', '>=') ? 'ok' : 'warning', + ]; + + // WordPress version + $wp_version = get_bloginfo('version'); + $checks['wp_version'] = [ + 'label' => 'WordPress 版本', + 'value' => $wp_version, + 'status' => version_compare($wp_version, '6.4', '>=') ? 'ok' : 'warning', + ]; + + // Theme version + $theme = wp_get_theme(); + $checks['theme_version'] = [ + 'label' => '主题版本', + 'value' => $theme->get('Version') ?? 'unknown', + 'status' => 'ok', + ]; + + // Database tables + $expected_tables = [ + $wpdb->prefix . 'aksrt_credit_log', + $wpdb->prefix . 'aksrt_signin_log', + $wpdb->prefix . 'aksrt_invite', + $wpdb->prefix . 'aksrt_dl_token', + $wpdb->prefix . 'aksrt_notification', + ]; + $missing_tables = []; + foreach ($expected_tables as $table) { + if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)) !== $table) { + $missing_tables[] = str_replace($wpdb->prefix, '', $table); + } + } + $checks['db_tables'] = [ + 'label' => '数据表', + 'value' => empty($missing_tables) ? '全部就绪' : '缺少: ' . implode(', ', $missing_tables), + 'status' => empty($missing_tables) ? 'ok' : 'error', + ]; + + // Email verification backfill + $backfill_done = get_option('aksrt_email_verification_backfill_done', false); + $checks['email_backfill'] = [ + 'label' => '邮箱验证迁移', + 'value' => $backfill_done ? '已完成' : '未完成', + 'status' => $backfill_done ? 'ok' : 'info', + ]; + + // Avatar source + $avatar_source = AKSRT_Settings::get_all()['avatar_source'] ?? 'cn.cravatar.com'; + $checks['avatar_source'] = [ + 'label' => '头像服务', + 'value' => $avatar_source, + 'status' => $avatar_source === 'cn.cravatar.com' ? 'ok' : 'info', + ]; + + // Option count + $option_count = (int) $wpdb->get_var( + "SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name LIKE 'aksrt_%'" + ); + $checks['option_count'] = [ + 'label' => 'AKSRT 选项数', + 'value' => $option_count, + 'status' => $option_count < 100 ? 'ok' : 'warning', + ]; + + return $this->success(['checks' => $checks]); + } + + /** + * Migration status — reports schema version and pending migrations. + */ +} diff --git a/inc/classes/admin/class-aksrt-admin-message-controller.php b/inc/classes/admin/class-aksrt-admin-message-controller.php new file mode 100644 index 00000000..a87b6a1f --- /dev/null +++ b/inc/classes/admin/class-aksrt-admin-message-controller.php @@ -0,0 +1,254 @@ + 'GET', + 'callback' => [$this, 'list_notifications'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + 'args' => [ + 'type' => ['default' => '', 'sanitize_callback' => 'sanitize_text_field'], + 'page' => ['default' => 1, 'sanitize_callback' => 'absint'], + 'per_page' => ['default' => 20, 'sanitize_callback' => 'absint'], + ], + ]); + + // Unread count + register_rest_route(self::API_NAMESPACE, '/messages/unread', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_unread_count'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + ]); + + // Mark one notification read + register_rest_route(self::API_NAMESPACE, '/messages/(?P\d+)/read', [ + 'methods' => 'POST', + 'callback' => [$this, 'mark_read'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + ]); + + // Mark all notifications read + register_rest_route(self::API_NAMESPACE, '/messages/read-all', [ + 'methods' => 'POST', + 'callback' => [$this, 'mark_all_read'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + ]); + + // ── Private Messages / Conversations ── + + // List conversations + register_rest_route(self::API_NAMESPACE, '/conversations', [ + 'methods' => 'GET', + 'callback' => [$this, 'list_conversations'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + 'args' => [ + 'limit' => ['default' => 20, 'sanitize_callback' => 'absint'], + ], + ]); + + // Get thread messages + register_rest_route(self::API_NAMESPACE, '/conversations/(?P\d+)/messages', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_thread'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + 'args' => [ + 'page' => ['default' => 1, 'sanitize_callback' => 'absint'], + 'per_page' => ['default' => 30, 'sanitize_callback' => 'absint'], + ], + ]); + + // Send message in conversation + register_rest_route(self::API_NAMESPACE, '/conversations/(?P\d+)/messages', [ + 'methods' => 'POST', + 'callback' => [$this, 'send_message'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + 'args' => [ + 'content' => ['required' => true, 'sanitize_callback' => 'sanitize_textarea_field'], + ], + ]); + + // Create or get conversation with a user + register_rest_route(self::API_NAMESPACE, '/conversations/with/(?P\d+)', [ + 'methods' => 'POST', + 'callback' => [$this, 'get_or_create_conversation'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + ]); + + // Mark conversation read + register_rest_route(self::API_NAMESPACE, '/conversations/(?P\d+)/read', [ + 'methods' => 'POST', + 'callback' => [$this, 'mark_conversation_read'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + ]); + } + + // ── Notification handlers ── + + public function list_notifications(WP_REST_Request $request): WP_REST_Response { + $user_id = get_current_user_id(); + $type = $request->get_param('type'); + $per_page = min(100, (int) $request->get_param('per_page')); + $page = max(1, (int) $request->get_param('page')); + + if (!class_exists('AKSRT_Message')) { + return $this->success(['data' => [], 'total' => 0]); + } + + $msg = AKSRT_Message::get_instance(); + $args = [ + 'limit' => $per_page, + 'offset' => ($page - 1) * $per_page, + ]; + if ($type) { + $args['type'] = $type; + } + + $notifications = $msg->get_notifications($user_id, $args); + $unread = $msg->get_unread_count($user_id); + + return $this->success([ + 'data' => $notifications, + 'unread' => $unread, + ]); + } + + public function get_unread_count(WP_REST_Request $request): WP_REST_Response { + $user_id = get_current_user_id(); + if (!class_exists('AKSRT_Message')) { + return $this->success(['data' => ['unread' => 0]]); + } + $count = AKSRT_Message::get_instance()->get_unread_count($user_id); + return $this->success(['data' => ['unread' => $count]]); + } + + public function mark_read(WP_REST_Request $request): WP_REST_Response { + $user_id = get_current_user_id(); + $id = (string) $request->get_param('id'); + if (!class_exists('AKSRT_Message')) { + return $this->error('not_available', '消息系统未启用。', 501); + } + $result = AKSRT_Message::get_instance()->mark_read($user_id, $id); + return $this->success(['data' => ['success' => $result]]); + } + + public function mark_all_read(WP_REST_Request $request): WP_REST_Response { + $user_id = get_current_user_id(); + if (!class_exists('AKSRT_Message')) { + return $this->error('not_available', '消息系统未启用。', 501); + } + $result = AKSRT_Message::get_instance()->mark_all_read($user_id); + return $this->success(['data' => ['success' => $result]]); + } + + // ── Conversation handlers ── + + public function list_conversations(WP_REST_Request $request): WP_REST_Response { + $user_id = get_current_user_id(); + $limit = min(50, (int) $request->get_param('limit')); + + if (!class_exists('AKSRT_Private_Message')) { + return $this->success(['data' => []]); + } + + $conversations = AKSRT_Private_Message::get_instance()->get_conversations($user_id, $limit); + return $this->success(['data' => $conversations]); + } + + public function get_thread(WP_REST_Request $request): WP_REST_Response { + $user_id = get_current_user_id(); + $conv_id = (int) $request->get_param('id'); + $page = max(1, (int) $request->get_param('page')); + $per_page = min(50, (int) $request->get_param('per_page')); + + if (!class_exists('AKSRT_Private_Message')) { + return $this->error('not_available', '私信系统未启用。', 501); + } + + $result = AKSRT_Private_Message::get_instance()->get_thread($conv_id, $user_id, $page, $per_page); + if (is_wp_error($result)) { + return $this->error('thread_error', $result->get_error_message(), 403); + } + + return $this->success(['data' => $result]); + } + + public function send_message(WP_REST_Request $request): WP_REST_Response { + $user_id = get_current_user_id(); + $conv_id = (int) $request->get_param('id'); + $content = trim((string) $request->get_param('content')); + + if (!$content) { + return $this->error('content_required', '消息内容不能为空。', 400); + } + + if (!class_exists('AKSRT_Private_Message')) { + return $this->error('not_available', '私信系统未启用。', 501); + } + + $result = AKSRT_Private_Message::get_instance()->send($conv_id, $user_id, $content); + if (!$result['success']) { + return $this->error('send_failed', $result['message'] ?? '发送失败。', 500); + } + + return $this->success(['data' => $result]); + } + + public function get_or_create_conversation(WP_REST_Request $request): WP_REST_Response { + $user_id = get_current_user_id(); + $other_id = (int) $request->get_param('user_id'); + + if ($user_id === $other_id) { + return $this->error('self_message', '不能与自己创建对话。', 400); + } + + if (!class_exists('AKSRT_Private_Message')) { + return $this->error('not_available', '私信系统未启用。', 501); + } + + $conv_id = AKSRT_Private_Message::get_instance()->get_or_create_conversation($user_id, $other_id); + if (is_wp_error($conv_id)) { + return $this->error('create_failed', $conv_id->get_error_message(), 500); + } + + return $this->success(['data' => ['conversation_id' => $conv_id]]); + } + + public function mark_conversation_read(WP_REST_Request $request): WP_REST_Response { + $user_id = get_current_user_id(); + $conv_id = (int) $request->get_param('id'); + + if (!class_exists('AKSRT_Private_Message')) { + return $this->error('not_available', '私信系统未启用。', 501); + } + + AKSRT_Private_Message::get_instance()->mark_conversation_read($conv_id, $user_id); + return $this->success(['data' => ['success' => true]]); + } + + public function check_logged_in_permission(): bool { + return is_user_logged_in(); + } +} diff --git a/inc/classes/admin/class-aksrt-admin-page-display-controller.php b/inc/classes/admin/class-aksrt-admin-page-display-controller.php new file mode 100644 index 00000000..93031211 --- /dev/null +++ b/inc/classes/admin/class-aksrt-admin-page-display-controller.php @@ -0,0 +1,201 @@ +admin = $admin; + } + + public function register_routes(): void { + // Banners (generic CRUD — hero, banner kinds) + register_rest_route(self::API_NAMESPACE, '/banners', [ + 'methods' => 'GET', + 'callback' => [$this, 'rest_get_banners'], + 'permission_callback' => [$this, 'check_permission'], + ]); + register_rest_route(self::API_NAMESPACE, '/banners', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_create_banner'], + 'permission_callback' => [$this, 'check_permission'], + ]); + register_rest_route(self::API_NAMESPACE, '/banners/(?P[\w-]+)', [ + 'methods' => ['PUT', 'DELETE'], + 'callback' => [$this, 'rest_handle_banner_item'], + 'permission_callback' => [$this, 'check_permission'], + ]); + } + + private function normalize_banner_kind($kind): string { + return in_array($kind, ['hero', 'banner'], true) ? $kind : 'hero'; + } + + private function get_banner_kind_query(string $kind): array { + if ('banner' === $kind) { + return [ + [ + 'key' => '_aksrt_kind', + 'value' => 'banner', + ], + ]; + } + + return [ + 'relation' => 'OR', + [ + 'key' => '_aksrt_kind', + 'value' => 'hero', + ], + [ + 'key' => '_aksrt_kind', + 'compare' => 'NOT EXISTS', + ], + ]; + } + + public function rest_get_banners(WP_REST_Request $request): WP_REST_Response { + $kind = $this->normalize_banner_kind($request->get_param('kind') ?: 'hero'); + $args = [ + 'post_type' => 'aksrt_banner', + 'posts_per_page' => 100, + 'post_status' => 'any', + 'orderby' => 'menu_order', + 'order' => 'ASC', + 'meta_query' => $this->get_banner_kind_query($kind), + ]; + $posts = get_posts($args); + $banners = array_map(function ($post) { + $layers_json = get_post_meta($post->ID, '_layers', true); + return [ + 'id' => $post->ID, + 'title' => $post->post_title, + 'subtitle' => get_post_meta($post->ID, '_subtitle', true), + 'description' => get_post_meta($post->ID, '_description', true), + 'image_url' => get_post_meta($post->ID, '_image_url', true), + 'video_url' => get_post_meta($post->ID, '_video_url', true), + 'link_url' => get_post_meta($post->ID, '_link_url', true), + 'link_target' => get_post_meta($post->ID, '_link_target', true) ?: '_self', + 'text_align' => get_post_meta($post->ID, '_text_align', true) ?: 'left', + 'device_visibility' => get_post_meta($post->ID, '_device_visibility', true) ?: 'all', + 'enabled' => get_post_meta($post->ID, '_enabled', true) === '1', + 'show_text' => get_post_meta($post->ID, '_show_text', true) !== '0', + 'kind' => get_post_meta($post->ID, '_aksrt_kind', true) ?: 'hero', + 'sort_order' => $post->menu_order, + 'text_size_pc' => get_post_meta($post->ID, '_text_size_pc', true) ?: '', + 'text_size_m' => get_post_meta($post->ID, '_text_size_m', true) ?: '', + 'text_parallax' => get_post_meta($post->ID, '_text_parallax', true) === '1', + 'layers' => is_string($layers_json) && $layers_json !== '' ? json_decode($layers_json, true) : [], + ]; + }, $posts); + return rest_ensure_response(['code' => 'success', 'data' => $banners]); + } + + public function rest_create_banner(WP_REST_Request $request): WP_REST_Response { + $params = $this->get_json_params($request); + $kind = $this->normalize_banner_kind($params['kind'] ?? 'hero'); + $title = sanitize_text_field($params['title'] ?? ''); + $post_id = wp_insert_post([ + 'post_title' => '' !== $title ? $title : ('banner' === $kind ? 'Banner Slide' : 'Hero Slide'), + 'post_type' => 'aksrt_banner', + 'post_status' => 'publish', + 'menu_order' => (int) ($params['sort_order'] ?? 0), + ]); + + if (is_wp_error($post_id)) { + return new WP_REST_Response(['code' => 'error', 'message' => $post_id->get_error_message()], 400); + } + + update_post_meta($post_id, '_image_url', esc_url_raw($params['image_url'] ?? '')); + update_post_meta($post_id, '_link_url', esc_url_raw($params['link_url'] ?? '')); + update_post_meta($post_id, '_link_target', in_array($params['link_target'] ?? '', ['_self', '_blank'], true) ? $params['link_target'] : '_self'); + update_post_meta($post_id, '_subtitle', sanitize_text_field($params['subtitle'] ?? '')); + update_post_meta($post_id, '_description', sanitize_textarea_field($params['description'] ?? '')); + update_post_meta($post_id, '_video_url', esc_url_raw($params['video_url'] ?? '')); + update_post_meta($post_id, '_text_align', in_array($params['text_align'] ?? '', ['left', 'center', 'right'], true) ? $params['text_align'] : 'left'); + update_post_meta($post_id, '_device_visibility', in_array($params['device_visibility'] ?? '', ['all', 'pc', 'mobile'], true) ? $params['device_visibility'] : 'all'); + update_post_meta($post_id, '_enabled', !array_key_exists('enabled', $params) || !empty($params['enabled']) ? '1' : '0'); + update_post_meta($post_id, '_show_text', !array_key_exists('show_text', $params) || !empty($params['show_text']) ? '1' : '0'); + update_post_meta($post_id, '_aksrt_kind', $kind); + update_post_meta($post_id, '_text_size_pc', sanitize_text_field($params['text_size_pc'] ?? '')); + update_post_meta($post_id, '_text_size_m', sanitize_text_field($params['text_size_m'] ?? '')); + update_post_meta($post_id, '_text_parallax', !empty($params['text_parallax']) ? '1' : '0'); + if (isset($params['layers']) && is_array($params['layers'])) { + update_post_meta($post_id, '_layers', wp_json_encode($params['layers'])); + } + + return rest_ensure_response(['code' => 'success', 'data' => ['id' => $post_id], 'message' => __('幻灯片已创建', 'aksrt')]); + } + + public function rest_handle_banner_item(WP_REST_Request $request): WP_REST_Response { + $id = absint($request['id']); + if (!$id || get_post_type($id) !== 'aksrt_banner') { + return new WP_REST_Response(['code' => 'not_found', 'message' => __('幻灯片不存在', 'aksrt')], 404); + } + + if ($request->get_method() === 'DELETE') { + wp_delete_post($id, true); + return rest_ensure_response(['code' => 'success', 'message' => __('幻灯片已删除', 'aksrt')]); + } + + $params = $this->get_json_params($request); + $kind = $this->normalize_banner_kind($params['kind'] ?? get_post_meta($id, '_aksrt_kind', true) ?: 'hero'); + $title = sanitize_text_field($params['title'] ?? ''); + if ($title === '') { + return new WP_REST_Response(['code' => 'validation_error', 'message' => __('标题不能为空', 'aksrt')], 422); + } + + wp_update_post([ + 'ID' => $id, + 'post_title' => $title, + ]); + + update_post_meta($id, '_aksrt_kind', $kind); + update_post_meta($id, '_subtitle', sanitize_text_field($params['subtitle'] ?? '')); + update_post_meta($id, '_description', sanitize_textarea_field($params['description'] ?? '')); + update_post_meta($id, '_image_url', esc_url_raw($params['image_url'] ?? '')); + update_post_meta($id, '_video_url', esc_url_raw($params['video_url'] ?? '')); + update_post_meta($id, '_link_url', esc_url_raw($params['link_url'] ?? '')); + update_post_meta($id, '_link_target', in_array($params['link_target'] ?? '', ['_blank', '_self'], true) ? $params['link_target'] : '_self'); + update_post_meta($id, '_text_align', in_array($params['text_align'] ?? '', ['left', 'center', 'right'], true) ? $params['text_align'] : 'left'); + update_post_meta($id, '_device_visibility', in_array($params['device_visibility'] ?? '', ['all', 'pc', 'mobile'], true) ? $params['device_visibility'] : 'all'); + update_post_meta($id, '_enabled', !empty($params['enabled']) ? '1' : '0'); + update_post_meta($id, '_show_text', !isset($params['show_text']) || !empty($params['show_text']) ? '1' : '0'); + update_post_meta($id, '_show_meta', !empty($params['show_meta']) ? '1' : '0'); + update_post_meta($id, '_text_size_pc', sanitize_text_field($params['text_size_pc'] ?? '')); + update_post_meta($id, '_text_size_m', sanitize_text_field($params['text_size_m'] ?? '')); + update_post_meta($id, '_text_parallax', !empty($params['text_parallax']) ? '1' : '0'); + + if (isset($params['layers']) && is_array($params['layers'])) { + update_post_meta($id, '_layers', wp_json_encode(array_values($params['layers']))); + } else { + delete_post_meta($id, '_layers'); + } + + if (isset($params['sort_order'])) { + wp_update_post([ + 'ID' => $id, + 'menu_order' => (int) $params['sort_order'], + ]); + } + + return rest_ensure_response(['code' => 'success', 'message' => __('幻灯片已更新', 'aksrt')]); + } +} + diff --git a/inc/classes/admin/class-aksrt-admin-payment-controller.php b/inc/classes/admin/class-aksrt-admin-payment-controller.php new file mode 100644 index 00000000..0bd0352c --- /dev/null +++ b/inc/classes/admin/class-aksrt-admin-payment-controller.php @@ -0,0 +1,753 @@ +register_section_routes('payment', 'payment'); + + register_rest_route(self::API_NAMESPACE, '/payment/overview', [ + 'methods' => 'GET', + 'callback' => [$this, 'rest_payment_overview'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Revenue stats (with optional time-series) + register_rest_route(self::API_NAMESPACE, '/payment/revenue', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_revenue'], + 'permission_callback' => [$this, 'check_permission'], + 'args' => [ + 'date_from' => ['default' => '', 'sanitize_callback' => 'sanitize_text_field'], + 'date_to' => ['default' => '', 'sanitize_callback' => 'sanitize_text_field'], + 'granularity' => ['default' => 'day', 'sanitize_callback' => 'sanitize_text_field'], + 'time_series' => ['default' => false, 'sanitize_callback' => 'rest_sanitize_boolean'], + ], + ]); + + // Withdrawal list — unified pagination: page + per_page + register_rest_route(self::API_NAMESPACE, '/payment/withdrawals', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_withdrawals'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Withdrawal status update + register_rest_route(self::API_NAMESPACE, '/payment/withdrawals/(?P\d+)', [ + 'methods' => 'POST', + 'callback' => [$this, 'update_withdrawal_status'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // ── Charge Card ── + + // Charge card list + register_rest_route(self::API_NAMESPACE, '/payment/charge-cards', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_charge_cards'], + 'permission_callback' => [$this, 'check_permission'], + ]); + register_rest_route(self::API_NAMESPACE, '/payment/charge-cards/(?P\d+)', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_charge_card_detail'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Charge card create + register_rest_route(self::API_NAMESPACE, '/payment/charge-cards', [ + 'methods' => 'POST', + 'callback' => [$this, 'create_charge_card'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Charge card batch create + register_rest_route(self::API_NAMESPACE, '/payment/charge-cards/batch', [ + 'methods' => 'POST', + 'callback' => [$this, 'batch_create_charge_cards'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Charge card disable + register_rest_route(self::API_NAMESPACE, '/payment/charge-cards/(?P\d+)/disable', [ + 'methods' => 'POST', + 'callback' => [$this, 'disable_charge_card'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // ── Rebate ── + + // Rebate list + register_rest_route(self::API_NAMESPACE, '/payment/rebate/log', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_rebate_list'], + 'permission_callback' => [$this, 'check_permission'], + ]); + register_rest_route(self::API_NAMESPACE, '/payment/rebate/(?P\d+)', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_rebate_detail'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Rebate settle + register_rest_route(self::API_NAMESPACE, '/payment/rebate/(?P\d+)/settle', [ + 'methods' => 'POST', + 'callback' => [$this, 'settle_rebate'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Rebate cancel + register_rest_route(self::API_NAMESPACE, '/payment/rebate/(?P\d+)/cancel', [ + 'methods' => 'POST', + 'callback' => [$this, 'cancel_rebate'], + 'permission_callback' => [$this, 'check_permission'], + ]); + } + + public function rest_payment_overview(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Settings')) { + return $this->error('设置模块未初始化', 500); + } + + $paid = AKSRT_Settings::get_paid_content_settings(); + $recharge = AKSRT_Settings::get_payment_recharge_settings(); + $vip = AKSRT_Settings::get_payment_vip_settings(); + $revenue = AKSRT_Settings::get_payment_revenue_settings(); + $affiliate = AKSRT_Settings::get_payment_affiliate_settings(); + $gateway = AKSRT_Settings::get_payment_gateway_settings(); + + return $this->success([ + 'data' => [ + 'paid' => $paid, + 'recharge' => $recharge, + 'vip' => $vip, + 'revenue' => $revenue, + 'affiliate' => $affiliate, + 'gateway' => $gateway, + 'currency_name' => AKSRT_Settings::get_currency_name(), + ], + ]); + } + + /** + * GET /payment/revenue — revenue stats with optional time-series + */ + public function get_revenue(WP_REST_Request $request): WP_REST_Response { + $revenue_stats = ['total' => 0, 'pending' => 0, 'settled' => 0, 'count' => 0]; + if (class_exists('AKSRT_Revenue')) { + $revenue_stats = AKSRT_Revenue::get_instance()->get_stats(); + } + + $withdrawal_stats = ['total' => 0, 'pending' => 0, 'completed' => 0, 'total_paid' => 0]; + if (class_exists('AKSRT_Withdrawal')) { + $withdrawal_stats = AKSRT_Withdrawal::get_instance()->get_stats(); + } + + $response = [ + 'revenue' => $revenue_stats, + 'withdrawal' => $withdrawal_stats, + ]; + + // Time-series data + if ($request->get_param('time_series')) { + $response['time_series'] = $this->get_revenue_time_series($request); + } + + return $this->success(['data' => $response]); + } + + /** + * Build revenue time-series data. + */ + private function get_revenue_time_series(WP_REST_Request $request): array { + global $wpdb; + $prefix = $wpdb->prefix; + + $date_from = sanitize_text_field((string) $request->get_param('date_from')); + $date_to = sanitize_text_field((string) $request->get_param('date_to')); + $granularity = sanitize_text_field((string) $request->get_param('granularity')); + + // Default: last 30 days + if (!$date_from) { + $date_from = gmdate('Y-m-d', strtotime('-30 days')); + } + if (!$date_to) { + $date_to = gmdate('Y-m-d'); + } + + // Granularity: day, week, month + $date_format = '%Y-%m-%d'; + if ($granularity === 'week') { + $date_format = '%x-W%v'; + } elseif ($granularity === 'month') { + $date_format = '%Y-%m'; + } + + $table = $prefix . 'aksrt_revenue'; + $results = $wpdb->get_results($wpdb->prepare( + "SELECT DATE_FORMAT(created_at, %s) AS period, + SUM(amount) AS total_amount, + COUNT(*) AS order_count + FROM {$table} + WHERE created_at >= %s AND created_at < DATE_ADD(%s, INTERVAL 1 DAY) + GROUP BY period + ORDER BY period ASC", + $date_format, + $date_from, + $date_to + )); + + $series = []; + if ($results) { + foreach ($results as $row) { + $series[] = [ + 'date' => (string) $row->period, + 'amount' => (float) $row->total_amount, + 'count' => (int) $row->order_count, + ]; + } + } + + return $series; + } + + /** + * GET /payment/withdrawals — 提现列表,统一 page + per_page 分页 + */ + public function get_withdrawals(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Withdrawal')) { + return $this->success(['data' => ['items' => [], 'total' => 0, 'page' => 1, 'total_pages' => 1, 'stats' => []]]); + } + + $status = sanitize_key((string) $request->get_param('status')); + $page = max(1, (int) $request->get_param('page')); + $per_page = max(1, min(50, (int) $request->get_param('per_page'))) ?: 20; + + $items = AKSRT_Withdrawal::get_instance()->get_all([ + 'status' => $status, + 'limit' => $per_page, + 'offset' => ($page - 1) * $per_page, + ]); + + $total = AKSRT_Withdrawal::get_instance()->get_count(['status' => $status]); + $stats = AKSRT_Withdrawal::get_instance()->get_stats(); + + return $this->success([ + 'data' => [ + 'items' => $items, + 'total' => $total, + 'page' => $page, + 'total_pages' => max(1, (int) ceil($total / $per_page)), + 'stats' => $stats, + ], + ]); + } + + /** + * POST /payment/withdrawals/{id} — 更新提现状态 + */ + public function update_withdrawal_status(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Withdrawal')) { + return $this->error('提现模块未初始化', 500); + } + + $id = (int) $request->get_param('id'); + $params = $this->get_json_params($request); + $new_status = sanitize_key((string) ($params['status'] ?? '')); + $note = sanitize_textarea_field((string) ($params['note'] ?? '')); + + $valid_statuses = ['pending', 'approved', 'processing', 'completed', 'rejected']; + if (!in_array($new_status, $valid_statuses, true)) { + return $this->error('无效的提现状态'); + } + + if ($id <= 0) { + return $this->error('无效的提现 ID'); + } + + $success = AKSRT_Withdrawal::get_instance()->update_status($id, $new_status, $note); + + if ($success) { + return $this->success(['message' => __('提现状态已更新', 'aksrt')]); + } + return $this->error(__('更新失败', 'aksrt')); + } + + /* ------------------------------------------------------------------ + * Charge Card + * ----------------------------------------------------------------*/ + + /** + * GET /payment/charge-cards — 充值卡列表 + */ + public function get_charge_cards(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Charge_Card')) { + return $this->success(['data' => ['items' => [], 'total' => 0, 'page' => 1, 'max_pages' => 1]]); + } + + global $wpdb; + + $table = AKSRT_DB_Migrator::table('charge_card'); + $page = max(1, (int) ($request->get_param('page') ?: 1)); + $per_page = max(1, min(100, (int) ($request->get_param('per_page') ?: 20))); + $status = sanitize_key((string) $request->get_param('status')); + $keyword = trim(sanitize_text_field((string) $request->get_param('keyword'))); + $date_from = sanitize_text_field((string) $request->get_param('date_from')); + $date_to = sanitize_text_field((string) $request->get_param('date_to')); + + $where = []; + $params = []; + + if ($status !== '') { + $where[] = 'status = %s'; + $params[] = $status; + } + if ($date_from !== '') { + $where[] = 'created_at >= %s'; + $params[] = $date_from . ' 00:00:00'; + } + if ($date_to !== '') { + $where[] = 'created_at <= %s'; + $params[] = $date_to . ' 23:59:59'; + } + + $where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; + $sql = "SELECT * FROM {$table} {$where_sql} ORDER BY created_at DESC LIMIT 500"; + $rows = $params ? $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A) : $wpdb->get_results($sql, ARRAY_A); + + $items = []; + foreach ((array) $rows as $row) { + $item = $this->build_charge_card_admin_item($row); + if ($keyword !== '' && mb_stripos(implode(' ', array_filter([ + (string) ($item['code'] ?? ''), + (string) ($item['note'] ?? ''), + (string) ($item['used_by_user']['display_name'] ?? ''), + (string) ($item['used_by_user']['email'] ?? ''), + ])), $keyword) === false) { + continue; + } + $items[] = $item; + } + + $total = count($items); + $offset = ($page - 1) * $per_page; + $stats = AKSRT_Charge_Card::get_instance()->get_stats(); + + return $this->success([ + 'data' => [ + 'items' => array_values(array_slice($items, $offset, $per_page)), + 'total' => $total, + 'page' => $page, + 'per_page' => $per_page, + 'max_pages' => max(1, (int) ceil(max(1, $total) / $per_page)), + 'stats' => $stats, + 'options' => [ + 'statuses' => [ + ['label' => __('全部状态', 'aksrt'), 'value' => ''], + ['label' => __('未使用', 'aksrt'), 'value' => 'active'], + ['label' => __('已使用', 'aksrt'), 'value' => 'used'], + ['label' => __('已过期', 'aksrt'), 'value' => 'expired'], + ['label' => __('已禁用', 'aksrt'), 'value' => 'disabled'], + ], + ], + ], + ]); + } + + /** + * POST /payment/charge-cards — 创建充值卡 + */ + public function create_charge_card(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Charge_Card')) { + return $this->error('充值卡系统未启用', 500); + } + + $params = $this->get_json_params($request); + $code = sanitize_text_field((string) ($params['code'] ?? '')); + $amount = (float) ($params['amount'] ?? 0); + + if ($code === '' || $amount <= 0) { + return $this->error('卡号和金额不能为空'); + } + + $id = AKSRT_Charge_Card::get_instance()->create($params); + + if ($id > 0) { + return $this->success([ + 'message' => __('充值卡已创建', 'aksrt'), + 'data' => ['id' => $id], + ], 201); + } + + return $this->error(__('创建失败(可能卡号已存在)', 'aksrt')); + } + + /** + * POST /payment/charge-cards/batch — 批量生成充值卡 + */ + public function batch_create_charge_cards(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Charge_Card')) { + return $this->error('充值卡系统未启用', 500); + } + + $params = $this->get_json_params($request); + $amount = (float) ($params['amount'] ?? 0); + $count = max(1, min(100, (int) ($params['count'] ?? 1))); + $length = max(8, min(32, (int) ($params['length'] ?? 16))); + $prefix = sanitize_text_field((string) ($params['prefix'] ?? '')); + $note = sanitize_textarea_field((string) ($params['note'] ?? '')); + $expires = sanitize_text_field((string) ($params['expires_at'] ?? '')); + + if ($amount <= 0) { + return $this->error('金额必须大于 0'); + } + + $service = AKSRT_Charge_Card::get_instance(); + $codes = []; + for ($i = 0; $i < $count; $i++) { + $code = $prefix . strtoupper(wp_generate_password($length, false)); + $id = $service->create([ + 'code' => $code, + 'amount' => $amount, + 'expires_at' => $expires, + 'note' => $note, + ]); + if ($id > 0) { + $codes[] = $code; + } + } + + return $this->success([ + 'message' => sprintf(__('已生成 %d 张充值卡', 'aksrt'), count($codes)), + 'data' => ['codes' => $codes, 'count' => count($codes)], + ]); + } + + /** + * POST /payment/charge-cards/{id}/disable — 禁用充值卡 + */ + public function disable_charge_card(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Charge_Card')) { + return $this->error('充值卡系统未启用', 500); + } + + $id = (int) $request->get_param('id'); + if ($id <= 0) { + return $this->error('参数无效'); + } + + $success = AKSRT_Charge_Card::get_instance()->disable($id); + + if ($success) { + return $this->success(['message' => __('充值卡已禁用', 'aksrt')]); + } + return $this->error(__('操作失败', 'aksrt')); + } + + public function get_charge_card_detail(WP_REST_Request $request): WP_REST_Response { + global $wpdb; + + $id = (int) $request->get_param('id'); + $table = AKSRT_DB_Migrator::table('charge_card'); + + if ($id <= 0 || !$table) { + return $this->error(__('充值卡不存在', 'aksrt'), 404); + } + + $row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE id = %d", $id), ARRAY_A); + if (!$row) { + return $this->error(__('充值卡不存在', 'aksrt'), 404); + } + + return $this->success([ + 'data' => [ + 'card' => $this->build_charge_card_admin_item($row), + ], + ]); + } + + /* ------------------------------------------------------------------ + * Rebate + * ----------------------------------------------------------------*/ + + /** + * GET /payment/rebate/log — 返佣记录列表 + */ + public function get_rebate_list(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Rebate')) { + return $this->success(['data' => ['items' => [], 'total' => 0, 'page' => 1, 'max_pages' => 1]]); + } + + global $wpdb; + + $table = AKSRT_DB_Migrator::table('rebate_log'); + $page = max(1, (int) ($request->get_param('page') ?: 1)); + $per_page = max(1, min(100, (int) ($request->get_param('per_page') ?: 20))); + $status = sanitize_key((string) $request->get_param('status')); + $type = sanitize_key((string) $request->get_param('type')); + $keyword = trim(sanitize_text_field((string) $request->get_param('keyword'))); + $user_id = (int) $request->get_param('user_id'); + $date_from = sanitize_text_field((string) $request->get_param('date_from')); + $date_to = sanitize_text_field((string) $request->get_param('date_to')); + + $where = []; + $params = []; + + if ($status !== '') { + $where[] = 'status = %s'; + $params[] = $status; + } + if ($type !== '') { + $where[] = 'type = %s'; + $params[] = $type; + } + if ($user_id > 0) { + $where[] = 'user_id = %d'; + $params[] = $user_id; + } + if ($date_from !== '') { + $where[] = 'created_at >= %s'; + $params[] = $date_from . ' 00:00:00'; + } + if ($date_to !== '') { + $where[] = 'created_at <= %s'; + $params[] = $date_to . ' 23:59:59'; + } + + $where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; + $sql = "SELECT * FROM {$table} {$where_sql} ORDER BY created_at DESC LIMIT 500"; + $rows = $params ? $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A) : $wpdb->get_results($sql, ARRAY_A); + + $items = []; + foreach ((array) $rows as $row) { + $item = $this->build_rebate_admin_item($row); + if ($keyword !== '' && mb_stripos(implode(' ', array_filter([ + (string) ($item['order_number'] ?? ''), + (string) ($item['note'] ?? ''), + (string) ($item['user']['display_name'] ?? ''), + (string) ($item['source_user']['display_name'] ?? ''), + ])), $keyword) === false) { + continue; + } + $items[] = $item; + } + + $total = count($items); + $offset = ($page - 1) * $per_page; + $stats = AKSRT_Rebate::get_instance()->get_stats(); + + return $this->success([ + 'data' => [ + 'items' => array_values(array_slice($items, $offset, $per_page)), + 'total' => $total, + 'page' => $page, + 'per_page' => $per_page, + 'max_pages' => max(1, (int) ceil(max(1, $total) / $per_page)), + 'stats' => $stats, + 'options' => [ + 'statuses' => [ + ['label' => __('全部状态', 'aksrt'), 'value' => ''], + ['label' => __('待结算', 'aksrt'), 'value' => 'pending'], + ['label' => __('已结算', 'aksrt'), 'value' => 'settled'], + ['label' => __('已取消', 'aksrt'), 'value' => 'cancelled'], + ], + 'types' => [ + ['label' => __('全部类型', 'aksrt'), 'value' => ''], + ['label' => __('订单返佣', 'aksrt'), 'value' => 'order'], + ['label' => __('邀请返佣', 'aksrt'), 'value' => 'invite'], + ], + ], + ], + ]); + } + + /** + * POST /payment/rebate/{id}/settle — 结算返佣 + */ + public function settle_rebate(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Rebate')) { + return $this->error('返佣系统未启用', 500); + } + + $id = (int) $request->get_param('id'); + $params = $this->get_json_params($request); + $note = sanitize_textarea_field((string) ($params['note'] ?? '')); + if ($id <= 0) { + return $this->error('参数无效'); + } + + $success = AKSRT_Rebate::get_instance()->settle($id); + + if ($success) { + $this->append_rebate_process_log($id, 'settled', $note); + return $this->success(['message' => __('返佣已结算', 'aksrt')]); + } + return $this->error(__('结算失败', 'aksrt')); + } + + /** + * POST /payment/rebate/{id}/cancel — 取消返佣 + */ + public function cancel_rebate(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Rebate')) { + return $this->error('返佣系统未启用', 500); + } + + $id = (int) $request->get_param('id'); + $params = $this->get_json_params($request); + $note = sanitize_textarea_field((string) ($params['note'] ?? '')); + if ($id <= 0) { + return $this->error('参数无效'); + } + + $success = AKSRT_Rebate::get_instance()->cancel($id); + + if ($success) { + $this->append_rebate_process_log($id, 'cancelled', $note); + return $this->success(['message' => __('返佣已取消', 'aksrt')]); + } + return $this->error(__('操作失败', 'aksrt')); + } + + public function get_rebate_detail(WP_REST_Request $request): WP_REST_Response { + global $wpdb; + + $id = (int) $request->get_param('id'); + $table = AKSRT_DB_Migrator::table('rebate_log'); + + if ($id <= 0 || !$table) { + return $this->error(__('返佣记录不存在', 'aksrt'), 404); + } + + $row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE id = %d", $id), ARRAY_A); + if (!$row) { + return $this->error(__('返佣记录不存在', 'aksrt'), 404); + } + + return $this->success([ + 'data' => [ + 'record' => $this->build_rebate_admin_item($row), + 'history' => $this->get_rebate_process_log($id), + ], + ]); + } + + private function build_charge_card_admin_item(array $row): array { + $used_by = (int) ($row['used_by'] ?? 0); + $user = $used_by > 0 ? get_userdata($used_by) : null; + $status = sanitize_key((string) ($row['status'] ?? 'active')); + $statusMap = [ + 'active' => __('未使用', 'aksrt'), + 'used' => __('已使用', 'aksrt'), + 'expired' => __('已过期', 'aksrt'), + 'disabled' => __('已禁用', 'aksrt'), + ]; + + return [ + 'id' => (int) ($row['id'] ?? 0), + 'code' => (string) ($row['code'] ?? ''), + 'amount' => (float) ($row['amount'] ?? 0), + 'balance' => (float) ($row['balance'] ?? 0), + 'status' => $status, + 'status_label' => $statusMap[$status] ?? $status, + 'used_by' => $used_by, + 'used_at' => (string) ($row['used_at'] ?? ''), + 'expires_at' => (string) ($row['expires_at'] ?? ''), + 'created_at' => (string) ($row['created_at'] ?? ''), + 'note' => (string) ($row['note'] ?? ''), + 'used_by_user' => $user instanceof WP_User ? [ + 'id' => (int) $user->ID, + 'display_name' => $user->display_name, + 'email' => $user->user_email, + ] : null, + ]; + } + + private function build_rebate_admin_item(array $row): array { + $user_id = (int) ($row['user_id'] ?? 0); + $source_user_id = (int) ($row['source_user_id'] ?? 0); + $user = $user_id > 0 ? get_userdata($user_id) : null; + $source_user = $source_user_id > 0 ? get_userdata($source_user_id) : null; + $status = sanitize_key((string) ($row['status'] ?? 'pending')); + $type = sanitize_key((string) ($row['type'] ?? 'order')); + + $statusMap = [ + 'pending' => __('待结算', 'aksrt'), + 'settled' => __('已结算', 'aksrt'), + 'cancelled' => __('已取消', 'aksrt'), + ]; + $typeMap = [ + 'order' => __('订单返佣', 'aksrt'), + 'invite' => __('邀请返佣', 'aksrt'), + ]; + + return [ + 'id' => (int) ($row['id'] ?? 0), + 'order_number' => (string) ($row['order_number'] ?? ''), + 'amount' => (float) ($row['amount'] ?? 0), + 'status' => $status, + 'status_label' => $statusMap[$status] ?? $status, + 'type' => $type, + 'type_label' => $typeMap[$type] ?? $type, + 'created_at' => (string) ($row['created_at'] ?? ''), + 'settled_at' => (string) ($row['settled_at'] ?? ''), + 'note' => (string) ($row['note'] ?? ''), + 'history_count' => count($this->get_rebate_process_log((int) ($row['id'] ?? 0))), + 'user' => $user instanceof WP_User ? [ + 'id' => (int) $user->ID, + 'display_name' => $user->display_name, + 'email' => $user->user_email, + ] : null, + 'source_user' => $source_user instanceof WP_User ? [ + 'id' => (int) $source_user->ID, + 'display_name' => $source_user->display_name, + 'email' => $source_user->user_email, + ] : null, + ]; + } + + private function append_rebate_process_log(int $rebate_id, string $event, string $note = ''): void { + $log = get_option('aksrt_rebate_process_log', []); + if (!is_array($log)) { + $log = []; + } + + $rebate_id = (int) $rebate_id; + if (!isset($log[$rebate_id]) || !is_array($log[$rebate_id])) { + $log[$rebate_id] = []; + } + + $log[$rebate_id][] = [ + 'event' => sanitize_key($event), + 'note' => sanitize_textarea_field($note), + 'operator' => get_current_user_id(), + 'operator_name' => ($user = wp_get_current_user()) ? $user->display_name : '', + 'time' => current_time('mysql'), + ]; + + update_option('aksrt_rebate_process_log', $log, false); + } + + private function get_rebate_process_log(int $rebate_id): array { + $log = get_option('aksrt_rebate_process_log', []); + if (!is_array($log) || !isset($log[$rebate_id]) || !is_array($log[$rebate_id])) { + return []; + } + + return array_values($log[$rebate_id]); + } +} diff --git a/inc/classes/admin/class-aksrt-admin-permissions-controller.php b/inc/classes/admin/class-aksrt-admin-permissions-controller.php new file mode 100644 index 00000000..8c9cabf4 --- /dev/null +++ b/inc/classes/admin/class-aksrt-admin-permissions-controller.php @@ -0,0 +1,171 @@ + 'GET', + 'callback' => [$this, 'get_capabilities'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/permissions/capabilities', [ + 'methods' => 'POST', + 'callback' => [$this, 'save_capabilities'], + 'permission_callback' => [$this, 'check_permission'], + ]); + } + + public function get_capabilities(): WP_REST_Response { + if (!class_exists('AKSRT_Settings')) { + return $this->error(__('能力矩阵模块不可用', 'aksrt'), 500); + } + + $caps = AKSRT_Settings::get_user_capabilities(); + $defs = AKSRT_Settings::cap_definitions(); + $columns = AKSRT_Settings::cap_role_columns(); + + return $this->success([ + 'data' => [ + 'columns' => $this->get_capability_column_labels(), + 'groups' => $this->build_capability_groups($defs, $caps, $columns), + 'summary' => [ + 'cap_count' => count($defs), + 'group_count' => 3, + 'column_count' => count($columns), + ], + ], + ]); + } + + public function save_capabilities(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Settings')) { + return $this->error(__('能力矩阵模块不可用', 'aksrt'), 500); + } + + $params = $this->get_json_params($request); + $input = $params['user_caps'] ?? null; + + if (!is_array($input)) { + return $this->error(__('无效的能力矩阵数据', 'aksrt'), 422); + } + + $defs = AKSRT_Settings::cap_definitions(); + $columns = AKSRT_Settings::cap_role_columns(); + $clean = []; + + foreach ($defs as $cap_id => $def) { + $row = is_array($input[$cap_id] ?? null) ? $input[$cap_id] : []; + $clean[$cap_id] = []; + + foreach ($columns as $column) { + if (in_array($column, ['level', 'vip'], true)) { + $value = isset($row[$column]) ? (int) $row[$column] : -1; + $max = $column === 'level' ? 10 : 2; + $clean[$cap_id][$column] = max(-1, min($max, $value)); + } else { + $clean[$cap_id][$column] = !empty($row[$column]); + } + } + } + + $all = AKSRT_Settings::get_all(); + $all['user_caps'] = $clean; + update_option('aksrt_settings', $all); + AKSRT_Settings::flush_settings_cache(); + + return $this->success([ + 'message' => __('能力矩阵已保存', 'aksrt'), + 'data' => [ + 'user_caps' => $clean, + ], + ]); + } + + private function build_capability_groups(array $defs, array $caps, array $columns): array { + $buckets = [ + 'user' => [ + 'key' => 'user', + 'title' => __('用户治理', 'aksrt'), + 'items' => [], + ], + 'post' => [ + 'key' => 'post', + 'title' => __('投稿权限', 'aksrt'), + 'items' => [], + ], + 'comment' => [ + 'key' => 'comment', + 'title' => __('评论权限', 'aksrt'), + 'items' => [], + ], + ]; + + foreach ($defs as $cap_id => $def) { + $bucket_key = 'user'; + if (str_starts_with($cap_id, 'comment_')) { + $bucket_key = 'comment'; + } elseif (str_starts_with($cap_id, 'new_post_')) { + $bucket_key = 'post'; + } + + $excludes = $def['exclude_roles'] ?? []; + $only_roles = $def['roles'] ?? null; + $values = $caps[$cap_id] ?? []; + + $visible_columns = []; + foreach ($columns as $column) { + if (in_array($column, $excludes, true)) { + continue; + } + if ($only_roles !== null && !in_array($column, $only_roles, true) && !in_array($column, ['all', 'logged', 'level', 'vip', 'auth'], true)) { + continue; + } + $visible_columns[] = $column; + } + + $buckets[$bucket_key]['items'][] = [ + 'cap_id' => $cap_id, + 'label' => $def['label'], + 'visible_columns' => $visible_columns, + 'values' => $values, + ]; + } + + return array_values($buckets); + } + + private function get_capability_column_labels(): array { + return [ + 'all' => __('所有人', 'aksrt'), + 'logged' => __('已登录', 'aksrt'), + 'level' => __('等级≥', 'aksrt'), + 'vip' => __('VIP≥', 'aksrt'), + 'auth' => __('已认证', 'aksrt'), + 'moderator' => __('版主', 'aksrt'), + 'plate_author' => __('超级版主', 'aksrt'), + 'cat_moderator' => __('分区版主', 'aksrt'), + ]; + } +} diff --git a/inc/classes/admin/class-aksrt-admin-post-list-controller.php b/inc/classes/admin/class-aksrt-admin-post-list-controller.php new file mode 100644 index 00000000..c4da6b7d --- /dev/null +++ b/inc/classes/admin/class-aksrt-admin-post-list-controller.php @@ -0,0 +1,24 @@ +register_section_routes('post-list', 'post_list'); + } +} diff --git a/inc/classes/admin/class-aksrt-admin-schema-provider.php b/inc/classes/admin/class-aksrt-admin-schema-provider.php new file mode 100644 index 00000000..0dbac292 --- /dev/null +++ b/inc/classes/admin/class-aksrt-admin-schema-provider.php @@ -0,0 +1,384 @@ + section -> group -> field. + * + * @package AKSRT + */ + +defined('ABSPATH') || exit; + +class AKSRT_Admin_Schema_Provider { + + private static $instance = null; + + /** + * @var array> + */ + private $domain_schemas = []; + + public static function get_instance(): self { + if (null === self::$instance) { + self::$instance = new self(); + } + + return self::$instance; + } + + public function normalize_domain(string $domain): string { + $domain = trim(str_replace('-', '_', $domain)); + return $domain !== '' ? $domain : 'global'; + } + + public function normalize_section(string $section): string { + return trim($section); + } + + public function get_domain_keys(): array { + return [ + 'global', + 'page_display', + 'post_list', + 'permissions', + 'users', + 'payment', + 'extensions', + 'tools', + 'shop', + 'forum', + ]; + } + + public function get_schema(string $domain): ?array { + $domain = $this->normalize_domain($domain); + + if (isset($this->domain_schemas[$domain])) { + return $this->domain_schemas[$domain]; + } + + $builder_map = $this->get_schema_builder_map(); + if (!isset($builder_map[$domain])) { + return null; + } + + $builder = $builder_map[$domain]; + $this->domain_schemas[$domain] = $builder::build(); + + return $this->domain_schemas[$domain]; + } + + public function get_sections(string $domain): array { + $schema = $this->get_schema($domain); + return is_array($schema['sections'] ?? null) ? $schema['sections'] : []; + } + + public function get_section_schema(string $domain, string $section): ?array { + $sections = $this->get_sections($domain); + $section = $this->normalize_section($section); + return $sections[$section] ?? null; + } + + public function get_option_key(string $domain, string $section = ''): string { + if ($section !== '') { + $schema = $this->get_section_schema($domain, $section); + return (string) ($schema['option_key'] ?? 'aksrt_settings'); + } + + $primary = [ + 'global' => 'aksrt_settings', + 'page_display' => 'aksrt_settings', + 'post_list' => 'aksrt_settings', + 'permissions' => 'aksrt_settings', + 'users' => 'aksrt_settings', + 'payment' => 'aksrt_settings', + 'extensions' => 'aksrt_settings', + 'tools' => 'aksrt_settings', + 'shop' => 'aksrt_settings', + 'forum' => 'aksrt_settings', + ]; + + return $primary[$this->normalize_domain($domain)] ?? 'aksrt_settings'; + } + + public function get_defaults(string $domain, string $section = ''): array { + if ($section !== '') { + $schema = $this->get_section_schema($domain, $section); + return $schema ? $this->collect_defaults($schema) : []; + } + + $defaults = []; + foreach ($this->get_sections($domain) as $section_schema) { + $defaults = array_merge($defaults, $this->collect_defaults($section_schema)); + } + + return $defaults; + } + + public function get_allowed_fields(string $domain, string $section = ''): array { + if ($section !== '') { + $schema = $this->get_section_schema($domain, $section); + return $schema ? $this->collect_allowed_fields($schema) : []; + } + + $allowed = []; + foreach ($this->get_sections($domain) as $section_schema) { + $allowed = array_merge($allowed, $this->collect_allowed_fields($section_schema)); + } + + return $allowed; + } + + public function validate(string $domain, array $data, string $section = ''): array { + $schemas = []; + + if ($section !== '') { + $schema = $this->get_section_schema($domain, $section); + if (!$schema) { + return ['valid' => false, 'errors' => ['Unknown section']]; + } + $schemas[] = $schema; + } else { + $schemas = array_values($this->get_sections($domain)); + } + + $errors = []; + + foreach ($schemas as $schema) { + foreach ($this->get_all_fields($schema) as $field) { + $key = $field['key']; + if (!array_key_exists($key, $data)) { + continue; + } + + $value = $data[$key]; + if (!empty($field['required']) && ($value === '' || $value === null)) { + $errors[] = sprintf('%s is required', $field['label'] ?? $key); + continue; + } + + $type = $field['type'] ?? 'input'; + if (($type === 'number' || $type === 'slider') && !is_numeric($value)) { + $errors[] = sprintf('%s must be a number', $field['label'] ?? $key); + continue; + } + + if ($type === 'checkbox' && !is_array($value)) { + $errors[] = sprintf('%s must be an array', $field['label'] ?? $key); + continue; + } + + if ($type === 'repeater' && !is_array($value)) { + $errors[] = sprintf('%s must be an array of items', $field['label'] ?? $key); + continue; + } + + if (isset($field['min']) && is_numeric($value) && $value < $field['min']) { + $errors[] = sprintf('%s must be at least %s', $field['label'] ?? $key, $field['min']); + } + + if (isset($field['max']) && is_numeric($value) && $value > $field['max']) { + $errors[] = sprintf('%s must be at most %s', $field['label'] ?? $key, $field['max']); + } + + if (in_array($type, ['select', 'radio'], true) && !empty($field['options'])) { + $valid_values = array_column($field['options'], 'value'); + if (!in_array($value, $valid_values, true)) { + $errors[] = sprintf('%s has an invalid value', $field['label'] ?? $key); + } + } + + if ($type === 'checkbox' && !empty($field['options'])) { + $valid_values = array_column($field['options'], 'value'); + foreach ((array) $value as $item) { + if (!in_array($item, $valid_values, true)) { + $errors[] = sprintf('%s has an invalid value', $field['label'] ?? $key); + break; + } + } + } + } + } + + return ['valid' => empty($errors), 'errors' => $errors]; + } + + public function read_settings(string $domain, string $section = ''): array { + if ($section !== '') { + $schema = $this->get_section_schema($domain, $section); + if (!$schema) { + return []; + } + + return $this->read_section_settings($schema); + } + + $settings = []; + foreach ($this->get_sections($domain) as $section_schema) { + $settings = array_merge($settings, $this->read_section_settings($section_schema)); + } + + return $settings; + } + + public function write_settings(string $domain, array $data, string $section = ''): bool { + if ($section !== '') { + $schema = $this->get_section_schema($domain, $section); + if (!$schema) { + return false; + } + + return $this->write_section_settings($schema, $data); + } + + $result = true; + foreach ($this->get_sections($domain) as $section_schema) { + $allowed = array_keys($this->collect_allowed_fields($section_schema)); + $payload = array_intersect_key($data, array_flip($allowed)); + if ($payload === []) { + continue; + } + $result = $this->write_section_settings($section_schema, $payload) && $result; + } + + return $result; + } + + public function reset_to_defaults(string $domain, string $section = ''): bool { + if ($section !== '') { + return $this->write_settings($domain, $this->get_defaults($domain, $section), $section); + } + + return $this->write_settings($domain, $this->get_defaults($domain)); + } + + private function get_schema_builder_map(): array { + return [ + 'global' => 'AKSRT_Admin_Schema_Global', + 'page_display' => 'AKSRT_Admin_Schema_Page_Display', + 'post_list' => 'AKSRT_Admin_Schema_Post_List', + 'permissions' => 'AKSRT_Admin_Schema_Permissions', + 'users' => 'AKSRT_Admin_Schema_Users', + 'payment' => 'AKSRT_Admin_Schema_Payment', + 'extensions' => 'AKSRT_Admin_Schema_Extensions', + 'tools' => 'AKSRT_Admin_Schema_Tools', + 'shop' => 'AKSRT_Admin_Schema_Shop', + 'forum' => 'AKSRT_Admin_Schema_Forum', + ]; + } + + private function read_section_settings(array $schema): array { + $option_key = (string) ($schema['option_key'] ?? 'aksrt_settings'); + $stored = get_option($option_key, []); + if (!is_array($stored)) { + $stored = []; + } + + $data = $this->collect_defaults($schema); + foreach ($this->get_all_fields($schema) as $field) { + if (array_key_exists($field['key'], $stored)) { + $data[$field['key']] = $stored[$field['key']]; + } + } + + return $data; + } + + private function write_section_settings(array $schema, array $data): bool { + $option_key = (string) ($schema['option_key'] ?? 'aksrt_settings'); + $stored = get_option($option_key, []); + if (!is_array($stored)) { + $stored = []; + } + + $allowed = $this->collect_allowed_fields($schema); + foreach ($allowed as $key => $type) { + if (!array_key_exists($key, $data)) { + continue; + } + + $value = $this->sanitize_value($data[$key], $type); + $stored[$key] = $value; + } + + $updated = update_option($option_key, $stored); + AKSRT_Settings::flush_settings_cache(); + + if (isset($stored['site_favicon'])) { + AKSRT_Settings::sync_native_site_icon((string) $stored['site_favicon']); + } + + return $updated || true; + } + + private function collect_defaults(array $schema): array { + $defaults = []; + foreach ($this->get_all_fields($schema) as $field) { + if (array_key_exists('default', $field)) { + $defaults[$field['key']] = $field['default']; + } + } + return $defaults; + } + + private function collect_allowed_fields(array $schema): array { + $allowed = []; + foreach ($this->get_all_fields($schema) as $field) { + $allowed[$field['key']] = $field['sanitize_type'] ?? $this->infer_sanitize_type($field['type'] ?? 'input'); + } + return $allowed; + } + + private function get_all_fields(array $schema): array { + $fields = []; + foreach ($schema['groups'] ?? [] as $group) { + foreach ($group['fields'] ?? [] as $field) { + $fields[] = $field; + } + } + return $fields; + } + + private function sanitize_value($value, string $type) { + switch ($type) { + case 'html': + return wp_kses_post((string) $value); + case 'code': + return is_string($value) ? wp_unslash($value) : ''; + case 'url': + return esc_url_raw((string) $value); + case 'int': + return (int) $value; + case 'bool': + return !empty($value); + case 'float': + return (float) $value; + case 'array': + return is_array($value) ? $value : []; + default: + return sanitize_text_field((string) $value); + } + } + + private function infer_sanitize_type(string $field_type): string { + $map = [ + 'switch' => 'bool', + 'input' => 'string', + 'text' => 'string', + 'textarea' => 'string', + 'number' => 'int', + 'slider' => 'int', + 'select' => 'string', + 'radio' => 'string', + 'checkbox' => 'array', + 'repeater' => 'array', + 'color' => 'string', + 'icon' => 'string', + 'media' => 'url', + 'code' => 'code', + ]; + + return $map[$field_type] ?? 'string'; + } +} diff --git a/inc/classes/admin/class-aksrt-admin-shop-controller-coupon-gateway-operations.php b/inc/classes/admin/class-aksrt-admin-shop-controller-coupon-gateway-operations.php new file mode 100644 index 00000000..cbe77a6a --- /dev/null +++ b/inc/classes/admin/class-aksrt-admin-shop-controller-coupon-gateway-operations.php @@ -0,0 +1,97 @@ +success(['data' => ['items' => [], 'total' => 0, 'page' => 1, 'total_pages' => 1]]); + } + + $page = max(1, (int) $request->get_param('page')); + $per_page = max(1, min(100, (int) $request->get_param('per_page'))) ?: 20; + $status = sanitize_key((string) $request->get_param('status')); + $search = sanitize_text_field((string) $request->get_param('search')); + + $result = AKSRT_Coupon::get_instance()->get_list([ + 'status' => $status, + 'search' => $search, + 'limit' => $per_page, + 'offset' => ($page - 1) * $per_page, + ]); + + return $this->success(['data' => $result]); + } + + /** + * POST /shop/coupons — 创建优惠券 + */ + public function create_coupon(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Coupon')) { + return $this->error('优惠券系统未启用', 500); + } + + $params = $this->get_json_params($request); + $code = sanitize_text_field((string) ($params['code'] ?? '')); + + if ($code === '') { + return $this->error('优惠码不能为空'); + } + + $id = AKSRT_Coupon::get_instance()->create($params); + + if ($id > 0) { + return $this->success([ + 'message' => __('优惠券已创建', 'aksrt'), + 'data' => ['id' => $id], + ], 201); + } + + return $this->error(__('创建失败(可能优惠码已存在)', 'aksrt')); + } + + /** + * POST /shop/coupons/{id}/disable — 禁用优惠券 + */ + public function disable_coupon(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Coupon')) { + return $this->error('优惠券系统未启用', 500); + } + + $id = (int) $request->get_param('id'); + if ($id <= 0) { + return $this->error('参数无效'); + } + + $success = AKSRT_Coupon::get_instance()->disable($id); + + if ($success) { + return $this->success(['message' => __('优惠券已禁用', 'aksrt')]); + } + return $this->error(__('操作失败', 'aksrt')); + } + + /** + * GET /shop/gateways — 支付网关状态列表 + */ + public function get_gateways(): WP_REST_Response { + if (!class_exists('AKSRT_Payment_Gateway')) { + return $this->success(['data' => ['gateways' => []]]); + } + + return $this->success([ + 'data' => [ + 'gateways' => AKSRT_Payment_Gateway::get_instance()->get_status_summary(), + ], + ]); + } +} diff --git a/inc/classes/admin/class-aksrt-admin-shop-controller-frontend-flow-operations.php b/inc/classes/admin/class-aksrt-admin-shop-controller-frontend-flow-operations.php new file mode 100644 index 00000000..63430a09 --- /dev/null +++ b/inc/classes/admin/class-aksrt-admin-shop-controller-frontend-flow-operations.php @@ -0,0 +1,324 @@ +error('商城服务不可用', 500); + } + + $result = AKSRT_Shop::get_instance()->preview_checkout(get_current_user_id(), $this->get_json_params($request)); + if (empty($result['success'])) { + return $this->error($result['message'] ?? '结算预览失败'); + } + + return $this->success(['data' => $result['data']]); + } + + public function create_frontend_order(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Shop')) { + return $this->error('商城服务不可用', 500); + } + + $result = AKSRT_Shop::get_instance()->create_checkout_order(get_current_user_id(), $this->get_json_params($request)); + if (empty($result['success'])) { + return $this->error($result['message'] ?? '订单创建失败'); + } + + return $this->success([ + 'message' => __('订单已创建', 'aksrt'), + 'data' => $result['data'], + ], 201); + } + + public function get_frontend_orders(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Shop')) { + return $this->success(['data' => ['items' => [], 'total' => 0, 'total_pages' => 1, 'page' => 1]]); + } + + $page = max(1, (int) $request->get_param('page')); + $per_page = max(1, min(50, (int) $request->get_param('per_page'))) ?: 20; + $status = sanitize_key((string) $request->get_param('status')); + + $view = AKSRT_Shop::get_instance()->get_order_center_view(get_current_user_id(), [ + 'page' => $page, + 'per_page' => $per_page, + 'status' => $status, + ]); + + return $this->success(['data' => $view]); + } + + public function get_frontend_order(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Shop')) { + return $this->error('商城服务不可用', 500); + } + + $order_number = sanitize_text_field((string) $request->get_param('order_number')); + $order = AKSRT_Shop::get_instance()->get_order_detail_view(get_current_user_id(), $order_number); + if (!$order) { + return $this->error('订单不存在', 404); + } + + return $this->success(['data' => $order]); + } + + public function pay_frontend_order(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Shop')) { + return $this->error('商城服务不可用', 500); + } + + $params = $this->get_json_params($request); + $payment_method = sanitize_key((string) ($params['payment_method'] ?? 'balance')); + $order_number = sanitize_text_field((string) $request->get_param('order_number')); + + $result = AKSRT_Shop::get_instance()->pay_order(get_current_user_id(), $order_number, $payment_method); + if (empty($result['success'])) { + return $this->error($result['message'] ?? '支付失败'); + } + + return $this->success(['data' => $result['data']]); + } + + public function cancel_frontend_order(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Shop')) { + return $this->error('商城服务不可用', 500); + } + + $order_number = sanitize_text_field((string) $request->get_param('order_number')); + $result = AKSRT_Shop::get_instance()->cancel_order(get_current_user_id(), $order_number); + if (empty($result['success'])) { + return $this->error($result['message'] ?? '取消订单失败'); + } + + return $this->success(['data' => $result['data']]); + } + + public function create_frontend_after_sale(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Shop')) { + return $this->error('商城服务不可用', 500); + } + + $params = $this->get_json_params($request); + $params['order_number'] = sanitize_text_field((string) $request->get_param('order_number')); + $result = AKSRT_Shop::get_instance()->create_after_sale_request(get_current_user_id(), $params); + if (empty($result['success'])) { + return $this->error($result['message'] ?? '提交售后失败'); + } + + return $this->success([ + 'message' => __('售后申请已提交', 'aksrt'), + 'data' => $result['data'], + ], 201); + } + + public function get_cart(WP_REST_Request $request): WP_REST_Response { + $user_id = get_current_user_id(); + $shop = AKSRT_Shop::get_instance(); + $cart = $shop->get_cart($user_id); + + $items = []; + foreach ($cart as $pid => $opts_map) { + $product = $shop->build_product_card_view((int) $pid); + if (empty($product) || !is_array($opts_map)) { + continue; + } + foreach ($opts_map as $opt_str => $qty) { + $items[] = [ + 'product_id' => (int) $pid, + 'options_string' => $opt_str === '_default' ? '' : $opt_str, + 'quantity' => (int) $qty, + 'product' => $product, + ]; + } + } + + return $this->success([ + 'data' => [ + 'items' => $items, + 'count' => $shop->get_cart_count($user_id), + ], + ]); + } + + public function get_cart_count(WP_REST_Request $request): WP_REST_Response { + $user_id = get_current_user_id(); + return $this->success(['data' => ['count' => AKSRT_Shop::get_instance()->get_cart_count($user_id)]]); + } + + public function handle_add_to_cart(WP_REST_Request $request): WP_REST_Response { + $params = $this->get_json_params($request); + $product_id = (int) ($params['product_id'] ?? 0); + $options_string = sanitize_text_field((string) ($params['options_string'] ?? '')); + $count = max(1, (int) ($params['count'] ?? 1)); + + $result = AKSRT_Shop::get_instance()->add_to_cart(get_current_user_id(), $product_id, $options_string, $count); + + if (isset($result['error'])) { + return $this->error($result['error']); + } + return $this->success(['data' => $result]); + } + + public function handle_reduce_from_cart(WP_REST_Request $request): WP_REST_Response { + $params = $this->get_json_params($request); + $product_id = (int) ($params['product_id'] ?? 0); + $options_string = sanitize_text_field((string) ($params['options_string'] ?? '')); + $count = max(1, (int) ($params['count'] ?? 1)); + + $result = AKSRT_Shop::get_instance()->reduce_from_cart(get_current_user_id(), $product_id, $options_string, $count); + + if (isset($result['error'])) { + return $this->error($result['error']); + } + return $this->success(['data' => $result]); + } + + public function handle_remove_from_cart(WP_REST_Request $request): WP_REST_Response { + $params = $this->get_json_params($request); + $product_id = (int) ($params['product_id'] ?? 0); + $options_string = sanitize_text_field((string) ($params['options_string'] ?? '')); + + $result = AKSRT_Shop::get_instance()->remove_from_cart(get_current_user_id(), $product_id, $options_string); + + if (isset($result['error'])) { + return $this->error($result['error']); + } + return $this->success(['data' => $result]); + } + + public function handle_remove_multi_from_cart(WP_REST_Request $request): WP_REST_Response { + $params = $this->get_json_params($request); + $items = is_array($params['items'] ?? null) ? $params['items'] : []; + + $result = AKSRT_Shop::get_instance()->remove_multi_from_cart(get_current_user_id(), $items); + + if (isset($result['error'])) { + return $this->error($result['error']); + } + return $this->success(['data' => $result]); + } + + public function handle_empty_cart(WP_REST_Request $request): WP_REST_Response { + $result = AKSRT_Shop::get_instance()->empty_cart(get_current_user_id()); + + if (isset($result['error'])) { + return $this->error($result['error']); + } + return $this->success(['data' => $result]); + } + + public function get_frontend_addresses(): WP_REST_Response { + if (!class_exists('AKSRT_Shop')) { + return $this->success(['data' => ['addresses' => []]]); + } + + $addresses = AKSRT_Shop::get_instance()->get_address_book(get_current_user_id()); + return $this->success(['data' => ['addresses' => $addresses]]); + } + + public function create_frontend_address(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Shop')) { + return $this->error('商城服务不可用', 500); + } + + $params = $this->get_json_params($request); + $user_id = get_current_user_id(); + $result = AKSRT_Shop::get_instance()->add_address($user_id, $params); + + if (empty($result['success'])) { + return $this->error($result['error'] ?? '添加地址失败'); + } + + return $this->success([ + 'message' => __('地址已添加', 'aksrt'), + 'data' => ['address' => $result['address']], + ], 201); + } + + public function update_frontend_address(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Shop')) { + return $this->error('商城服务不可用', 500); + } + + $address_id = (int) $request->get_param('id'); + $params = $this->get_json_params($request); + $user_id = get_current_user_id(); + $result = AKSRT_Shop::get_instance()->update_address($user_id, $address_id, $params); + + if (empty($result['success'])) { + return $this->error($result['error'] ?? '更新地址失败'); + } + + return $this->success([ + 'message' => __('地址已更新', 'aksrt'), + 'data' => ['address' => $result['address']], + ]); + } + + public function delete_frontend_address(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Shop')) { + return $this->error('商城服务不可用', 500); + } + + $address_id = (int) $request->get_param('id'); + $user_id = get_current_user_id(); + $result = AKSRT_Shop::get_instance()->delete_address($user_id, $address_id); + + if (empty($result['success'])) { + return $this->error($result['error'] ?? '删除地址失败'); + } + + return $this->success(['message' => __('地址已删除', 'aksrt')]); + } + + public function set_default_frontend_address(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Shop')) { + return $this->error('商城服务不可用', 500); + } + + $address_id = (int) $request->get_param('id'); + $user_id = get_current_user_id(); + $result = AKSRT_Shop::get_instance()->set_default_address($user_id, $address_id); + + if (empty($result['success'])) { + return $this->error($result['error'] ?? '设置默认地址失败'); + } + + return $this->success(['message' => __('已设为默认地址', 'aksrt')]); + } + + public function create_frontend_product(WP_REST_Request $request): WP_REST_Response { + $params = $this->get_json_params($request); + $params['author_id'] = get_current_user_id(); + $frontend_request = new WP_REST_Request('POST', '/'); + $frontend_request->set_body(wp_json_encode($params)); + + return $this->create_product($frontend_request); + } + + public function update_frontend_product(WP_REST_Request $request): WP_REST_Response { + $id = (int) $request->get_param('id'); + $post = get_post($id); + if (!$post || $post->post_type !== 'aksrt_product') { + return $this->error('商品不存在', 404); + } + + if ((int) $post->post_author !== get_current_user_id() && !current_user_can('manage_options')) { + return $this->error('没有权限编辑该商品', 403); + } + + return $this->update_product($request); + } +} diff --git a/inc/classes/admin/class-aksrt-admin-shop-controller-fulfillment-operations.php b/inc/classes/admin/class-aksrt-admin-shop-controller-fulfillment-operations.php new file mode 100644 index 00000000..f3dc0052 --- /dev/null +++ b/inc/classes/admin/class-aksrt-admin-shop-controller-fulfillment-operations.php @@ -0,0 +1,354 @@ +success(['data' => ['items' => [], 'total' => 0, 'page' => 1, 'max_pages' => 1]]); + } + + $page = max(1, (int) $request->get_param('page')); + $per_page = max(1, min(100, (int) $request->get_param('per_page'))) ?: 20; + $status = sanitize_key((string) $request->get_param('status')); + $stage = sanitize_key((string) $request->get_param('stage')); + $order = sanitize_text_field((string) $request->get_param('order_number')); + $type = sanitize_key((string) $request->get_param('type')); + $date_from = sanitize_text_field((string) $request->get_param('date_from')); + $date_to = sanitize_text_field((string) $request->get_param('date_to')); + + $result = AKSRT_After_Sale::get_instance()->get_list([ + 'limit' => $per_page, + 'offset' => ($page - 1) * $per_page, + 'status' => $status, + 'stage' => $stage, + 'order_number' => $order, + 'type' => $type, + 'date_from' => $date_from, + 'date_to' => $date_to, + 'include_order_context' => true, + ]); + + $stats = AKSRT_After_Sale::get_instance()->get_stats(); + + return $this->success(['data' => array_merge($result, [ + 'stats' => $stats, + 'filters' => [ + 'status' => $status, + 'stage' => $stage, + 'order_number' => $order, + 'type' => $type, + 'date_from' => $date_from, + 'date_to' => $date_to, + ], + 'options' => [ + 'status' => AKSRT_After_Sale::get_status_options(), + 'stage' => AKSRT_After_Sale::get_stage_options(), + 'type' => AKSRT_After_Sale::get_type_options(), + ], + ])]); + } + + /** + * POST /shop/after-sale — 创建售后工单 + */ + public function create_after_sale(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_After_Sale')) { + return $this->error('售后系统未启用', 500); + } + + $params = $this->get_json_params($request); + $order_number = sanitize_text_field((string) ($params['order_number'] ?? '')); + $reason = sanitize_textarea_field((string) ($params['reason'] ?? '')); + + if ($order_number === '' || $reason === '') { + return $this->error('订单号和原因不能为空'); + } + + $order = class_exists('AKSRT_Order_Repository') + ? AKSRT_Order_Repository::get_instance()->get($order_number) + : null; + + if (!$order) { + return $this->error(__('订单不存在', 'aksrt'), 404); + } + + $params['user_id'] = (int) ($params['user_id'] ?? ($order['user_id'] ?? 0)); + if ($params['user_id'] <= 0) { + return $this->error(__('订单未绑定有效用户', 'aksrt')); + } + + $id = AKSRT_After_Sale::get_instance()->create($params); + + if ($id > 0) { + return $this->success([ + 'message' => __('售后工单已创建', 'aksrt'), + 'data' => ['id' => $id], + ], 201); + } + + return $this->error(__('创建失败', 'aksrt')); + } + + /** + * POST /shop/after-sale/{id}/update-status — 更新售后工单状态 + */ + public function update_after_sale_status(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_After_Sale')) { + return $this->error('售后系统未启用', 500); + } + + $id = (int) $request->get_param('id'); + $params = $this->get_json_params($request); + $new_status = sanitize_key((string) ($params['status'] ?? '')); + $note = sanitize_textarea_field((string) ($params['operator_note'] ?? '')); + + if ($id <= 0 || $new_status === '') { + return $this->error('参数无效'); + } + + $success = AKSRT_After_Sale::get_instance()->update_status( + $id, + $new_status, + get_current_user_id(), + $note + ); + + if ($success) { + return $this->success(['message' => __('状态已更新', 'aksrt')]); + } + return $this->error(__('更新失败', 'aksrt')); + } + + /** + * GET /shop/shipping — 发货记录列表 + */ + public function get_shipping_list(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Shipping')) { + return $this->success(['data' => ['items' => [], 'total' => 0, 'page' => 1, 'max_pages' => 1]]); + } + + $page = max(1, (int) $request->get_param('page')); + $per_page = max(1, min(100, (int) $request->get_param('per_page'))) ?: 20; + $status = sanitize_key((string) $request->get_param('status')); + $order = sanitize_text_field((string) $request->get_param('order_number')); + $date_from = sanitize_text_field((string) $request->get_param('date_from')); + $date_to = sanitize_text_field((string) $request->get_param('date_to')); + $tracking_no = sanitize_text_field((string) $request->get_param('tracking_no')); + + $result = AKSRT_Shipping::get_instance()->get_list([ + 'limit' => $per_page, + 'offset' => ($page - 1) * $per_page, + 'status' => $status, + 'order_number' => $order, + 'date_from' => $date_from, + 'date_to' => $date_to, + 'tracking_no' => $tracking_no, + 'include_order_context' => true, + ]); + + $stats = AKSRT_Shipping::get_instance()->get_stats(); + + return $this->success(['data' => array_merge($result, [ + 'stats' => $stats, + 'filters' => [ + 'status' => $status, + 'order_number' => $order, + 'date_from' => $date_from, + 'date_to' => $date_to, + 'tracking_no' => $tracking_no, + ], + 'options' => [ + 'status' => AKSRT_Shipping::get_status_options(), + 'carrier' => AKSRT_Shipping::get_carrier_options(), + ], + ])]); + } + + /** + * POST /shop/shipping — 创建发货记录 + */ + public function create_shipping(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Shipping')) { + return $this->error('物流系统未启用', 500); + } + + $params = $this->get_json_params($request); + $order_number = sanitize_text_field((string) ($params['order_number'] ?? '')); + $carrier = sanitize_text_field((string) ($params['carrier'] ?? '')); + $tracking_no = sanitize_text_field((string) ($params['tracking_no'] ?? '')); + + if ($order_number === '' || $carrier === '' || $tracking_no === '') { + return $this->error('订单号、快递公司和物流单号不能为空'); + } + + $order = class_exists('AKSRT_Order_Repository') + ? AKSRT_Order_Repository::get_instance()->get($order_number) + : null; + + if (!$order) { + return $this->error(__('订单不存在', 'aksrt'), 404); + } + + $params['operator_id'] = get_current_user_id(); + + $id = AKSRT_Shipping::get_instance()->create($params); + + if ($id > 0) { + return $this->success([ + 'message' => __('发货记录已创建', 'aksrt'), + 'data' => ['id' => $id], + ], 201); + } + + return $this->error(__('创建失败', 'aksrt')); + } + + /** + * POST /shop/shipping/{id}/update-status — 更新发货状态 + */ + public function update_shipping_status(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Shipping')) { + return $this->error('物流系统未启用', 500); + } + + $id = (int) $request->get_param('id'); + $params = $this->get_json_params($request); + $new_status = sanitize_key((string) ($params['status'] ?? '')); + $note = sanitize_textarea_field((string) ($params['note'] ?? '')); + + if ($id <= 0 || $new_status === '') { + return $this->error('参数无效'); + } + + $success = AKSRT_Shipping::get_instance()->update_status($id, $new_status, $note, get_current_user_id()); + + if ($success) { + return $this->success(['message' => __('状态已更新', 'aksrt')]); + } + return $this->error(__('更新失败', 'aksrt')); + } + + public function get_after_sale_detail(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_After_Sale')) { + return $this->error('售后系统未启用', 500); + } + + $id = (int) $request->get_param('id'); + if ($id <= 0) { + return $this->error('参数无效'); + } + + $record = AKSRT_After_Sale::get_instance()->get_with_order_context($id); + if (!$record) { + return $this->error(__('售后工单不存在', 'aksrt'), 404); + } + + return $this->success([ + 'data' => [ + 'record' => $record, + 'timeline' => $this->build_after_sale_timeline($record), + ], + ]); + } + + public function get_shipping_detail(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Shipping')) { + return $this->error('物流系统未启用', 500); + } + + $id = (int) $request->get_param('id'); + if ($id <= 0) { + return $this->error('参数无效'); + } + + $record = AKSRT_Shipping::get_instance()->get_with_order_context($id); + if (!$record) { + return $this->error(__('发货记录不存在', 'aksrt'), 404); + } + + return $this->success([ + 'data' => [ + 'record' => $record, + 'timeline' => $this->build_shipping_timeline($record), + ], + ]); + } + + public function batch_update_shipping_status(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Shipping')) { + return $this->error('物流系统未启用', 500); + } + + $params = $this->get_json_params($request); + $ids = is_array($params['ids'] ?? null) ? $params['ids'] : []; + $status = sanitize_key((string) ($params['status'] ?? '')); + $note = sanitize_textarea_field((string) ($params['note'] ?? '')); + + if (empty($ids) || $status === '') { + return $this->error('参数无效'); + } + + $result = AKSRT_Shipping::get_instance()->batch_update_status($ids, $status, $note, get_current_user_id()); + + return $this->success([ + 'message' => __('批量状态更新已执行', 'aksrt'), + 'data' => $result, + ]); + } + + private function build_shipping_timeline(array $record): array { + $timeline = []; + + foreach ([ + 'created_at' => __('创建发货记录', 'aksrt'), + 'updated_at' => __('更新物流状态', 'aksrt'), + 'delivered_at' => __('确认签收', 'aksrt'), + ] as $field => $label) { + if (!empty($record[$field])) { + $timeline[] = [ + 'time' => (string) $record[$field], + 'label' => $label, + ]; + } + } + + usort($timeline, static function ($a, $b) { + return strcmp((string) ($a['time'] ?? ''), (string) ($b['time'] ?? '')); + }); + + return $timeline; + } + + private function build_after_sale_timeline(array $record): array { + $timeline = []; + + foreach ([ + 'created_at' => __('提交售后申请', 'aksrt'), + 'updated_at' => __('更新售后状态', 'aksrt'), + 'resolved_at' => __('售后处理结束', 'aksrt'), + ] as $field => $label) { + if (!empty($record[$field])) { + $timeline[] = [ + 'time' => (string) $record[$field], + 'label' => $label, + ]; + } + } + + usort($timeline, static function ($a, $b) { + return strcmp((string) ($a['time'] ?? ''), (string) ($b['time'] ?? '')); + }); + + return $timeline; + } +} diff --git a/inc/classes/admin/class-aksrt-admin-shop-controller-order-operations.php b/inc/classes/admin/class-aksrt-admin-shop-controller-order-operations.php new file mode 100644 index 00000000..b9814fed --- /dev/null +++ b/inc/classes/admin/class-aksrt-admin-shop-controller-order-operations.php @@ -0,0 +1,136 @@ +get_param('page')); + $per_page = max(1, min(50, (int) $request->get_param('per_page'))) ?: 20; + $search = sanitize_text_field((string) $request->get_param('search')); + $status = sanitize_key((string) $request->get_param('status')); + + if (class_exists('AKSRT_Order_Repository')) { + $repo = AKSRT_Order_Repository::get_instance(); + + $args = [ + 'limit' => $per_page, + 'offset' => ($page - 1) * $per_page, + ]; + if ($status !== '' && $status !== 'all') { + $args['status'] = $status; + } + + $orders = $repo->get_orders_admin($args, $search); + $total = $repo->get_orders_count($args, $search); + + return $this->success([ + 'data' => [ + 'items' => $orders, + 'total' => $total, + 'page' => $page, + 'total_pages' => max(1, (int) ceil($total / $per_page)), + ], + ]); + } + + return $this->error('订单仓库不可用', 500); + } + + /** + * GET /shop/orders/{order_number} — 单个订单详情(含上下文) + */ + public function get_order(WP_REST_Request $request): WP_REST_Response { + $order_number = sanitize_text_field((string) $request->get_param('order_number')); + + if (!class_exists('AKSRT_Order_Repository')) { + return $this->error('订单服务不可用', 500); + } + + $repo = AKSRT_Order_Repository::get_instance(); + $order = $repo->get($order_number); + if (!$order) { + return $this->error('订单不存在', 404); + } + + $context = $repo->get_admin_order_context($order_number); + $timeline = []; + if (class_exists('AKSRT_Shop')) { + $shipping_records = class_exists('AKSRT_Shipping') ? AKSRT_Shipping::get_instance()->get_by_order($order_number) : []; + $after_sale_result = class_exists('AKSRT_After_Sale') ? AKSRT_After_Sale::get_instance()->get_list(['order_number' => $order_number, 'limit' => 20]) : ['items' => []]; + $after_sale_items = $after_sale_result['items'] ?? []; + + $timeline = []; + $date_fields = [ + 'created_at' => '订单创建', + 'paid_at' => '完成支付', + ]; + foreach ($date_fields as $field => $label) { + if (!empty($order[$field])) { + $timeline[] = ['time' => $order[$field], 'title' => $label, 'type' => 'order']; + } + } + foreach ($shipping_records as $record) { + $timeline[] = [ + 'time' => $record['created_at'] ?? '', + 'title' => '物流更新:' . ($record['status_label'] ?? $record['status'] ?? ''), + 'detail' => ($record['carrier_label'] ?? '') . ' / ' . ($record['tracking_no'] ?? ''), + 'type' => 'shipping', + ]; + } + foreach ($after_sale_items as $record) { + $timeline[] = [ + 'time' => $record['created_at'] ?? '', + 'title' => '售后:' . ($record['type_label'] ?? '') . ' — ' . ($record['status_label'] ?? ''), + 'detail' => $record['reason'] ?? '', + 'type' => 'after_sale', + ]; + } + usort($timeline, static function ($a, $b) { + return strcmp((string) ($b['time'] ?? ''), (string) ($a['time'] ?? '')); + }); + } + + return $this->success([ + 'data' => $order, + 'context' => $context, + 'timeline' => $timeline, + ]); + } + + /** + * POST /shop/orders/update-status — 更新订单状态 + * Canonical field: order_number (前端必须发送 order_number) + */ + public function update_order_status(WP_REST_Request $request): WP_REST_Response { + $params = $this->get_json_params($request); + $order_number = sanitize_text_field((string) ($params['order_number'] ?? '')); + $new_status = sanitize_key((string) ($params['status'] ?? '')); + $note = sanitize_textarea_field((string) ($params['note'] ?? '')); + + if ($order_number === '' || $new_status === '') { + return $this->error('参数无效'); + } + + $success = false; + if (class_exists('AKSRT_Order_Repository')) { + $success = AKSRT_Order_Repository::get_instance()->update_status($order_number, $new_status, [ + 'note' => $note, + 'force' => true, + ]); + } + + if ($success) { + return $this->success(['message' => __('订单状态已更新', 'aksrt')]); + } + return $this->error(__('更新失败', 'aksrt')); + } +} diff --git a/inc/classes/admin/class-aksrt-admin-shop-controller-product-admin-columns.php b/inc/classes/admin/class-aksrt-admin-shop-controller-product-admin-columns.php new file mode 100644 index 00000000..25b28f59 --- /dev/null +++ b/inc/classes/admin/class-aksrt-admin-shop-controller-product-admin-columns.php @@ -0,0 +1,187 @@ + $label) { + $new[$key] = $label; + if ($key === 'title') { + $new['product_price'] = __('价格', 'aksrt'); + $new['product_stock'] = __('库存', 'aksrt'); + $new['product_sales'] = __('销量', 'aksrt'); + $new['product_stats'] = __('综合数据', 'aksrt'); + $new['product_author_name'] = __('商家', 'aksrt'); + $new['product_discount'] = __('优惠活动', 'aksrt'); + } + } + return $new; + } + + public function render_product_column(string $column, int $post_id): void { + switch ($column) { + case 'product_price': + $price = get_post_meta($post_id, '_aksrt_price', true); + echo esc_html(number_format_i18n((float) $price, 2)); + break; + case 'product_stock': + $stock = get_post_meta($post_id, '_aksrt_stock', true); + $stock_type = get_post_meta($post_id, '_aksrt_stock_type', true); + if ($stock_type === 'opts') { + echo esc_html__('按选项', 'aksrt'); + } elseif ((int) $stock === -1) { + echo esc_html__('无限', 'aksrt'); + } else { + echo esc_html((string) (int) $stock); + } + break; + case 'product_sales': + echo esc_html((string) (int) get_post_meta($post_id, '_aksrt_sales', true)); + break; + case 'product_stats': + $views = (int) get_post_meta($post_id, '_aksrt_views', true); + $fav = (int) get_post_meta($post_id, '_aksrt_favorite_count', true); + printf( + esc_html__('阅读 %1$d / 收藏 %2$d', 'aksrt'), + $views, + $fav + ); + break; + case 'product_author_name': + $post = get_post($post_id); + if ($post) { + $author = get_userdata($post->post_author); + echo esc_html($author ? $author->display_name : '—'); + } + break; + case 'product_discount': + $terms = wp_get_object_terms($post_id, 'shop_discount', ['fields' => 'all']); + if (!is_wp_error($terms) && !empty($terms)) { + $term_ids = wp_list_pluck($terms, 'term_id'); + $names = wp_list_pluck($terms, 'name'); + echo ''; + echo esc_html(implode(', ', $names)); + } else { + echo '—'; + } + break; + } + } + + public function sortable_product_columns(array $columns): array { + $columns['product_sales'] = 'product_sales'; + return $columns; + } + + public function render_product_quick_edit(string $column_name, string $post_type): void { + if ($column_name !== 'product_discount' || $post_type !== 'aksrt_product') { + return; + } + wp_nonce_field('aksrt_product_quick_edit', 'aksrt_product_qe_nonce'); + $discounts = get_terms([ + 'taxonomy' => 'shop_discount', + 'hide_empty' => false, + ]); + ?> +
+
+

+ + +
+
+ 0 && term_exists($term_id, 'shop_discount')) { + wp_set_object_terms($post_id, [$term_id], 'shop_discount'); + } + } + } + + public function product_quick_edit_script(): void { + $screen = get_current_screen(); + if (!$screen || $screen->post_type !== 'aksrt_product') { + return; + } + ?> + + publish ?? 0); + } + + $order_stats = ['total' => 0, 'pending' => 0, 'paid' => 0, 'revenue' => 0]; + if (class_exists('AKSRT_Order_Repository')) { + $order_stats = AKSRT_Order_Repository::get_instance()->get_stats(); + } + + $revenue_stats = ['total' => 0, 'pending' => 0, 'settled' => 0]; + if (class_exists('AKSRT_Revenue')) { + $revenue_stats = AKSRT_Revenue::get_instance()->get_stats(); + } + + $gateway_summary = []; + if (class_exists('AKSRT_Payment_Gateway')) { + $gateway_summary = AKSRT_Payment_Gateway::get_instance()->get_status_summary(); + } + + return $this->success([ + 'data' => [ + 'enabled' => true, + 'product_count' => $product_count, + 'order_count' => $order_stats['total'], + 'order_pending' => $order_stats['pending'] ?? 0, + 'revenue_total' => $order_stats['revenue'] ?? 0, + 'revenue_pending' => $revenue_stats['pending'] ?? 0, + 'gateways' => $gateway_summary, + ], + ]); + } + + /** + * GET /shop/products — 商品列表,支持 search、status、page、per_page + */ + public function get_products(WP_REST_Request $request): WP_REST_Response { + $page = max(1, (int) $request->get_param('page')); + $per_page = max(1, min(50, (int) $request->get_param('per_page'))) ?: 20; + $search = sanitize_text_field((string) $request->get_param('search')); + $status = sanitize_key((string) $request->get_param('status')); + + $query_args = [ + 'post_type' => 'aksrt_product', + 'posts_per_page' => $per_page, + 'paged' => $page, + 'orderby' => 'date', + 'order' => 'DESC', + ]; + + if ($status !== '') { + $query_args['post_status'] = $status; + } else { + $query_args['post_status'] = ['publish', 'draft', 'pending']; + } + + if ($search !== '') { + $query_args['s'] = $search; + } + + $query = new WP_Query($query_args); + + $products = []; + while ($query->have_posts()) { + $query->the_post(); + $id = get_the_ID(); + $products[] = [ + 'id' => $id, + 'title' => get_the_title(), + 'price' => (float) get_post_meta($id, '_aksrt_price', true), + 'image' => get_the_post_thumbnail_url($id, 'medium') ?: '', + 'stock' => (int) get_post_meta($id, '_aksrt_stock', true), + 'sales' => (int) get_post_meta($id, '_aksrt_sales', true), + 'status' => get_post_status(), + 'created' => get_the_date('Y-m-d H:i'), + ]; + } + wp_reset_postdata(); + + return $this->success([ + 'data' => [ + 'items' => $products, + 'total' => (int) $query->found_posts, + 'total_pages' => (int) $query->max_num_pages, + 'page' => $page, + ], + ]); + } + + /** + * GET /shop/products/{id} — 单个商品详情 + */ + public function get_product(WP_REST_Request $request): WP_REST_Response { + $id = (int) $request->get_param('id'); + $post = get_post($id); + + if (!$post || $post->post_type !== 'aksrt_product') { + return $this->error('商品不存在', 404); + } + + return $this->success([ + 'data' => [ + 'id' => $post->ID, + 'title' => $post->post_title, + 'content' => $post->post_content, + 'excerpt' => $post->post_excerpt, + 'price' => (float) get_post_meta($post->ID, '_aksrt_price', true), + 'image' => (string) (get_post_meta($post->ID, '_aksrt_product_image', true) ?: get_the_post_thumbnail_url($post->ID, 'medium') ?: ''), + 'stock' => (int) get_post_meta($post->ID, '_aksrt_stock', true), + 'sales' => (int) get_post_meta($post->ID, '_aksrt_sales', true), + 'status' => $post->post_status, + 'created' => get_the_date('Y-m-d H:i', $post), + 'modified' => get_the_modified_date('Y-m-d H:i', $post), + 'categories' => wp_get_post_terms($post->ID, 'aksrt_product_cat', ['fields' => 'names']) ?: [], + 'tags' => wp_get_post_terms($post->ID, 'aksrt_product_tag', ['fields' => 'names']) ?: [], + 'gallery' => get_post_meta($post->ID, '_aksrt_product_gallery', true) ?: [], + 'shipping_mode' => (string) get_post_meta($post->ID, '_aksrt_shipping_mode', true), + 'shipping_fee' => (float) get_post_meta($post->ID, '_aksrt_shipping_fee', true), + 'after_sale_policy' => (string) get_post_meta($post->ID, '_aksrt_after_sale_policy', true), + 'support_refund' => get_post_meta($post->ID, '_aksrt_support_refund', true) !== '0', + ], + ]); + } + + /** + * POST /shop/products — 创建商品 + */ + public function create_product(WP_REST_Request $request): WP_REST_Response { + $params = $this->get_json_params($request); + + $title = sanitize_text_field((string) ($params['title'] ?? '')); + $content = wp_kses_post((string) ($params['content'] ?? '')); + $excerpt = sanitize_textarea_field((string) ($params['excerpt'] ?? '')); + $price = (float) ($params['price'] ?? 0); + $stock = (int) ($params['stock'] ?? 0); + $status = sanitize_key((string) ($params['status'] ?? 'publish')); + $image = esc_url_raw((string) ($params['image'] ?? '')); + $gallery = is_array($params['gallery'] ?? null) ? array_values(array_filter(array_map('esc_url_raw', $params['gallery']))) : []; + $shipping_mode = sanitize_key((string) ($params['shipping_mode'] ?? 'express')); + $shipping_fee = max(0, (float) ($params['shipping_fee'] ?? 0)); + $after_sale_policy = sanitize_textarea_field((string) ($params['after_sale_policy'] ?? '')); + $support_refund = !isset($params['support_refund']) || !empty($params['support_refund']); + $author_id = max(0, (int) ($params['author_id'] ?? 0)); + + if ($title === '') { + return $this->error('商品名称不能为空'); + } + + $valid_statuses = ['publish', 'draft', 'pending']; + if (!in_array($status, $valid_statuses, true)) { + $status = 'draft'; + } + + $post_id = wp_insert_post([ + 'post_type' => 'aksrt_product', + 'post_title' => $title, + 'post_content' => $content, + 'post_excerpt' => $excerpt, + 'post_status' => $status, + 'post_author' => $author_id > 0 ? $author_id : get_current_user_id(), + 'meta_input' => [ + '_aksrt_price' => max(0, $price), + '_aksrt_stock' => max(0, $stock), + '_aksrt_sales' => 0, + '_aksrt_score' => 0, + '_aksrt_views' => 0, + '_aksrt_favorite_count' => 0, + '_aksrt_product_gallery' => $gallery, + '_aksrt_shipping_mode' => $shipping_mode, + '_aksrt_shipping_fee' => $shipping_fee, + '_aksrt_after_sale_policy' => $after_sale_policy, + '_aksrt_support_refund' => $support_refund ? '1' : '0', + '_aksrt_product_video' => is_array($params['product_video'] ?? null) ? array_values(array_filter(array_map('esc_url_raw', $params['product_video']))) : [], + '_aksrt_product_params' => sanitize_textarea_field((string) ($params['product_params'] ?? '')), + '_aksrt_pay_modo' => sanitize_key((string) ($params['pay_modo'] ?? '0')), + '_aksrt_start_price' => max(0, (float) ($params['start_price'] ?? 0)), + '_aksrt_product_rebate' => sanitize_text_field((string) ($params['product_rebate'] ?? '')), + '_aksrt_limit_buy' => max(-1, min(9999, (int) ($params['limit_buy'] ?? -1))), + '_aksrt_limit_buy_note' => sanitize_text_field((string) ($params['limit_buy_note'] ?? '')), + '_aksrt_stock_type' => sanitize_key((string) ($params['stock_type'] ?? 'all')), + '_aksrt_user_required' => sanitize_textarea_field((string) ($params['user_required'] ?? '')), + '_aksrt_shipping_type' => sanitize_key((string) ($params['shipping_type'] ?? $shipping_mode)), + '_aksrt_shipping_delivery_desc' => sanitize_text_field((string) ($params['shipping_delivery_desc'] ?? '')), + '_aksrt_shipping_fee_type' => sanitize_key((string) ($params['shipping_fee_type'] ?? 'free')), + '_aksrt_shipping_fee_free_amount'=> max(0, (float) ($params['shipping_fee_free_amount'] ?? 0)), + '_aksrt_after_sale_matrix' => sanitize_textarea_field((string) ($params['after_sale_matrix'] ?? '')), + '_aksrt_service' => sanitize_textarea_field((string) ($params['service'] ?? '')), + '_aksrt_single_tab' => sanitize_textarea_field((string) ($params['single_tab'] ?? '')), + '_aksrt_content_layout' => sanitize_key((string) ($params['content_layout'] ?? '')), + '_aksrt_content_show_bg' => sanitize_text_field((string) ($params['content_show_bg'] ?? '')), + '_aksrt_delivery_note' => sanitize_text_field((string) ($params['delivery_note'] ?? '')), + '_aksrt_marketing_headline' => sanitize_text_field((string) ($params['marketing_headline'] ?? '')), + '_aksrt_shipping_tip' => sanitize_text_field((string) ($params['shipping_tip'] ?? '')), + '_aksrt_service_tip' => sanitize_text_field((string) ($params['service_tip'] ?? '')), + ], + ], true); + + if (is_wp_error($post_id)) { + return $this->error($post_id->get_error_message()); + } + + if (!empty($params['categories']) && is_array($params['categories'])) { + wp_set_object_terms($post_id, $params['categories'], 'aksrt_product_cat'); + } + if (!empty($params['tags']) && is_array($params['tags'])) { + wp_set_object_terms($post_id, $params['tags'], 'aksrt_product_tag'); + } + if ($image !== '') { + update_post_meta($post_id, '_aksrt_product_image', $image); + } + + return $this->success([ + 'message' => __('商品已创建', 'aksrt'), + 'data' => ['id' => $post_id], + ], 201); + } + + /** + * POST /shop/products/{id} — 更新商品(含上下架) + */ + public function update_product(WP_REST_Request $request): WP_REST_Response { + $id = (int) $request->get_param('id'); + $post = get_post($id); + + if (!$post || $post->post_type !== 'aksrt_product') { + return $this->error('商品不存在', 404); + } + + $params = $this->get_json_params($request); + $update = ['ID' => $id]; + + if (isset($params['title'])) { + $update['post_title'] = sanitize_text_field($params['title']); + } + if (isset($params['content'])) { + $update['post_content'] = wp_kses_post($params['content']); + } + if (isset($params['excerpt'])) { + $update['post_excerpt'] = sanitize_textarea_field($params['excerpt']); + } + if (isset($params['status'])) { + $new_status = sanitize_key($params['status']); + $valid_statuses = ['publish', 'draft', 'pending', 'trash']; + if (in_array($new_status, $valid_statuses, true)) { + $update['post_status'] = $new_status; + } + } + + $meta = []; + if (isset($params['price'])) { + $meta['_aksrt_price'] = max(0, (float) $params['price']); + } + if (isset($params['stock'])) { + $meta['_aksrt_stock'] = max(0, (int) $params['stock']); + } + if (isset($params['gallery']) && is_array($params['gallery'])) { + $meta['_aksrt_product_gallery'] = array_values(array_filter(array_map('esc_url_raw', $params['gallery']))); + } + if (isset($params['shipping_mode'])) { + $meta['_aksrt_shipping_mode'] = sanitize_key((string) $params['shipping_mode']); + } + if (isset($params['shipping_fee'])) { + $meta['_aksrt_shipping_fee'] = max(0, (float) $params['shipping_fee']); + } + if (isset($params['after_sale_policy'])) { + $meta['_aksrt_after_sale_policy'] = sanitize_textarea_field((string) $params['after_sale_policy']); + } + if (isset($params['support_refund'])) { + $meta['_aksrt_support_refund'] = !empty($params['support_refund']) ? '1' : '0'; + } + if (isset($params['product_video']) && is_array($params['product_video'])) { + $meta['_aksrt_product_video'] = array_values(array_filter(array_map('esc_url_raw', $params['product_video']))); + } + if (isset($params['product_params'])) { + $meta['_aksrt_product_params'] = sanitize_textarea_field((string) $params['product_params']); + } + if (isset($params['pay_modo'])) { + $meta['_aksrt_pay_modo'] = sanitize_key((string) $params['pay_modo']); + } + if (isset($params['start_price'])) { + $meta['_aksrt_start_price'] = max(0, (float) $params['start_price']); + } + if (isset($params['product_rebate'])) { + $meta['_aksrt_product_rebate'] = sanitize_text_field((string) $params['product_rebate']); + } + if (isset($params['limit_buy'])) { + $meta['_aksrt_limit_buy'] = max(-1, min(9999, (int) $params['limit_buy'])); + } + if (isset($params['limit_buy_note'])) { + $meta['_aksrt_limit_buy_note'] = sanitize_text_field((string) $params['limit_buy_note']); + } + if (isset($params['stock_type'])) { + $meta['_aksrt_stock_type'] = sanitize_key((string) $params['stock_type']); + } + if (isset($params['stock_opts']) && is_array($params['stock_opts'])) { + $meta['_aksrt_stock_opts'] = wp_json_encode($params['stock_opts']); + } + if (isset($params['user_required'])) { + $meta['_aksrt_user_required'] = sanitize_textarea_field((string) $params['user_required']); + } + if (isset($params['shipping_type'])) { + $meta['_aksrt_shipping_type'] = sanitize_key((string) $params['shipping_type']); + } + if (isset($params['shipping_delivery_desc'])) { + $meta['_aksrt_shipping_delivery_desc'] = sanitize_text_field((string) $params['shipping_delivery_desc']); + } + if (isset($params['auto_delivery']) && is_array($params['auto_delivery'])) { + $meta['_aksrt_auto_delivery'] = wp_json_encode($params['auto_delivery']); + } + if (isset($params['shipping_fee_type'])) { + $meta['_aksrt_shipping_fee_type'] = sanitize_key((string) $params['shipping_fee_type']); + } + if (isset($params['shipping_fee_free_amount'])) { + $meta['_aksrt_shipping_fee_free_amount'] = max(0, (float) $params['shipping_fee_free_amount']); + } + if (isset($params['after_sale_matrix'])) { + $meta['_aksrt_after_sale_matrix'] = sanitize_textarea_field((string) $params['after_sale_matrix']); + } + if (isset($params['service'])) { + $meta['_aksrt_service'] = sanitize_textarea_field((string) $params['service']); + } + if (isset($params['single_tab'])) { + $meta['_aksrt_single_tab'] = sanitize_textarea_field((string) $params['single_tab']); + } + if (isset($params['content_layout'])) { + $meta['_aksrt_content_layout'] = sanitize_key((string) $params['content_layout']); + } + if (isset($params['content_show_bg'])) { + $meta['_aksrt_content_show_bg'] = sanitize_text_field((string) $params['content_show_bg']); + } + if (isset($params['delivery_note'])) { + $meta['_aksrt_delivery_note'] = sanitize_text_field((string) $params['delivery_note']); + } + if (isset($params['marketing_headline'])) { + $meta['_aksrt_marketing_headline'] = sanitize_text_field((string) $params['marketing_headline']); + } + if (isset($params['shipping_tip'])) { + $meta['_aksrt_shipping_tip'] = sanitize_text_field((string) $params['shipping_tip']); + } + if (isset($params['service_tip'])) { + $meta['_aksrt_service_tip'] = sanitize_text_field((string) $params['service_tip']); + } + if (!empty($meta)) { + $update['meta_input'] = $meta; + } + + $result = wp_update_post($update, true); + + if (is_wp_error($result)) { + return $this->error($result->get_error_message()); + } + + if (isset($params['categories']) && is_array($params['categories'])) { + wp_set_object_terms($id, $params['categories'], 'aksrt_product_cat'); + } + if (isset($params['tags']) && is_array($params['tags'])) { + wp_set_object_terms($id, $params['tags'], 'aksrt_product_tag'); + } + if (isset($params['image'])) { + update_post_meta($id, '_aksrt_product_image', esc_url_raw((string) $params['image'])); + } + + return $this->success([ + 'message' => __('商品已更新', 'aksrt'), + ]); + } +} diff --git a/inc/classes/admin/class-aksrt-admin-shop-controller-variant-export-operations.php b/inc/classes/admin/class-aksrt-admin-shop-controller-variant-export-operations.php new file mode 100644 index 00000000..5caa99cc --- /dev/null +++ b/inc/classes/admin/class-aksrt-admin-shop-controller-variant-export-operations.php @@ -0,0 +1,111 @@ +get_param('id'); + $product = get_post($product_id); + if (!$product || $product->post_type !== 'aksrt_product') { + return $this->error('商品不存在', 404); + } + + if (!class_exists('AKSRT_Shop')) { + return $this->success(['data' => ['variants' => []]]); + } + + $variants = AKSRT_Shop::get_instance()->get_variants($product_id); + return $this->success(['data' => ['variants' => $variants]]); + } + + public function save_product_variants(WP_REST_Request $request): WP_REST_Response { + $product_id = (int) $request->get_param('id'); + $product = get_post($product_id); + if (!$product || $product->post_type !== 'aksrt_product') { + return $this->error('商品不存在', 404); + } + + $params = $request->get_json_params() ?? []; + $variants = $params['variants'] ?? []; + if (!is_array($variants)) { + return $this->error('变体数据格式错误'); + } + + if (!class_exists('AKSRT_Shop')) { + return $this->error('商城系统未启用', 501); + } + + $saved = AKSRT_Shop::get_instance()->save_variants($product_id, $variants); + return $this->success(['message' => '变体已保存', 'data' => ['variants' => $saved]]); + } + + public function delete_variant(WP_REST_Request $request): WP_REST_Response { + $variant_id = (int) $request->get_param('id'); + + if (!class_exists('AKSRT_Shop')) { + return $this->error('商城系统未启用', 501); + } + + $result = AKSRT_Shop::get_instance()->delete_variant($variant_id); + if (!$result) { + return $this->error('删除失败', 500); + } + + return $this->success(['message' => '变体已删除']); + } + + public function export_orders(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Shop')) { + return $this->error('商城服务不可用', 500); + } + + $args = [ + 'status' => sanitize_key((string) $request->get_param('status')), + 'date_from' => sanitize_text_field((string) $request->get_param('date_from')), + 'date_to' => sanitize_text_field((string) $request->get_param('date_to')), + 'limit' => max(1, min(5000, (int) ($request->get_param('limit') ?: 1000))), + ]; + + $result = AKSRT_Shop::get_instance()->export_orders_csv($args); + + return new WP_REST_Response($result['csv'], 200, [ + 'Content-Type' => 'text/csv; charset=utf-8', + 'Content-Disposition' => 'attachment; filename="orders-' . date('Ymd-His') . '.csv"', + 'X-Order-Count' => $result['count'], + ]); + } + + public function confirm_receipt(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Shop')) { + return $this->error('商城服务不可用', 500); + } + + $order_number = sanitize_text_field((string) $request->get_param('order_number')); + $result = AKSRT_Shop::get_instance()->confirm_receipt(get_current_user_id(), $order_number); + if (empty($result['success'])) { + return $this->error($result['message'] ?? '确认收货失败'); + } + + return $this->success(['data' => $result['data']]); + } + + public function get_after_sale_reasons(WP_REST_Request $request): WP_REST_Response { + if (class_exists('AKSRT_After_Sale')) { + return $this->success(['data' => AKSRT_After_Sale::get_reason_options()]); + } + return $this->success(['data' => ['refund' => [], 'return' => []]]); + } + + public function get_frontend_after_sale_reasons(WP_REST_Request $request): WP_REST_Response { + if (class_exists('AKSRT_After_Sale')) { + return $this->success(['data' => AKSRT_After_Sale::get_reason_options()]); + } + return $this->success(['data' => ['refund' => [], 'return' => []]]); + } +} diff --git a/inc/classes/admin/class-aksrt-admin-shop-controller.php b/inc/classes/admin/class-aksrt-admin-shop-controller.php new file mode 100644 index 00000000..5a8a8ff5 --- /dev/null +++ b/inc/classes/admin/class-aksrt-admin-shop-controller.php @@ -0,0 +1,369 @@ +admin = $admin; + } + + public function register_routes(): void { + register_rest_route(self::API_NAMESPACE, '/shop/frontend/checkout/preview', [ + 'methods' => 'POST', + 'callback' => [$this, 'preview_checkout'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/shop/frontend/orders', [ + 'methods' => 'POST', + 'callback' => [$this, 'create_frontend_order'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/shop/frontend/orders', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_frontend_orders'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/shop/frontend/orders/(?P[A-Za-z0-9]+)/pay', [ + 'methods' => 'POST', + 'callback' => [$this, 'pay_frontend_order'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/shop/frontend/orders/(?P[A-Za-z0-9]+)/cancel', [ + 'methods' => 'POST', + 'callback' => [$this, 'cancel_frontend_order'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/shop/frontend/orders/(?P[A-Za-z0-9]+)', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_frontend_order'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/shop/frontend/orders/(?P[A-Za-z0-9]+)/after-sale', [ + 'methods' => 'POST', + 'callback' => [$this, 'create_frontend_after_sale'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + ]); + + // ── Address book CRUD (frontend) ── + + register_rest_route(self::API_NAMESPACE, '/shop/frontend/address', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_frontend_addresses'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/shop/frontend/address', [ + 'methods' => 'POST', + 'callback' => [$this, 'create_frontend_address'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/shop/frontend/address/(?P\d+)', [ + 'methods' => 'POST', + 'callback' => [$this, 'update_frontend_address'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/shop/frontend/address/(?P\d+)', [ + 'methods' => 'DELETE', + 'callback' => [$this, 'delete_frontend_address'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/shop/frontend/address/(?P\d+)/default', [ + 'methods' => 'POST', + 'callback' => [$this, 'set_default_frontend_address'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/shop/frontend/products', [ + 'methods' => 'POST', + 'callback' => [$this, 'create_frontend_product'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/shop/frontend/products/(?P\d+)', [ + 'methods' => 'POST', + 'callback' => [$this, 'update_frontend_product'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + ]); + + // Cart endpoints (§6.4) + register_rest_route(self::API_NAMESPACE, '/shop/frontend/cart', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_cart'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/shop/frontend/cart/add', [ + 'methods' => 'POST', + 'callback' => [$this, 'handle_add_to_cart'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/shop/frontend/cart/reduce', [ + 'methods' => 'POST', + 'callback' => [$this, 'handle_reduce_from_cart'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/shop/frontend/cart/remove', [ + 'methods' => 'POST', + 'callback' => [$this, 'handle_remove_from_cart'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/shop/frontend/cart/remove-multi', [ + 'methods' => 'POST', + 'callback' => [$this, 'handle_remove_multi_from_cart'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/shop/frontend/cart/empty', [ + 'methods' => 'POST', + 'callback' => [$this, 'handle_empty_cart'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/shop/frontend/cart/count', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_cart_count'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + ]); + + // Shop overview / dashboard + register_rest_route(self::API_NAMESPACE, '/shop/overview', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_overview'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Product list + register_rest_route(self::API_NAMESPACE, '/shop/products', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_products'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Product create + register_rest_route(self::API_NAMESPACE, '/shop/products', [ + 'methods' => 'POST', + 'callback' => [$this, 'create_product'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Product update + register_rest_route(self::API_NAMESPACE, '/shop/products/(?P\d+)', [ + 'methods' => 'POST', + 'callback' => [$this, 'update_product'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Product single read + register_rest_route(self::API_NAMESPACE, '/shop/products/(?P\d+)', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_product'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Order list + register_rest_route(self::API_NAMESPACE, '/shop/orders', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_orders'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Order single read + register_rest_route(self::API_NAMESPACE, '/shop/orders/(?P[A-Za-z0-9]+)', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_order'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Order status update — canonical field is order_number + register_rest_route(self::API_NAMESPACE, '/shop/orders/update-status', [ + 'methods' => 'POST', + 'callback' => [$this, 'update_order_status'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Gateway status + register_rest_route(self::API_NAMESPACE, '/shop/gateways', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_gateways'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Coupon list (admin) + register_rest_route(self::API_NAMESPACE, '/shop/coupons', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_coupons'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Coupon create (admin) + register_rest_route(self::API_NAMESPACE, '/shop/coupons', [ + 'methods' => 'POST', + 'callback' => [$this, 'create_coupon'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Coupon disable (admin) + register_rest_route(self::API_NAMESPACE, '/shop/coupons/(?P\d+)/disable', [ + 'methods' => 'POST', + 'callback' => [$this, 'disable_coupon'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // ── After Sale ── + + // After-sale list (admin) + register_rest_route(self::API_NAMESPACE, '/shop/after-sale', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_after_sale_list'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // After-sale create (admin) + register_rest_route(self::API_NAMESPACE, '/shop/after-sale', [ + 'methods' => 'POST', + 'callback' => [$this, 'create_after_sale'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // After-sale status update (admin) + register_rest_route(self::API_NAMESPACE, '/shop/after-sale/(?P\d+)/update-status', [ + 'methods' => 'POST', + 'callback' => [$this, 'update_after_sale_status'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/shop/after-sale/(?P\d+)', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_after_sale_detail'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // ── Shipping ── + + // Shipping list (admin) + register_rest_route(self::API_NAMESPACE, '/shop/shipping', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_shipping_list'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Shipping create (admin) + register_rest_route(self::API_NAMESPACE, '/shop/shipping', [ + 'methods' => 'POST', + 'callback' => [$this, 'create_shipping'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Shipping status update (admin) + register_rest_route(self::API_NAMESPACE, '/shop/shipping/(?P\d+)/update-status', [ + 'methods' => 'POST', + 'callback' => [$this, 'update_shipping_status'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/shop/shipping/(?P\d+)', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_shipping_detail'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/shop/shipping/batch-update-status', [ + 'methods' => 'POST', + 'callback' => [$this, 'batch_update_shipping_status'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + + // ── R68: Order export + confirm receipt + after-sale reasons ── + + register_rest_route(self::API_NAMESPACE, '/shop/orders/export', [ + 'methods' => 'GET', + 'callback' => [$this, 'export_orders'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // ── R69: Frontend after-sale reasons (§6.8) ── + + register_rest_route(self::API_NAMESPACE, '/shop/frontend/after-sale/reasons', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_frontend_after_sale_reasons'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/shop/frontend/orders/(?P[A-Za-z0-9]+)/confirm-receipt', [ + 'methods' => 'POST', + 'callback' => [$this, 'confirm_receipt'], + 'permission_callback' => [$this, 'check_logged_in_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/shop/after-sale/reasons', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_after_sale_reasons'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // ── Product Variants / SKU ── + + // List variants for a product + register_rest_route(self::API_NAMESPACE, '/shop/products/(?P\d+)/variants', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_product_variants'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Save variants for a product (replace all) + register_rest_route(self::API_NAMESPACE, '/shop/products/(?P\d+)/variants', [ + 'methods' => 'POST', + 'callback' => [$this, 'save_product_variants'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Delete a variant + register_rest_route(self::API_NAMESPACE, '/shop/variants/(?P\d+)', [ + 'methods' => 'DELETE', + 'callback' => [$this, 'delete_variant'], + 'permission_callback' => [$this, 'check_permission'], + ]); + } + + public function check_logged_in_permission(): bool { + return is_user_logged_in(); + } + +} diff --git a/inc/classes/admin/class-aksrt-admin-tools-controller.php b/inc/classes/admin/class-aksrt-admin-tools-controller.php new file mode 100644 index 00000000..4a96f2bb --- /dev/null +++ b/inc/classes/admin/class-aksrt-admin-tools-controller.php @@ -0,0 +1,285 @@ + 'GET', + 'callback' => [$this, 'rest_health_email_verification'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Health: REST block toggle + register_rest_route(self::API_NAMESPACE, '/health/rest-block', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_set_rest_block'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Email rollback: list users with recent email changes + register_rest_route(self::API_NAMESPACE, '/email-rollback/users', [ + 'methods' => 'GET', + 'callback' => [$this, 'rest_email_rollback_list'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Email rollback: execute rollback + register_rest_route(self::API_NAMESPACE, '/email-rollback/users/(?P\d+)/rollback', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_email_rollback_execute'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Tools: system health (compact format for ExtensionTools.vue) + register_rest_route(self::API_NAMESPACE, '/tools/health', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_tools_health'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Tools: maintenance actions + register_rest_route(self::API_NAMESPACE, '/tools/maintenance', [ + 'methods' => 'POST', + 'callback' => [$this, 'run_maintenance'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Tools: update center + register_rest_route(self::API_NAMESPACE, '/tools/update-info', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_update_info'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/tools/update-check', [ + 'methods' => 'POST', + 'callback' => [$this, 'detect_update'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/tools/update-dismiss', [ + 'methods' => 'POST', + 'callback' => [$this, 'dismiss_update'], + 'permission_callback' => [$this, 'check_permission'], + ]); + } + + /** + * GET /tools/health — compact health data for the tools page. + */ + public function get_tools_health(): WP_REST_Response { + global $wpdb; + + $upload_dir = wp_upload_dir(); + $upload_ok = !empty($upload_dir['error']) === false && is_dir($upload_dir['basedir']); + + $db_ok = true; + $db_status = '正常'; + $expected_tables = [ + $wpdb->prefix . 'aksrt_credit_log', + $wpdb->prefix . 'aksrt_signin_log', + $wpdb->prefix . 'aksrt_invite', + $wpdb->prefix . 'aksrt_dl_token', + $wpdb->prefix . 'aksrt_notification', + ]; + $missing = []; + foreach ($expected_tables as $table) { + if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)) !== $table) { + $missing[] = str_replace($wpdb->prefix, '', $table); + } + } + if (!empty($missing)) { + $db_ok = false; + $db_status = '缺少: ' . implode(', ', $missing); + } + + return $this->success([ + 'data' => [ + 'php_version' => PHP_VERSION, + 'wp_version' => get_bloginfo('version'), + 'db_status' => $db_status, + 'db_ok' => $db_ok, + 'upload_dir' => $upload_dir['basedir'], + 'upload_ok' => $upload_ok, + 'theme_version' => wp_get_theme()->get('Version') ?? 'unknown', + ], + ]); + } + + /** + * POST /tools/maintenance — run a maintenance action. + */ + public function run_maintenance(WP_REST_Request $request): WP_REST_Response { + $params = $this->get_json_params($request); + $action = sanitize_key((string) ($params['action'] ?? '')); + + switch ($action) { + case 'flush_cache': + // Flush WordPress object cache + wp_cache_flush(); + // Clear transients + global $wpdb; + $wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_aksrt_%' OR option_name LIKE '_transient_timeout_aksrt_%'"); + return $this->success(['message' => __('缓存已清除', 'aksrt')]); + + case 'optimize_db': + global $wpdb; + $tables = [ + $wpdb->prefix . 'aksrt_credit_log', + $wpdb->prefix . 'aksrt_signin_log', + $wpdb->prefix . 'aksrt_invite', + $wpdb->prefix . 'aksrt_dl_token', + $wpdb->prefix . 'aksrt_notification', + ]; + $optimized = 0; + foreach ($tables as $table) { + if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)) === $table) { + $wpdb->query("OPTIMIZE TABLE {$table}"); + $optimized++; + } + } + return $this->success(['message' => sprintf(__('已优化 %d 个数据表', 'aksrt'), $optimized)]); + + case 'rebuild_index': + // Rebuild search index / recount stats + if (class_exists('AKSRT_Forum') && method_exists(AKSRT_Forum::get_instance(), 'recount_stats')) { + AKSRT_Forum::get_instance()->recount_stats(); + } + return $this->success(['message' => __('索引重建完成', 'aksrt')]); + + default: + return $this->error('未知的维护操作: ' . esc_html($action)); + } + } + + public function get_update_info(): WP_REST_Response { + $theme = wp_get_theme(); + $info = [ + 'current_version' => $theme->get('Version') ?: '0.0.0', + 'name' => $theme->get('Name') ?: 'AKSRT', + 'update_available'=> false, + 'remote_version' => '', + 'download_url' => '', + 'changelog' => '', + 'last_check' => 0, + 'dismissed' => '', + ]; + + if (class_exists('AKSRT_Update_Checker')) { + $stored = AKSRT_Update_Checker::get_instance()->get_update_info(); + if (is_array($stored)) { + $info = array_merge($info, $stored); + } + } + + $info['dismissed'] = (string) get_user_meta(get_current_user_id(), 'aksrt_dismissed_update', true); + + return $this->success(['data' => $info]); + } + + public function detect_update(): WP_REST_Response { + if (!class_exists('AKSRT_Update_Checker')) { + return $this->error(__('更新检查器不可用', 'aksrt'), 500); + } + + AKSRT_Update_Checker::get_instance()->do_check(); + $stored = AKSRT_Update_Checker::get_instance()->get_update_info(); + + return $this->success([ + 'message' => __('已完成更新检测', 'aksrt'), + 'data' => is_array($stored) ? $stored : [], + ]); + } + + public function dismiss_update(WP_REST_Request $request): WP_REST_Response { + $params = $this->get_json_params($request); + $version = sanitize_text_field((string) ($params['version'] ?? '')); + + if ($version === '') { + return $this->error(__('缺少版本号', 'aksrt'), 422); + } + + update_user_meta(get_current_user_id(), 'aksrt_dismissed_update', $version); + + return $this->success([ + 'message' => __('已忽略此版本提醒', 'aksrt'), + 'data' => ['version' => $version], + ]); + } + + public function rest_health_email_verification(): WP_REST_Response { + $feature_enabled = class_exists('AKSRT_Settings') && AKSRT_Settings::registration_requires_email_verification(); + $pending_users = 0; + + if (class_exists('AKSRT_Auth_Email_Verification')) { + global $wpdb; + $pending_users = (int) $wpdb->get_var( + "SELECT COUNT(*) FROM {$wpdb->usermeta} WHERE meta_key = 'aksrt_email_verified' AND CAST(meta_value AS UNSIGNED) = 0" + ); + } + + return $this->success(['data' => [ + 'feature_enabled' => $feature_enabled, + 'pending_users' => $pending_users, + 'rest_blocking_active' => !empty(get_option('aksrt_rest_block_email_verification', false)), + ]]); + } + + public function rest_set_rest_block(WP_REST_Request $request): WP_REST_Response { + $params = $this->get_json_params($request); + $enabled = !empty($params['enabled']); + update_option('aksrt_rest_block_email_verification', $enabled); + + return $this->success([ + 'message' => $enabled ? __('已启用 REST 邮箱验证阻断', 'aksrt') : __('已关闭 REST 邮箱验证阻断', 'aksrt'), + 'data' => ['enabled' => $enabled], + ]); + } + + public function rest_email_rollback_list(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Email_Rollback')) { + return $this->success(['data' => ['items' => [], 'total' => 0, 'page' => 1, 'per_page' => 20]]); + } + + $page = max(1, (int) $request->get_param('page')); + $per_page = min(100, max(1, (int) $request->get_param('per_page'))); + $data = AKSRT_Email_Rollback::list_recent_changes($page, $per_page); + + return $this->success(['data' => $data]); + } + + public function rest_email_rollback_execute(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Email_Rollback')) { + return $this->error(__('邮箱回滚功能不可用', 'aksrt'), 500); + } + + $user_id = (int) $request->get_param('id'); + $result = AKSRT_Email_Rollback::rollback($user_id, get_current_user_id()); + if (empty($result['success'])) { + return $this->error((string) ($result['error'] ?? __('邮箱回滚失败', 'aksrt')), 422); + } + + return $this->success([ + 'message' => (string) ($result['message'] ?? __('邮箱已回滚', 'aksrt')), + 'data' => $result, + ]); + } +} diff --git a/inc/classes/admin/class-aksrt-admin-user-controller-auth-phone-operations.php b/inc/classes/admin/class-aksrt-admin-user-controller-auth-phone-operations.php new file mode 100644 index 00000000..86a5efff --- /dev/null +++ b/inc/classes/admin/class-aksrt-admin-user-controller-auth-phone-operations.php @@ -0,0 +1,287 @@ +get_json_params($request); + $payload = is_array($params) ? $params : []; + $login = sanitize_text_field((string) ($payload['log'] ?? $payload['login'] ?? '')); + $password = (string) ($payload['pwd'] ?? $payload['password'] ?? ''); + + if ($login === '' || $password === '') { + return $this->error('请输入用户名和密码'); + } + + if (!class_exists('AKSRT_Auth_Service')) { + return $this->error('认证服务不可用', 500); + } + + $result = AKSRT_Auth_Service::login_user([ + ...$payload, + 'log' => $login, + 'pwd' => $password, + 'rememberme' => !empty($payload['rememberme']) ? (string) $payload['rememberme'] : 'forever', + ], true, true); + + if (empty($result['success'])) { + $error = $result['error'] ?? '登录失败'; + $verify_url = $result['verify_url'] ?? ''; + $response = ['success' => false, 'message' => $error]; + if ($verify_url) { + $response['verify_url'] = $verify_url; + } + return new WP_REST_Response($response, 401); + } + + $user = wp_get_current_user(); + return $this->success([ + 'message' => __('登录成功', 'aksrt'), + 'redirect' => $result['redirect'] ?? home_url('/'), + 'user' => [ + 'id' => $user->ID, + 'display_name' => $user->display_name, + 'avatar_url' => function_exists('aksrt_get_avatar_url') + ? aksrt_get_avatar_url($user->ID, ['size' => 48]) + : get_avatar_url($user->ID, ['size' => 48]), + ], + ]); + } + + /** + * POST /auth/modal-register — 弹窗注册 + */ + public function modal_register(WP_REST_Request $request): WP_REST_Response { + $params = $this->get_json_params($request); + $payload = is_array($params) ? $params : []; + $username = sanitize_user((string) ($payload['user_login'] ?? $payload['username'] ?? ''), true); + $email = sanitize_email((string) ($payload['user_email'] ?? $payload['email'] ?? '')); + $password = (string) ($payload['user_pass'] ?? $payload['password'] ?? ''); + $confirm = (string) ($payload['user_pass_confirm'] ?? $payload['password_confirm'] ?? $payload['confirm_password'] ?? $password); + $invite_code = sanitize_text_field((string) ($payload['invite_code'] ?? '')); + $register_nonce = sanitize_text_field((string) ($payload['_register_nonce'] ?? '')); + $agree_terms = !empty($payload['agree_terms']) ? '1' : ''; + + if ($username === '' || $email === '' || $password === '') { + return $this->error('请填写所有必填项'); + } + + if (!is_email($email)) { + return $this->error('邮箱格式不正确'); + } + + if (strlen($password) < 6) { + return $this->error('密码至少 6 位'); + } + + if (!class_exists('AKSRT_Auth_Service')) { + return $this->error('认证服务不可用', 500); + } + + $payload['user_login'] = $username; + $payload['user_email'] = $email; + $payload['user_pass'] = $password; + $payload['user_pass_confirm'] = $confirm; + $payload['invite_code'] = $invite_code; + $payload['_register_nonce'] = $register_nonce; + $payload['agree_terms'] = $agree_terms; + + $result = AKSRT_Auth_Service::register_user($payload); + + if (empty($result['success'])) { + return $this->error($result['error'] ?? '注册失败'); + } + + $verification_required = !empty($result['verification_required']); + $login_url = function_exists('aksrt_get_login_url') ? aksrt_get_login_url() : wp_login_url(); + $registered_user = null; + $current_user = wp_get_current_user(); + if ( + $current_user instanceof WP_User + && $current_user->ID > 0 + && !empty($result['user_id']) + && (int) $result['user_id'] === (int) $current_user->ID + ) { + $registered_user = $current_user; + } + $user = $current_user; + $needs_verify = $verification_required; + + return $this->success([ + 'message' => $needs_verify ? __('注册成功,请验证邮箱', 'aksrt') : __('注册成功', 'aksrt'), + 'needs_verification' => $needs_verify, + 'verify_url' => $result['verify_url'] ?? '', + 'redirect' => $verification_required ? ($result['verify_url'] ?? '') : $login_url, + 'verification_required' => $verification_required, + 'login_url' => $login_url, + 'user_login' => $result['user_login'] ?? $username, + 'user_email' => $email, + 'user' => $registered_user ? [ + 'id' => $registered_user->ID, + 'display_name' => $registered_user->display_name, + 'avatar_url' => function_exists('aksrt_get_avatar_url') + ? aksrt_get_avatar_url($registered_user->ID, ['size' => 48]) + : get_avatar_url($registered_user->ID, ['size' => 48]), + ] : ($user->ID ? [ + 'id' => $user->ID, + 'display_name' => $user->display_name, + 'avatar_url' => function_exists('aksrt_get_avatar_url') + ? aksrt_get_avatar_url($user->ID, ['size' => 48]) + : get_avatar_url($user->ID, ['size' => 48]), + ] : null), + ]); + } + + /** + * GET /auth/me — 当前用户信息(用于 AJAX 刷新) + */ + public function get_current_user_info(): WP_REST_Response { + $user = wp_get_current_user(); + if (!$user->ID) { + return $this->error('未登录', 401); + } + + $unread = 0; + if (class_exists('AKSRT_Private_Message')) { + $unread = AKSRT_Private_Message::get_instance()->get_unread_count($user->ID); + } + + $credits = 0; + if (class_exists('AKSRT_Credits')) { + $credits = AKSRT_Credits::balance($user->ID); + } + + return $this->success([ + 'data' => [ + 'id' => $user->ID, + 'display_name' => $user->display_name, + 'user_email' => $user->user_email, + 'avatar_url' => function_exists('aksrt_get_avatar_url') + ? aksrt_get_avatar_url($user->ID, ['size' => 48]) + : get_avatar_url($user->ID, ['size' => 48]), + 'is_admin' => current_user_can('manage_options'), + 'unread' => $unread, + 'credits' => $credits, + ], + ]); + } + + /** + * POST /auth/send-sms-code — 发送短信验证码 + */ + public function auth_send_sms_code(WP_REST_Request $request): WP_REST_Response { + $params = $this->get_json_params($request); + $phone = sanitize_text_field((string) ($params['phone'] ?? '')); + + if (!class_exists('AKSRT_SMS')) { + return $this->error('短信服务不可用'); + } + + $result = AKSRT_SMS::get_instance()->send_code($phone); + + if (!empty($result['success'])) { + return $this->success([ + 'message' => $result['message'], + 'remaining_time' => $result['remaining_time'] ?? 60, + ]); + } + + $response = [ + 'success' => false, + 'message' => $result['message'], + ]; + if (isset($result['remaining_time'])) { + $response['data'] = ['remaining_time' => $result['remaining_time']]; + } + return new WP_REST_Response($response, 400); + } + + /** + * POST /auth/phone-register — 手机号注册 + */ + public function auth_phone_register(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Auth_Service')) { + return $this->error('认证服务不可用'); + } + + $params = $this->get_json_params($request); + $result = AKSRT_Auth_Service::register_by_phone($params); + + if (!empty($result['success'])) { + return $this->success([ + 'message' => $result['message'] ?? '注册成功', + 'redirect' => $result['redirect'] ?? home_url('/'), + 'user_id' => $result['user_id'] ?? 0, + ]); + } + + return $this->error($result['error'] ?? '注册失败'); + } + + /** + * POST /auth/phone-login — 手机号 + 密码登录 + */ + public function auth_phone_login(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Auth_Service')) { + return $this->error('认证服务不可用'); + } + + $params = $this->get_json_params($request); + $result = AKSRT_Auth_Service::login_by_phone_password($params); + + if (!empty($result['success'])) { + return $this->success([ + 'redirect' => $result['redirect'] ?? home_url('/'), + ]); + } + + return $this->error($result['error'] ?? '登录失败'); + } + + /** + * POST /auth/phone-code-login — 手机号 + 验证码免密登录 + */ + public function auth_phone_code_login(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Auth_Service')) { + return $this->error('认证服务不可用'); + } + + $params = $this->get_json_params($request); + $result = AKSRT_Auth_Service::login_by_phone_code($params); + + if (!empty($result['success'])) { + return $this->success([ + 'redirect' => $result['redirect'] ?? home_url('/'), + ]); + } + + return $this->error($result['error'] ?? '登录失败'); + } + + /** + * POST /auth/bind-phone — 绑定手机号 + */ + public function auth_bind_phone(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Auth_Service')) { + return $this->error('认证服务不可用'); + } + + $params = $this->get_json_params($request); + $result = AKSRT_Auth_Service::bind_phone($params); + + if (!empty($result['success'])) { + return $this->success(['message' => $result['message']]); + } + + return $this->error($result['error'] ?? '绑定失败'); + } +} diff --git a/inc/classes/admin/class-aksrt-admin-user-controller-follow-operations.php b/inc/classes/admin/class-aksrt-admin-user-controller-follow-operations.php new file mode 100644 index 00000000..c159ebde --- /dev/null +++ b/inc/classes/admin/class-aksrt-admin-user-controller-follow-operations.php @@ -0,0 +1,89 @@ +error('关注系统未启用', 500); + } + + $params = $this->get_json_params($request); + $target_user_id = (int) ($params['target_user_id'] ?? 0); + $current_user_id = get_current_user_id(); + + if ($target_user_id <= 0 || $target_user_id === $current_user_id) { + return $this->error('参数无效'); + } + + $target_user = get_userdata($target_user_id); + if (!$target_user) { + return $this->error('用户不存在', 404); + } + + $is_following = AKSRT_Follow::get_instance()->toggle($current_user_id, $target_user_id); + + return $this->success([ + 'data' => [ + 'is_following' => $is_following, + 'follower_count' => AKSRT_Follow::get_instance()->get_follower_count($target_user_id), + ], + ]); + } + + /** + * GET /follow/status — 查询关注状态 + */ + public function follow_status(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Follow')) { + return $this->success(['data' => ['is_following' => false]]); + } + + $target_user_id = (int) $request->get_param('target_user_id'); + $current_user_id = get_current_user_id(); + + if ($target_user_id <= 0) { + return $this->error('参数无效'); + } + + $is_following = AKSRT_Follow::get_instance()->is_following($current_user_id, $target_user_id); + + return $this->success([ + 'data' => [ + 'is_following' => $is_following, + 'follower_count' => AKSRT_Follow::get_instance()->get_follower_count($target_user_id), + 'following_count' => AKSRT_Follow::get_instance()->get_following_count($target_user_id), + ], + ]); + } + + /** + * GET /follow/counts — 获取关注/粉丝数 + */ + public function follow_counts(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Follow')) { + return $this->success(['data' => ['followers' => 0, 'following' => 0]]); + } + + $user_id = (int) $request->get_param('user_id'); + if ($user_id <= 0) { + return $this->error('参数无效'); + } + + return $this->success([ + 'data' => [ + 'followers' => AKSRT_Follow::get_instance()->get_follower_count($user_id), + 'following' => AKSRT_Follow::get_instance()->get_following_count($user_id), + ], + ]); + } +} diff --git a/inc/classes/admin/class-aksrt-admin-user-controller-invite-operations.php b/inc/classes/admin/class-aksrt-admin-user-controller-invite-operations.php new file mode 100644 index 00000000..4f664706 --- /dev/null +++ b/inc/classes/admin/class-aksrt-admin-user-controller-invite-operations.php @@ -0,0 +1,450 @@ +get_param('page') ?: 1)); + $per_page = max(1, min(100, (int) ($request->get_param('per_page') ?: 20))); + $keyword = trim(sanitize_text_field((string) $request->get_param('keyword'))); + $status = sanitize_key((string) $request->get_param('status')); + $source = sanitize_key((string) $request->get_param('source')); + $date_from = sanitize_text_field((string) $request->get_param('date_from')); + $date_to = sanitize_text_field((string) $request->get_param('date_to')); + + $usermeta_table = $wpdb->usermeta; + $invite_table = class_exists('AKSRT_DB_Migrator') ? AKSRT_DB_Migrator::table('invite') : ''; + + $invite_rows = []; + if ($invite_table) { + $records = $wpdb->get_results("SELECT * FROM {$invite_table}", ARRAY_A); + foreach ((array) $records as $record) { + $invite_rows[(string) $record['invite_code']] = $record; + } + } + + $items = []; + $user_rows = $wpdb->get_results( + "SELECT user_id, meta_value AS invite_code FROM {$usermeta_table} WHERE meta_key = 'aksrt_invite_code' ORDER BY umeta_id DESC", + ARRAY_A + ); + + foreach ((array) $user_rows as $row) { + $user_id = (int) $row['user_id']; + $user = get_userdata($user_id); + $code = sanitize_key((string) $row['invite_code']); + $record = $invite_rows[$code] ?? null; + + $item = [ + 'row_key' => 'user:' . $user_id, + 'source_type' => 'user', + 'source_label' => __('用户邀请码', 'aksrt'), + 'user' => $this->build_admin_user_summary($user_id, $user), + 'invite_code' => $code, + 'status' => $record ? 'used' : 'active', + 'status_label' => $record ? __('已使用', 'aksrt') : __('可用', 'aksrt'), + 'created_at' => $user ? (string) $user->user_registered : '', + 'used_at' => $record['created_at'] ?? '', + 'note' => '', + 'batch_id' => '', + 'created_by' => null, + 'registration_status' => $record['status'] ?? '', + 'invitee' => $this->build_admin_user_summary((int) ($record['invitee_id'] ?? 0)), + 'can_bulk' => false, + 'actions' => ['disable'], + ]; + + if ($this->invite_item_matches_filters($item, $keyword, $status, $source, $date_from, $date_to)) { + $items[] = $item; + } + } + + foreach ($this->load_standalone_invite_codes() as $entry) { + $code = sanitize_key((string) ($entry['code'] ?? '')); + $record = $invite_rows[$code] ?? null; + $used_by = (int) ($entry['used_by'] ?? 0); + $disabled = !empty($entry['disabled']); + $used = !empty($entry['used']) || $used_by > 0 || !empty($record['invitee_id']); + $status_key = $disabled ? 'disabled' : ($used ? 'used' : 'active'); + $status_map = [ + 'active' => __('可用', 'aksrt'), + 'used' => __('已使用', 'aksrt'), + 'disabled' => __('已禁用', 'aksrt'), + ]; + $invitee_id = $used_by > 0 ? $used_by : (int) ($record['invitee_id'] ?? 0); + + $item = [ + 'row_key' => 'standalone:' . $code, + 'source_type' => 'standalone', + 'source_label' => __('独立邀请码', 'aksrt'), + 'user' => null, + 'invite_code' => $code, + 'status' => $status_key, + 'status_label' => $status_map[$status_key], + 'created_at' => (string) ($entry['created_at'] ?? ''), + 'used_at' => (string) ($entry['used_at'] ?? ($record['created_at'] ?? '')), + 'note' => (string) ($entry['note'] ?? ''), + 'batch_id' => (string) ($entry['batch_id'] ?? ''), + 'created_by' => $this->build_admin_user_summary((int) ($entry['created_by'] ?? 0)), + 'registration_status' => $record['status'] ?? '', + 'invitee' => $this->build_admin_user_summary($invitee_id), + 'can_bulk' => true, + 'actions' => ['disable', 'delete'], + ]; + + if ($this->invite_item_matches_filters($item, $keyword, $status, $source, $date_from, $date_to)) { + $items[] = $item; + } + } + + usort($items, static function ($left, $right) { + return strcmp((string) ($right['created_at'] ?? ''), (string) ($left['created_at'] ?? '')); + }); + + $total = count($items); + $offset = ($page - 1) * $per_page; + $paged = array_slice($items, $offset, $per_page); + $stats = $this->build_invite_operation_stats($items, $invite_rows); + + return $this->success([ + 'data' => [ + 'items' => array_values($paged), + 'total' => $total, + 'page' => $page, + 'per_page' => $per_page, + 'total_pages' => max(1, (int) ceil(max(1, $total) / $per_page)), + 'stats' => $stats, + 'options' => [ + 'statuses' => [ + ['label' => __('全部状态', 'aksrt'), 'value' => ''], + ['label' => __('可用', 'aksrt'), 'value' => 'active'], + ['label' => __('已使用', 'aksrt'), 'value' => 'used'], + ['label' => __('已禁用', 'aksrt'), 'value' => 'disabled'], + ], + 'sources' => [ + ['label' => __('全部来源', 'aksrt'), 'value' => ''], + ['label' => __('用户邀请码', 'aksrt'), 'value' => 'user'], + ['label' => __('独立邀请码', 'aksrt'), 'value' => 'standalone'], + ], + ], + ], + ]); + } + + /** + * POST /invite/codes — 批量生成邀请码(管理后台) + * Body: { count: int, length: int, prefix: string } + * 为指定用户或批量创建邀请码。 + */ + public function generate_invite_codes(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Invite')) { + return $this->error(__('邀请系统未启用', 'aksrt'), 500); + } + + $params = $this->get_json_params($request); + $count = max(1, min(100, (int) ($params['count'] ?? 1))); + $length = max(4, min(12, (int) ($params['length'] ?? 6))); + $prefix = sanitize_key((string) ($params['prefix'] ?? '')); + $note = sanitize_textarea_field((string) ($params['note'] ?? '')); + $batch_id = sanitize_text_field((string) ($params['batch_id'] ?? '')); + + if ($batch_id === '') { + $batch_id = 'INV-' . gmdate('YmdHis') . '-' . wp_generate_password(4, false, false); + } + + $generated = []; + $codes = $this->load_standalone_invite_codes(); + $alphabet = 'abcdefghjkmnpqrstuvwxyz23456789'; + + for ($i = 0; $i < $count; $i++) { + $code = ''; + for ($attempt = 0; $attempt < 12; $attempt++) { + $candidate = $prefix; + for ($j = 0; $j < $length; $j++) { + $candidate .= $alphabet[random_int(0, strlen($alphabet) - 1)]; + } + $candidate = sanitize_key($candidate); + if (!$this->invite_code_exists_globally($candidate, $codes)) { + $code = $candidate; + break; + } + } + + if ($code === '') { + continue; + } + + $codes[] = [ + 'code' => $code, + 'created_at' => current_time('mysql'), + 'used' => false, + 'used_by' => 0, + 'used_at' => '', + 'disabled' => false, + 'note' => $note, + 'batch_id' => $batch_id, + 'created_by' => get_current_user_id(), + ]; + $generated[] = $code; + } + + $this->save_standalone_invite_codes($codes); + + return $this->success([ + 'message' => sprintf(__('已生成 %d 个邀请码', 'aksrt'), count($generated)), + 'data' => [ + 'codes' => $generated, + 'batch_id' => $batch_id, + ], + ]); + } + + /** + * POST /invite/codes/{id}/disable — 禁用邀请码 + */ + public function disable_invite_code(WP_REST_Request $request): WP_REST_Response { + $user_id = (int) $request->get_param('id'); + + if ($user_id <= 0) { + return $this->error(__('参数无效', 'aksrt')); + } + + $removed = $this->remove_user_invite_code($user_id); + if (!$removed) { + return $this->error(__('邀请码不存在或已禁用', 'aksrt')); + } + + return $this->success(['message' => __('邀请码已禁用', 'aksrt')]); + } + + public function bulk_update_invite_codes(WP_REST_Request $request): WP_REST_Response { + $params = $this->get_json_params($request); + $action = sanitize_key((string) ($params['action'] ?? '')); + $targets = isset($params['targets']) && is_array($params['targets']) ? $params['targets'] : []; + + if (!in_array($action, ['disable', 'delete'], true) || empty($targets)) { + return $this->error(__('批量操作参数无效', 'aksrt')); + } + + $codes = $this->load_standalone_invite_codes(); + $processed = 0; + + foreach ($targets as $target) { + $source_type = sanitize_key((string) ($target['source_type'] ?? '')); + if ($source_type === 'user') { + $user_id = (int) ($target['user_id'] ?? 0); + if ($action === 'disable' && $this->remove_user_invite_code($user_id)) { + $processed++; + } + continue; + } + + if ($source_type !== 'standalone') { + continue; + } + + $code = sanitize_key((string) ($target['code'] ?? $target['invite_code'] ?? '')); + foreach ($codes as $index => $entry) { + $entry_code = sanitize_key((string) ($entry['code'] ?? '')); + if ($entry_code !== $code) { + continue; + } + + if ($action === 'delete' && empty($entry['used']) && empty($entry['used_by'])) { + unset($codes[$index]); + } else { + $entry['disabled'] = true; + $codes[$index] = $entry; + } + $processed++; + break; + } + } + + $this->save_standalone_invite_codes($codes); + + return $this->success([ + 'message' => sprintf(__('已处理 %d 条邀请码记录', 'aksrt'), $processed), + ]); + } + + /** + * GET /invite/stats — 邀请系统统计 + */ + public function get_invite_stats(): WP_REST_Response { + global $wpdb; + + $items = []; + $user_records = $wpdb->get_results( + "SELECT user_id, meta_value AS invite_code FROM {$wpdb->usermeta} WHERE meta_key = 'aksrt_invite_code'", + ARRAY_A + ); + + foreach ((array) $user_records as $row) { + $items[] = [ + 'source_type' => 'user', + 'status' => 'active', + ]; + } + + foreach ($this->load_standalone_invite_codes() as $entry) { + $used = !empty($entry['used']) || !empty($entry['used_by']); + $disabled = !empty($entry['disabled']); + $items[] = [ + 'source_type' => 'standalone', + 'status' => $disabled ? 'disabled' : ($used ? 'used' : 'active'), + ]; + } + + $stats = $this->build_invite_operation_stats($items); + return $this->success(['data' => $stats]); + } + + private function load_standalone_invite_codes(): array { + $codes = get_option('aksrt_standalone_invite_codes', []); + return is_array($codes) ? array_values($codes) : []; + } + + private function save_standalone_invite_codes(array $codes): void { + update_option('aksrt_standalone_invite_codes', array_values($codes), false); + } + + private function invite_code_exists_globally(string $candidate, array $standalone_codes = []): bool { + if ($candidate === '') { + return true; + } + + global $wpdb; + + $existing_user_code = $wpdb->get_var($wpdb->prepare( + "SELECT umeta_id FROM {$wpdb->usermeta} WHERE meta_key = 'aksrt_invite_code' AND meta_value = %s LIMIT 1", + $candidate + )); + if ($existing_user_code) { + return true; + } + + foreach ($standalone_codes as $entry) { + if (sanitize_key((string) ($entry['code'] ?? '')) === $candidate) { + return true; + } + } + + $invite_table = class_exists('AKSRT_DB_Migrator') ? AKSRT_DB_Migrator::table('invite') : ''; + if ($invite_table) { + $existing_invite = $wpdb->get_var($wpdb->prepare( + "SELECT id FROM {$invite_table} WHERE invite_code = %s LIMIT 1", + $candidate + )); + if ($existing_invite) { + return true; + } + } + + return false; + } + + private function invite_item_matches_filters(array $item, string $keyword, string $status, string $source, string $date_from, string $date_to): bool { + if ($status !== '' && ($item['status'] ?? '') !== $status) { + return false; + } + if ($source !== '' && ($item['source_type'] ?? '') !== $source) { + return false; + } + + $created_at = (string) ($item['created_at'] ?? ''); + if ($date_from !== '' && $created_at !== '' && strcmp($created_at, $date_from . ' 00:00:00') < 0) { + return false; + } + if ($date_to !== '' && $created_at !== '' && strcmp($created_at, $date_to . ' 23:59:59') > 0) { + return false; + } + + if ($keyword === '') { + return true; + } + + $haystack = implode(' ', array_filter([ + (string) ($item['invite_code'] ?? ''), + (string) ($item['status_label'] ?? ''), + (string) ($item['note'] ?? ''), + (string) ($item['batch_id'] ?? ''), + (string) ($item['user']['display_name'] ?? ''), + (string) ($item['invitee']['display_name'] ?? ''), + (string) ($item['invitee']['email'] ?? ''), + ])); + + return mb_stripos($haystack, $keyword) !== false; + } + + private function build_invite_operation_stats(array $items, array $invite_rows = []): array { + $stats = [ + 'total_codes' => count($items), + 'active_codes' => 0, + 'used_codes' => 0, + 'disabled_codes' => 0, + 'user_codes' => 0, + 'standalone_codes' => 0, + 'total_invites' => count($invite_rows), + 'total_rewarded' => 0, + ]; + + foreach ($items as $item) { + $status = $item['status'] ?? ''; + if ($status === 'active') { + $stats['active_codes']++; + } elseif ($status === 'used') { + $stats['used_codes']++; + } elseif ($status === 'disabled') { + $stats['disabled_codes']++; + } + + if (($item['source_type'] ?? '') === 'user') { + $stats['user_codes']++; + } elseif (($item['source_type'] ?? '') === 'standalone') { + $stats['standalone_codes']++; + } + } + + foreach ($invite_rows as $row) { + if (($row['status'] ?? '') === 'rewarded') { + $stats['total_rewarded']++; + } + } + + return $stats; + } + + private function remove_user_invite_code(int $user_id): bool { + $user_id = (int) $user_id; + if ($user_id <= 0) { + return false; + } + + $code = get_user_meta($user_id, 'aksrt_invite_code', true); + if (!$code) { + return false; + } + + delete_user_meta($user_id, 'aksrt_invite_code'); + + $map = get_option('aksrt_invite_code_map', []); + if (is_array($map) && isset($map[$code])) { + unset($map[$code]); + update_option('aksrt_invite_code_map', $map, false); + } + + return true; + } +} diff --git a/inc/classes/admin/class-aksrt-admin-user-controller-report-operations.php b/inc/classes/admin/class-aksrt-admin-user-controller-report-operations.php new file mode 100644 index 00000000..3495f286 --- /dev/null +++ b/inc/classes/admin/class-aksrt-admin-user-controller-report-operations.php @@ -0,0 +1,471 @@ +success([ + 'data' => [ + 'items' => [], + 'reports' => [], + 'counts' => ['pending' => 0, 'reviewing' => 0, 'resolved' => 0, 'dismissed' => 0, 'total' => 0], + 'total' => 0, + 'page' => 1, + 'options' => [ + 'statuses' => $this->get_report_status_options(), + ], + ], + ]); + } + + global $wpdb; + + $table = AKSRT_DB_Migrator::table('report'); + if (!$table) { + return $this->success([ + 'data' => [ + 'items' => [], + 'reports' => [], + 'counts' => ['pending' => 0, 'reviewing' => 0, 'resolved' => 0, 'dismissed' => 0, 'total' => 0], + 'total' => 0, + 'page' => 1, + 'options' => [ + 'statuses' => $this->get_report_status_options(), + ], + ], + ]); + } + + $page = max(1, (int) ($request->get_param('page') ?: 1)); + $per_page = max(1, min(100, (int) ($request->get_param('per_page') ?: $request->get_param('limit') ?: 20))); + $status = sanitize_key((string) $request->get_param('status')); + $category = sanitize_key((string) $request->get_param('category')); + $target_type = sanitize_key((string) $request->get_param('target_type')); + $keyword = trim(sanitize_text_field((string) $request->get_param('keyword'))); + $date_from = sanitize_text_field((string) $request->get_param('date_from')); + $date_to = sanitize_text_field((string) $request->get_param('date_to')); + + $where = []; + $params = []; + + if ($status !== '') { + $where[] = 'status = %s'; + $params[] = $status; + } + if ($category !== '') { + $where[] = 'category = %s'; + $params[] = $category; + } + if ($target_type !== '') { + $where[] = 'target_type = %s'; + $params[] = $target_type; + } + if ($date_from !== '') { + $where[] = 'created_at >= %s'; + $params[] = $date_from . ' 00:00:00'; + } + if ($date_to !== '') { + $where[] = 'created_at <= %s'; + $params[] = $date_to . ' 23:59:59'; + } + + $where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; + $sql = "SELECT * FROM {$table} {$where_sql} ORDER BY created_at DESC LIMIT 500"; + $rows = $params ? $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A) : $wpdb->get_results($sql, ARRAY_A); + + $items = []; + foreach ((array) $rows as $row) { + $item = $this->build_report_admin_item($row); + if ($keyword !== '' && !$this->report_matches_keyword($item, $keyword)) { + continue; + } + $items[] = $item; + } + + $total = count($items); + $offset = ($page - 1) * $per_page; + $paged = array_slice($items, $offset, $per_page); + $counts = AKSRT_Report_Center::get_instance()->get_status_counts(); + $categories = []; + foreach ((array) $rows as $row) { + $cat = sanitize_key((string) ($row['category'] ?? '')); + if ($cat !== '') { + $categories[$cat] = $cat; + } + } + + return $this->success([ + 'data' => [ + 'items' => array_values($paged), + 'reports' => array_values($paged), + 'total' => $total, + 'page' => $page, + 'per_page' => $per_page, + 'counts' => $counts, + 'options' => [ + 'statuses' => $this->get_report_status_options(), + 'actions' => $this->get_report_action_options(), + 'targets' => $this->get_report_target_type_options(), + 'categories' => array_map(static function ($item) { + return ['label' => $item, 'value' => $item]; + }, array_values($categories)), + ], + ], + ]); + } + + public function update_report_status(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Report_Center')) { + return $this->error(__('举报系统未启用', 'aksrt')); + } + + $params = $this->get_json_params($request); + $report_id = (int) ($params['report_id'] ?? 0); + if ($report_id <= 0) { + return $this->error(__('参数无效', 'aksrt')); + } + + return $this->handle_report_process($report_id, $params); + } + + public function get_report_counts(): WP_REST_Response { + if (!class_exists('AKSRT_Report_Center')) { + return $this->success(['pending' => 0, 'reviewing' => 0, 'resolved' => 0, 'dismissed' => 0, 'total' => 0]); + } + return $this->success(AKSRT_Report_Center::get_instance()->get_status_counts()); + } + + public function get_report_detail(WP_REST_Request $request): WP_REST_Response { + global $wpdb; + + $report_id = (int) $request->get_param('id'); + $table = AKSRT_DB_Migrator::table('report'); + + if ($report_id <= 0 || !$table) { + return $this->error(__('举报工单不存在', 'aksrt'), 404); + } + + $row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE id = %d", $report_id), ARRAY_A); + if (!$row) { + return $this->error(__('举报工单不存在', 'aksrt'), 404); + } + + $related = (int) $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM {$table} WHERE target_type = %s AND target_id = %d", + sanitize_key((string) $row['target_type']), + (int) $row['target_id'] + )); + + return $this->success([ + 'data' => [ + 'report' => $this->build_report_admin_item($row), + 'related' => ['same_target_count' => $related], + 'options' => [ + 'statuses' => $this->get_report_status_options(), + 'actions' => $this->get_report_action_options(sanitize_key((string) $row['target_type'])), + ], + ], + ]); + } + + public function process_report(WP_REST_Request $request): WP_REST_Response { + $report_id = (int) $request->get_param('id'); + if ($report_id <= 0) { + return $this->error(__('参数无效', 'aksrt')); + } + + return $this->handle_report_process($report_id, $this->get_json_params($request)); + } + + private function handle_report_process(int $report_id, array $payload): WP_REST_Response { + global $wpdb; + + $table = AKSRT_DB_Migrator::table('report'); + if (!$table) { + return $this->error(__('举报系统未启用', 'aksrt')); + } + + $status = sanitize_key((string) ($payload['status'] ?? '')); + $action_taken = sanitize_key((string) ($payload['action_taken'] ?? 'none')); + $note = sanitize_textarea_field((string) ($payload['note'] ?? '')); + + $valid_statuses = array_column($this->get_report_status_options(), 'value'); + $valid_actions = array_column($this->get_report_action_options(), 'value'); + + if ($status === '' || !in_array($status, $valid_statuses, true)) { + return $this->error(__('处理状态无效', 'aksrt')); + } + if (!in_array($action_taken, $valid_actions, true)) { + $action_taken = 'none'; + } + + $row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE id = %d", $report_id), ARRAY_A); + if (!$row) { + return $this->error(__('举报工单不存在', 'aksrt'), 404); + } + + $effect = $this->apply_report_action( + sanitize_key((string) $row['target_type']), + (int) $row['target_id'], + $action_taken + ); + + $notes = json_decode((string) ($row['notes_json'] ?? '[]'), true); + if (!is_array($notes)) { + $notes = []; + } + $notes[] = [ + 'note' => $note, + 'status' => $status, + 'action_taken' => $action_taken, + 'operator' => get_current_user_id(), + 'timestamp' => current_time('mysql'), + 'effect' => $effect, + ]; + + $updated = $wpdb->update( + $table, + [ + 'status' => $status, + 'updated_at' => current_time('mysql'), + 'notes_json' => wp_json_encode($notes), + ], + ['id' => $report_id], + ['%s', '%s', '%s'], + ['%d'] + ); + + if ($updated === false) { + return $this->error(__('处理失败', 'aksrt')); + } + + return $this->success([ + 'message' => __('举报工单已处理', 'aksrt'), + ]); + } + + private function build_report_admin_item(array $row): array { + $reporter = get_userdata((int) ($row['reporter_id'] ?? 0)); + $notes = json_decode((string) ($row['notes_json'] ?? '[]'), true); + if (!is_array($notes)) { + $notes = []; + } + + $normalized_notes = []; + foreach ($notes as $entry) { + $operator_id = (int) ($entry['operator'] ?? 0); + $operator_user = $operator_id > 0 ? get_userdata($operator_id) : null; + $normalized_notes[] = [ + 'note' => (string) ($entry['note'] ?? ''), + 'status' => sanitize_key((string) ($entry['status'] ?? '')), + 'action_taken' => sanitize_key((string) ($entry['action_taken'] ?? 'none')), + 'operator' => $operator_id, + 'operator_name' => $operator_user ? $operator_user->display_name : '', + 'timestamp' => (string) ($entry['timestamp'] ?? ''), + 'effect' => (string) ($entry['effect'] ?? ''), + ]; + } + + $last_note = !empty($normalized_notes) ? $normalized_notes[count($normalized_notes) - 1] : null; + + return [ + 'id' => (int) ($row['id'] ?? 0), + 'target_type' => sanitize_key((string) ($row['target_type'] ?? '')), + 'target_id' => (int) ($row['target_id'] ?? 0), + 'reason' => (string) ($row['reason'] ?? ''), + 'category' => sanitize_key((string) ($row['category'] ?? '')), + 'status' => sanitize_key((string) ($row['status'] ?? 'pending')), + 'created_at' => (string) ($row['created_at'] ?? ''), + 'updated_at' => (string) ($row['updated_at'] ?? ''), + 'reporter' => $this->build_admin_user_summary($reporter ? (int) $reporter->ID : 0, $reporter), + 'target' => $this->build_report_target_summary( + sanitize_key((string) ($row['target_type'] ?? '')), + (int) ($row['target_id'] ?? 0) + ), + 'notes' => $normalized_notes, + 'note_count' => count($normalized_notes), + 'last_note' => $last_note, + ]; + } + + private function report_matches_keyword(array $item, string $keyword): bool { + $haystack = implode(' ', array_filter([ + (string) ($item['reason'] ?? ''), + (string) ($item['category'] ?? ''), + (string) ($item['reporter']['display_name'] ?? ''), + (string) ($item['reporter']['email'] ?? ''), + (string) ($item['target']['title'] ?? ''), + (string) ($item['target']['display_name'] ?? ''), + (string) ($item['target']['excerpt'] ?? ''), + (string) ($item['target_id'] ?? ''), + ])); + + return mb_stripos($haystack, $keyword) !== false; + } + + private function build_report_target_summary(string $target_type, int $target_id): array { + $summary = [ + 'type' => $target_type, + 'id' => $target_id, + 'title' => '', + 'excerpt' => '', + 'display_name' => '', + 'status' => '', + 'url' => '', + 'ban_state' => '', + ]; + + if ($target_type === 'post') { + $post = get_post($target_id); + if ($post instanceof WP_Post) { + $summary['title'] = get_the_title($post); + $summary['status'] = (string) $post->post_status; + $summary['url'] = get_permalink($post); + } + } elseif ($target_type === 'comment') { + $comment = get_comment($target_id); + if ($comment instanceof WP_Comment) { + $summary['display_name'] = (string) $comment->comment_author; + $summary['excerpt'] = wp_trim_words(wp_strip_all_tags((string) $comment->comment_content), 24); + $summary['status'] = $comment->comment_approved === '1' ? 'approved' : (string) $comment->comment_approved; + $summary['url'] = get_comment_link($comment); + if (!empty($comment->comment_post_ID)) { + $summary['title'] = get_the_title((int) $comment->comment_post_ID); + } + } + } elseif ($target_type === 'user') { + $user = get_userdata($target_id); + if ($user instanceof WP_User) { + $summary['display_name'] = $user->display_name; + $summary['title'] = $user->display_name; + $summary['url'] = function_exists('aksrt_get_user_home_url') ? aksrt_get_user_home_url($target_id) : ''; + $summary['ban_state'] = (string) get_user_meta($target_id, 'aksrt_user_ban_state', true); + } + } + + return $summary; + } + + private function apply_report_action(string $target_type, int $target_id, string $action_taken): string { + if ($target_id <= 0 || $action_taken === 'none') { + return ''; + } + + if ($target_type === 'user') { + if (in_array($action_taken, ['muted', 'restricted', 'banned'], true)) { + update_user_meta($target_id, 'aksrt_user_ban_state', $action_taken); + return sprintf(__('已同步用户限制状态:%s', 'aksrt'), $action_taken); + } + + if ($action_taken === 'clear') { + delete_user_meta($target_id, 'aksrt_user_ban_state'); + return __('已清除用户限制状态', 'aksrt'); + } + + return ''; + } + + if ($action_taken === 'content_removed' && $target_type === 'post' && get_post($target_id)) { + wp_update_post([ + 'ID' => $target_id, + 'post_status' => 'pending', + ]); + return __('已将内容改为待审状态', 'aksrt'); + } + + if ($action_taken === 'content_removed' && $target_type === 'comment' && get_comment($target_id)) { + wp_set_comment_status($target_id, 'hold', true); + return __('已将评论改为待审状态', 'aksrt'); + } + + return ''; + } + + private function get_report_status_options(): array { + return [ + ['label' => __('待处理', 'aksrt'), 'value' => 'pending'], + ['label' => __('处理中', 'aksrt'), 'value' => 'reviewing'], + ['label' => __('已解决', 'aksrt'), 'value' => 'resolved'], + ['label' => __('已驳回', 'aksrt'), 'value' => 'dismissed'], + ]; + } + + private function get_report_target_type_options(): array { + return [ + ['label' => __('文章', 'aksrt'), 'value' => 'post'], + ['label' => __('评论', 'aksrt'), 'value' => 'comment'], + ['label' => __('用户', 'aksrt'), 'value' => 'user'], + ]; + } + + private function get_report_action_options(string $target_type = ''): array { + $common = [ + ['label' => __('仅更新状态', 'aksrt'), 'value' => 'none'], + ['label' => __('升级人工处理', 'aksrt'), 'value' => 'escalated'], + ]; + + if ($target_type === 'user' || $target_type === '') { + $common = array_merge($common, [ + ['label' => __('发送警告', 'aksrt'), 'value' => 'warned'], + ['label' => __('禁言', 'aksrt'), 'value' => 'muted'], + ['label' => __('限制功能', 'aksrt'), 'value' => 'restricted'], + ['label' => __('封禁账号', 'aksrt'), 'value' => 'banned'], + ['label' => __('解除限制', 'aksrt'), 'value' => 'clear'], + ]); + } + + if ($target_type === 'post' || $target_type === 'comment' || $target_type === '') { + $common[] = ['label' => __('转为待审内容', 'aksrt'), 'value' => 'content_removed']; + } + + return $common; + } + + /** + * POST /report/submit — 前台举报提交(登录用户) + */ + public function submit_report(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Report_Center')) { + return $this->error('举报系统未启用', 500); + } + + $params = $this->get_json_params($request); + $target_type = sanitize_key((string) ($params['target_type'] ?? '')); + $target_id = (int) ($params['target_id'] ?? 0); + $reason = sanitize_textarea_field((string) ($params['reason'] ?? '')); + $category = sanitize_key((string) ($params['category'] ?? '')); + + if ($target_type === '' || $target_id <= 0 || $reason === '') { + return $this->error('请填写完整的举报信息'); + } + + $valid_types = ['post', 'comment', 'user']; + if (!in_array($target_type, $valid_types, true)) { + return $this->error('无效的举报类型'); + } + + $report_id = AKSRT_Report_Center::get_instance()->submit( + get_current_user_id(), + $target_type, + $target_id, + $reason, + $category + ); + + if ($report_id > 0) { + return $this->success([ + 'message' => __('举报已提交,我们会尽快处理', 'aksrt'), + 'data' => ['report_id' => $report_id], + ]); + } + + return $this->error(__('举报提交失败', 'aksrt')); + } +} diff --git a/inc/classes/admin/class-aksrt-admin-user-controller-restriction-operations.php b/inc/classes/admin/class-aksrt-admin-user-controller-restriction-operations.php new file mode 100644 index 00000000..561021e9 --- /dev/null +++ b/inc/classes/admin/class-aksrt-admin-user-controller-restriction-operations.php @@ -0,0 +1,82 @@ +get_param('user_id'); + $user = get_userdata($user_id); + if (!$user) { + return $this->error('用户不存在', 404); + } + + if (!class_exists('AKSRT_User_Restriction')) { + return $this->success(['data' => ['restrictions' => []]]); + } + + $restrictions = AKSRT_User_Restriction::get_instance()->get_user_restrictions($user_id, false); + return $this->success(['data' => ['restrictions' => $restrictions]]); + } + + public function add_user_restriction(WP_REST_Request $request): WP_REST_Response { + $user_id = (int) $request->get_param('user_id'); + $user = get_userdata($user_id); + if (!$user) { + return $this->error('用户不存在', 404); + } + + $params = $request->get_json_params() ?? []; + $type = sanitize_key((string) ($params['type'] ?? '')); + $reason = sanitize_textarea_field((string) ($params['reason'] ?? '')); + $scope = sanitize_key((string) ($params['scope'] ?? 'global')); + $expires_at = sanitize_text_field((string) ($params['expires_at'] ?? '')); + + if (!$type) { + return $this->error('请选择限制类型'); + } + + if (!class_exists('AKSRT_User_Restriction')) { + return $this->error('限制系统未启用', 501); + } + + $result = AKSRT_User_Restriction::get_instance()->restrict( + $user_id, $type, $reason, $scope, $expires_at, get_current_user_id() + ); + + if (!$result['success']) { + return $this->error($result['message']); + } + + return $this->success(['message' => '限制已添加', 'data' => $result]); + } + + public function remove_user_restriction(WP_REST_Request $request): WP_REST_Response { + $restriction_id = (int) $request->get_param('id'); + + if (!class_exists('AKSRT_User_Restriction')) { + return $this->error('限制系统未启用', 501); + } + + $result = AKSRT_User_Restriction::get_instance()->remove_restriction($restriction_id); + if (!$result) { + return $this->error('移除失败', 500); + } + + return $this->success(['message' => '限制已移除']); + } + + public function get_restriction_stats(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_User_Restriction')) { + return $this->success(['data' => ['total' => 0, 'active' => 0, 'banned' => 0]]); + } + + $stats = AKSRT_User_Restriction::get_instance()->get_stats(); + return $this->success(['data' => $stats]); + } +} diff --git a/inc/classes/admin/class-aksrt-admin-user-controller-verify-integrations.php b/inc/classes/admin/class-aksrt-admin-user-controller-verify-integrations.php new file mode 100644 index 00000000..70903e67 --- /dev/null +++ b/inc/classes/admin/class-aksrt-admin-user-controller-verify-integrations.php @@ -0,0 +1,379 @@ +get_verify_requests(new WP_REST_Request('GET', '/')); + $data = $response->get_data(); + $items = isset($data['data']['items']) && is_array($data['data']['items']) ? $data['data']['items'] : []; + $pending = array_values(array_filter($items, static function ($item) { + return ($item['status'] ?? '') === 'pending'; + })); + + return $this->success([ + 'data' => [ + 'requests' => array_slice($pending, 0, 50), + 'total' => count($pending), + ], + ]); + } + + public function get_verify_requests(WP_REST_Request $request): WP_REST_Response { + global $wpdb; + + $page = max(1, (int) ($request->get_param('page') ?: 1)); + $per_page = max(1, min(100, (int) ($request->get_param('per_page') ?: 20))); + $keyword = trim(sanitize_text_field((string) $request->get_param('keyword'))); + $status = sanitize_key((string) $request->get_param('status')); + $date_from = sanitize_text_field((string) $request->get_param('date_from')); + $date_to = sanitize_text_field((string) $request->get_param('date_to')); + + $sql = " + SELECT u.ID, u.display_name, u.user_email, u.user_registered, + status.meta_value AS verify_status, + reason.meta_value AS verify_reason, + reviewed_at.meta_value AS reviewed_at, + reviewer.meta_value AS reviewer_id + FROM {$wpdb->users} u + INNER JOIN {$wpdb->usermeta} status + ON status.user_id = u.ID AND status.meta_key = 'aksrt_user_verify_status' + LEFT JOIN {$wpdb->usermeta} reason + ON reason.user_id = u.ID AND reason.meta_key = 'aksrt_user_verify_reason' + LEFT JOIN {$wpdb->usermeta} reviewed_at + ON reviewed_at.user_id = u.ID AND reviewed_at.meta_key = 'aksrt_user_verify_reviewed_at' + LEFT JOIN {$wpdb->usermeta} reviewer + ON reviewer.user_id = u.ID AND reviewer.meta_key = 'aksrt_user_verify_reviewer' + ORDER BY u.user_registered DESC + LIMIT 500 + "; + + $rows = $wpdb->get_results($sql, ARRAY_A); + + $items = []; + $counts = ['pending' => 0, 'verified' => 0, 'rejected' => 0, 'total' => 0]; + + foreach ((array) $rows as $row) { + $item = $this->build_verify_request_item($row); + $item_status = $item['status']; + if (isset($counts[$item_status])) { + $counts[$item_status]++; + } + $counts['total']++; + + if ($status !== '' && $item_status !== $status) { + continue; + } + + if ($date_from !== '' && strcmp((string) $item['requested_at'], $date_from . ' 00:00:00') < 0) { + continue; + } + if ($date_to !== '' && strcmp((string) $item['requested_at'], $date_to . ' 23:59:59') > 0) { + continue; + } + if ($keyword !== '' && mb_stripos(implode(' ', [ + (string) ($item['display_name'] ?? ''), + (string) ($item['email'] ?? ''), + (string) ($item['reason'] ?? ''), + ]), $keyword) === false) { + continue; + } + + $items[] = $item; + } + + $total = count($items); + $offset = ($page - 1) * $per_page; + + return $this->success([ + 'data' => [ + 'items' => array_values(array_slice($items, $offset, $per_page)), + 'total' => $total, + 'page' => $page, + 'per_page' => $per_page, + 'counts' => $counts, + 'options' => [ + 'statuses' => [ + ['label' => __('待审核', 'aksrt'), 'value' => 'pending'], + ['label' => __('已通过', 'aksrt'), 'value' => 'verified'], + ['label' => __('已驳回', 'aksrt'), 'value' => 'rejected'], + ], + ], + ], + ]); + } + + public function get_verify_request_detail(WP_REST_Request $request): WP_REST_Response { + $user_id = (int) $request->get_param('id'); + if ($user_id <= 0) { + return $this->error(__('认证申请不存在', 'aksrt'), 404); + } + + $user = get_userdata($user_id); + if (!$user) { + return $this->error(__('用户不存在', 'aksrt'), 404); + } + + $detail = $this->build_verify_request_item([ + 'ID' => $user->ID, + 'display_name' => $user->display_name, + 'user_email' => $user->user_email, + 'user_registered' => $user->user_registered, + 'verify_status' => get_user_meta($user_id, 'aksrt_user_verify_status', true), + 'verify_reason' => get_user_meta($user_id, 'aksrt_user_verify_reason', true), + 'reviewed_at' => get_user_meta($user_id, 'aksrt_user_verify_reviewed_at', true), + 'reviewer_id' => get_user_meta($user_id, 'aksrt_user_verify_reviewer', true), + ]); + + return $this->success([ + 'data' => [ + 'request' => $detail, + 'history' => $this->get_verify_history_for_user($user_id), + 'options' => [ + 'statuses' => [ + ['label' => __('通过认证', 'aksrt'), 'value' => 'verified'], + ['label' => __('驳回申请', 'aksrt'), 'value' => 'rejected'], + ], + ], + ], + ]); + } + + /** + * POST /verify/review — approve or reject a verification request. + */ + public function review_verify_request(WP_REST_Request $request): WP_REST_Response { + $params = $this->get_json_params($request); + $user_id = (int) ($params['user_id'] ?? 0); + if ($user_id <= 0) { + return $this->error(__('参数无效', 'aksrt')); + } + + return $this->perform_verify_review($user_id, $params); + } + + public function review_verify_request_by_id(WP_REST_Request $request): WP_REST_Response { + $user_id = (int) $request->get_param('id'); + if ($user_id <= 0) { + return $this->error(__('参数无效', 'aksrt')); + } + + return $this->perform_verify_review($user_id, $this->get_json_params($request)); + } + + private function perform_verify_review(int $user_id, array $payload): WP_REST_Response { + $status = sanitize_key((string) ($payload['status'] ?? '')); + $note = sanitize_textarea_field((string) ($payload['note'] ?? ($payload['reason'] ?? ''))); + + if (!in_array($status, ['verified', 'rejected'], true)) { + return $this->error(__('审核状态无效', 'aksrt')); + } + + $user = get_userdata($user_id); + if (!$user) { + return $this->error(__('用户不存在', 'aksrt'), 404); + } + + update_user_meta($user_id, 'aksrt_user_verify_status', $status); + update_user_meta($user_id, 'aksrt_user_verify_reviewed_at', current_time('mysql')); + update_user_meta($user_id, 'aksrt_user_verify_reviewer', get_current_user_id()); + + if ($note !== '') { + update_user_meta($user_id, 'aksrt_user_verify_reason', $note); + } elseif ($status === 'verified') { + delete_user_meta($user_id, 'aksrt_user_verify_reason'); + } + + $log = get_option('aksrt_verify_review_log', []); + if (!is_array($log)) { + $log = []; + } + $log[] = [ + 'user_id' => $user_id, + 'reviewer' => get_current_user_id(), + 'status' => $status, + 'reason' => $note, + 'time' => current_time('mysql'), + ]; + if (count($log) > 500) { + $log = array_slice($log, -500); + } + update_option('aksrt_verify_review_log', $log, false); + + return $this->success([ + 'message' => $status === 'verified' + ? __('已通过认证', 'aksrt') + : __('已驳回申请', 'aksrt'), + ]); + } + + private function build_verify_request_item(array $row): array { + $user_id = (int) ($row['ID'] ?? 0); + $reviewer_id = (int) ($row['reviewer_id'] ?? 0); + $reviewer = $reviewer_id > 0 ? get_userdata($reviewer_id) : null; + $status = sanitize_key((string) ($row['verify_status'] ?? 'pending')); + $email_verified = (bool) get_user_meta($user_id, 'aksrt_email_verified', true); + + return [ + 'user_id' => $user_id, + 'display_name' => (string) ($row['display_name'] ?? ''), + 'email' => (string) ($row['user_email'] ?? ''), + 'status' => $status, + 'reason' => (string) ($row['verify_reason'] ?? ''), + 'requested_at' => (string) ($row['user_registered'] ?? ''), + 'reviewed_at' => (string) ($row['reviewed_at'] ?? ''), + 'email_verified' => $email_verified, + 'reviewer' => $this->build_admin_user_summary($reviewer_id, $reviewer), + 'history_count' => count($this->get_verify_history_for_user($user_id)), + ]; + } + + private function get_verify_history_for_user(int $user_id): array { + $log = get_option('aksrt_verify_review_log', []); + if (!is_array($log)) { + return []; + } + + $history = []; + foreach ($log as $entry) { + if ((int) ($entry['user_id'] ?? 0) !== $user_id) { + continue; + } + + $reviewer_id = (int) ($entry['reviewer'] ?? 0); + $history[] = [ + 'status' => sanitize_key((string) ($entry['status'] ?? '')), + 'reason' => (string) ($entry['reason'] ?? ''), + 'time' => (string) ($entry['time'] ?? ''), + 'reviewer' => $reviewer_id, + 'reviewer_name' => $reviewer_id > 0 && ($user = get_userdata($reviewer_id)) ? $user->display_name : '', + ]; + } + + usort($history, static function ($left, $right) { + return strcmp((string) ($right['time'] ?? ''), (string) ($left['time'] ?? '')); + }); + + return $history; + } + + private function build_admin_user_summary(int $user_id, $user = null): ?array { + if ($user_id <= 0 && !$user instanceof WP_User) { + return null; + } + + if (!$user instanceof WP_User) { + $user = get_userdata($user_id); + } + if (!$user instanceof WP_User) { + return null; + } + + return [ + 'id' => (int) $user->ID, + 'display_name' => (string) $user->display_name, + 'email' => (string) $user->user_email, + 'avatar_url' => get_avatar_url((int) $user->ID, ['size' => 64]), + ]; + } + + /** + * GET /sms/status — SMS provider connection status. + */ + public function get_sms_status(): WP_REST_Response { + if (class_exists('AKSRT_SMS')) { + return $this->success(['data' => AKSRT_SMS::get_instance()->get_provider_status()]); + } + + return $this->success([ + 'data' => [ + 'enabled' => false, + 'provider' => 'none', + 'configured' => false, + 'available' => false, + 'verified' => false, + 'transport' => '', + 'missing_fields' => [], + 'code_length' => 6, + 'code_expire' => 300, + 'rate_limit' => 60, + 'daily_limit' => 10, + 'notify_bind' => true, + 'notify_login' => false, + ], + ]); + } + + /** + * POST /sms/test — Send a test SMS to verify provider configuration. + */ + public function test_sms_send(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_SMS')) { + return $this->error('短信服务不可用'); + } + + $params = $this->get_json_params($request); + $phone = sanitize_text_field((string) ($params['phone'] ?? '')); + + if (!preg_match('/^1[3-9]\d{9}$/', $phone)) { + return $this->error('手机号格式不正确'); + } + + $result = AKSRT_SMS::get_instance()->test_provider($phone); + + if (!empty($result['success'])) { + return $this->success(['message' => $result['message'] ?? '发送成功']); + } + + return $this->error($result['message'] ?? '发送失败'); + } + + /** + * GET /wechat/status — WeChat notification service status. + */ + public function get_wechat_status(): WP_REST_Response { + if (class_exists('AKSRT_WeChat_Notify')) { + return $this->success(['data' => AKSRT_WeChat_Notify::get_instance()->get_status()]); + } + + return $this->success([ + 'data' => [ + 'appid' => '', + 'configured' => false, + 'has_token' => false, + 'available' => false, + ], + ]); + } + + /** + * POST /wechat/settings — 更新微信通知设置 + */ + public function update_wechat_settings(WP_REST_Request $request): WP_REST_Response { + $params = $this->get_json_params($request); + + $settings = [ + 'enabled' => !empty($params['enabled']), + 'app_id' => sanitize_text_field((string) ($params['app_id'] ?? '')), + 'app_secret' => sanitize_text_field((string) ($params['app_secret'] ?? '')), + 'template_id' => sanitize_text_field((string) ($params['template_id'] ?? '')), + ]; + + $existing = AKSRT_Settings::get_wechat_settings(); + if ($settings['app_secret'] === '***') { + $settings['app_secret'] = $existing['app_secret'] ?? ''; + } + + $merged = array_merge($existing, $settings); + AKSRT_Settings::update_wechat_settings($merged); + delete_transient('aksrt_wechat_token'); + + return $this->success(['message' => __('微信设置已保存', 'aksrt')]); + } +} diff --git a/inc/classes/admin/class-aksrt-admin-user-controller.php b/inc/classes/admin/class-aksrt-admin-user-controller.php new file mode 100644 index 00000000..2ed5b0fd --- /dev/null +++ b/inc/classes/admin/class-aksrt-admin-user-controller.php @@ -0,0 +1,708 @@ + ['GET', 'POST'], + 'callback' => [$this, 'rest_handle_auth_settings'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // OAuth settings + register_rest_route(self::API_NAMESPACE, '/settings/oauth', [ + 'methods' => ['GET', 'POST'], + 'callback' => [$this, 'rest_handle_oauth'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // OAuth proxy settings + register_rest_route(self::API_NAMESPACE, '/settings/oauth-proxy', [ + 'methods' => ['GET', 'POST'], + 'callback' => [$this, 'rest_handle_oauth_proxy'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // User background upload (logged-in users) + register_rest_route(self::API_NAMESPACE, '/user/background', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_upload_user_background'], + 'permission_callback' => function () { return is_user_logged_in(); }, + ]); + + // Comment inline image upload (logged-in users) + register_rest_route(self::API_NAMESPACE, '/comment/upload-image', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_upload_comment_image'], + 'permission_callback' => function () { return is_user_logged_in(); }, + ]); + + // Notification settings + register_rest_route(self::API_NAMESPACE, '/settings/notifications', [ + 'methods' => ['GET', 'POST'], + 'callback' => [$this, 'rest_handle_notification_settings'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Keep message-center aliases on this controller so existing admin pages + // remain compatible while notifications runtime lives in Message controller. + register_rest_route(self::API_NAMESPACE, '/notifications/overview', [ + 'methods' => 'GET', + 'callback' => [$this, 'rest_get_notification_overview'], + 'permission_callback' => [$this, 'check_permission'], + ]); + register_rest_route(self::API_NAMESPACE, '/notifications/mark-read', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_mark_notification_read'], + 'permission_callback' => [$this, 'check_permission'], + ]); + register_rest_route(self::API_NAMESPACE, '/notifications/(?P\d+)', [ + 'methods' => 'DELETE', + 'callback' => [$this, 'rest_delete_notification_item'], + 'permission_callback' => [$this, 'check_permission'], + ]); + register_rest_route(self::API_NAMESPACE, '/notifications/cleanup', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_run_notification_cleanup'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Mail templates (email content templates) + register_rest_route(self::API_NAMESPACE, '/settings/mail-templates', [ + 'methods' => ['GET', 'POST'], + 'callback' => [$this, 'handle_mail_templates'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Credits settings (admin) + register_rest_route(self::API_NAMESPACE, '/credits/settings', [ + 'methods' => ['GET', 'POST'], + 'callback' => [$this, 'rest_handle_credit_settings'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Credits — current user balance (frontend) + register_rest_route(self::API_NAMESPACE, '/credits/me', [ + 'methods' => 'GET', + 'callback' => [$this, 'rest_get_credits_me'], + 'permission_callback' => function () { return is_user_logged_in(); }, + ]); + + // Credits — user list (admin) + register_rest_route(self::API_NAMESPACE, '/credits/users', [ + 'methods' => 'GET', + 'callback' => [$this, 'rest_list_credit_users'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Credits — adjust user balance (admin) + register_rest_route(self::API_NAMESPACE, '/credits/users/(?P\d+)/adjust', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_adjust_user_credits'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Credits — transaction log (admin) + register_rest_route(self::API_NAMESPACE, '/credits/log', [ + 'methods' => 'GET', + 'callback' => [$this, 'rest_get_credits_log'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Report ticket list (admin) + register_rest_route(self::API_NAMESPACE, '/report/list', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_report_list'], + 'permission_callback' => [$this, 'check_permission'], + ]); + register_rest_route(self::API_NAMESPACE, '/users/reports', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_report_list'], + 'permission_callback' => [$this, 'check_permission'], + ]); + register_rest_route(self::API_NAMESPACE, '/users/reports/(?P\d+)', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_report_detail'], + 'permission_callback' => [$this, 'check_permission'], + ]); + register_rest_route(self::API_NAMESPACE, '/users/reports/(?P\d+)/process', [ + 'methods' => 'POST', + 'callback' => [$this, 'process_report'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Report status update (admin) + register_rest_route(self::API_NAMESPACE, '/report/update-status', [ + 'methods' => 'POST', + 'callback' => [$this, 'update_report_status'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Report counts (admin) + register_rest_route(self::API_NAMESPACE, '/report/counts', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_report_counts'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Report submit (logged-in users, front-end) + register_rest_route(self::API_NAMESPACE, '/report/submit', [ + 'methods' => 'POST', + 'callback' => [$this, 'submit_report'], + 'permission_callback' => function () { return is_user_logged_in(); }, + ]); + + // Follow toggle (logged-in users) + register_rest_route(self::API_NAMESPACE, '/follow/toggle', [ + 'methods' => 'POST', + 'callback' => [$this, 'follow_toggle'], + 'permission_callback' => function () { return is_user_logged_in(); }, + ]); + + // Follow status check (logged-in users) + register_rest_route(self::API_NAMESPACE, '/follow/status', [ + 'methods' => 'GET', + 'callback' => [$this, 'follow_status'], + 'permission_callback' => function () { return is_user_logged_in(); }, + ]); + + // Follow counts (public) + register_rest_route(self::API_NAMESPACE, '/follow/counts', [ + 'methods' => 'GET', + 'callback' => [$this, 'follow_counts'], + 'permission_callback' => '__return_true', + ]); + + // Invite codes management (admin) + register_rest_route(self::API_NAMESPACE, '/invite/codes', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_invite_codes'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/invite/codes', [ + 'methods' => 'POST', + 'callback' => [$this, 'generate_invite_codes'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/invite/codes/(?P\d+)/disable', [ + 'methods' => 'POST', + 'callback' => [$this, 'disable_invite_code'], + 'permission_callback' => [$this, 'check_permission'], + ]); + register_rest_route(self::API_NAMESPACE, '/invite/codes/bulk', [ + 'methods' => 'POST', + 'callback' => [$this, 'bulk_update_invite_codes'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + register_rest_route(self::API_NAMESPACE, '/invite/stats', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_invite_stats'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // ── Modal Auth (弹窗登录/注册) ── + + // Modal login + register_rest_route(self::API_NAMESPACE, '/auth/modal-login', [ + 'methods' => 'POST', + 'callback' => [$this, 'modal_login'], + 'permission_callback' => '__return_true', + ]); + + // Modal register + register_rest_route(self::API_NAMESPACE, '/auth/modal-register', [ + 'methods' => 'POST', + 'callback' => [$this, 'modal_register'], + 'permission_callback' => '__return_true', + ]); + + // Current user info (for AJAX refresh) + register_rest_route(self::API_NAMESPACE, '/auth/me', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_current_user_info'], + 'permission_callback' => function () { return is_user_logged_in(); }, + ]); + + // Verify: list pending verification requests + register_rest_route(self::API_NAMESPACE, '/verify/pending', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_verify_pending'], + 'permission_callback' => [$this, 'check_permission'], + ]); + register_rest_route(self::API_NAMESPACE, '/verify/requests', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_verify_requests'], + 'permission_callback' => [$this, 'check_permission'], + ]); + register_rest_route(self::API_NAMESPACE, '/verify/requests/(?P\d+)', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_verify_request_detail'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Verify: approve or reject a verification request + register_rest_route(self::API_NAMESPACE, '/verify/review', [ + 'methods' => 'POST', + 'callback' => [$this, 'review_verify_request'], + 'permission_callback' => [$this, 'check_permission'], + ]); + register_rest_route(self::API_NAMESPACE, '/verify/requests/(?P\d+)/review', [ + 'methods' => 'POST', + 'callback' => [$this, 'review_verify_request_by_id'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // SMS provider status + register_rest_route(self::API_NAMESPACE, '/sms/status', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_sms_status'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // SMS test send (admin only) + register_rest_route(self::API_NAMESPACE, '/sms/test', [ + 'methods' => 'POST', + 'callback' => [$this, 'test_sms_send'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // WeChat notification status + register_rest_route(self::API_NAMESPACE, '/wechat/status', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_wechat_status'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // WeChat settings update + register_rest_route(self::API_NAMESPACE, '/wechat/settings', [ + 'methods' => 'POST', + 'callback' => [$this, 'update_wechat_settings'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // SMS auth: send verification code (public, rate-limited) + register_rest_route(self::API_NAMESPACE, '/auth/send-sms-code', [ + 'methods' => 'POST', + 'callback' => [$this, 'auth_send_sms_code'], + 'permission_callback' => '__return_true', + ]); + + // SMS auth: register by phone (public) + register_rest_route(self::API_NAMESPACE, '/auth/phone-register', [ + 'methods' => 'POST', + 'callback' => [$this, 'auth_phone_register'], + 'permission_callback' => '__return_true', + ]); + + // SMS auth: login by phone + password (public) + register_rest_route(self::API_NAMESPACE, '/auth/phone-login', [ + 'methods' => 'POST', + 'callback' => [$this, 'auth_phone_login'], + 'permission_callback' => '__return_true', + ]); + + // SMS auth: login by phone + SMS code (public) + register_rest_route(self::API_NAMESPACE, '/auth/phone-code-login', [ + 'methods' => 'POST', + 'callback' => [$this, 'auth_phone_code_login'], + 'permission_callback' => '__return_true', + ]); + + // SMS auth: bind phone to current user (logged-in) + register_rest_route(self::API_NAMESPACE, '/auth/bind-phone', [ + 'methods' => 'POST', + 'callback' => [$this, 'auth_bind_phone'], + 'permission_callback' => function () { return is_user_logged_in(); }, + ]); + + // ── User Restrictions / Ban ── + + // List restrictions for a user (admin) + register_rest_route(self::API_NAMESPACE, '/users/(?P\d+)/restrictions', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_user_restrictions'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Add restriction (admin) + register_rest_route(self::API_NAMESPACE, '/users/(?P\d+)/restrict', [ + 'methods' => 'POST', + 'callback' => [$this, 'add_user_restriction'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Remove restriction (admin) + register_rest_route(self::API_NAMESPACE, '/users/restrictions/(?P\d+)', [ + 'methods' => 'DELETE', + 'callback' => [$this, 'remove_user_restriction'], + 'permission_callback' => [$this, 'check_permission'], + ]); + + // Restriction stats (admin) + register_rest_route(self::API_NAMESPACE, '/users/restrictions/stats', [ + 'methods' => 'GET', + 'callback' => [$this, 'get_restriction_stats'], + 'permission_callback' => [$this, 'check_permission'], + ]); + } + + + /** + * GET/POST /settings/mail-templates — 邮件内容模板 + */ + public function handle_mail_templates(WP_REST_Request $request): WP_REST_Response { + $option_key = 'aksrt_mail_templates'; + $fields = [ + 'welcome_subject', 'welcome_body', + 'comment_notify_subject', 'comment_notify_body', + 'comment_reply_subject', 'comment_reply_body', + 'password_reset_subject', 'password_reset_body', + 'email_verify_subject', 'email_verify_body', + ]; + + if ($request->get_method() === 'GET') { + $stored = get_option($option_key, []); + if (!is_array($stored)) $stored = []; + $result = []; + foreach ($fields as $f) { + $result[$f] = sanitize_text_field((string) ($stored[$f] ?? '')); + } + return $this->success(['data' => $result]); + } + + // POST + $params = $this->get_json_params($request); + $clean = []; + foreach ($fields as $f) { + $clean[$f] = wp_kses_post((string) ($params[$f] ?? '')); + } + update_option($option_key, $clean); + + return $this->success(['message' => __('邮件模板已保存', 'aksrt')]); + } + + public function rest_handle_credit_settings(WP_REST_Request $request): WP_REST_Response { + if ($request->get_method() === 'GET') { + $config = AKSRT_Settings_Credits_Runtime::get_credit_settings(); + return $this->success(['data' => $config]); + } + + $params = $this->get_json_params($request); + $current = get_option('aksrt_credit_settings', []); + if (!is_array($current)) { + $current = []; + } + $merged = array_replace_recursive($current, $params); + update_option('aksrt_credit_settings', $merged); + + return $this->success(['message' => __('积分配置已保存', 'aksrt')]); + } + + public function rest_handle_auth_settings(WP_REST_Request $request): WP_REST_Response { + $provider = AKSRT_Admin_Schema_Provider::get_instance(); + $allowed = $provider->get_allowed_fields('users', 'auth'); + + if ($request->get_method() === 'GET') { + $data = AKSRT_Settings::get_all(); + $invite = class_exists('AKSRT_Settings_Credits_Runtime') + ? AKSRT_Settings_Credits_Runtime::get_invite_registration_config() + : []; + + return $this->success(['data' => [ + 'registration_email_verification_enabled' => AKSRT_Settings::registration_requires_email_verification(), + 'registration_email_verification_method' => AKSRT_Settings::registration_verification_method(), + 'invite_registration_enabled' => $invite['enabled'] ?? true, + 'invite_code_length' => class_exists('AKSRT_Settings') ? AKSRT_Settings::invite_code_length() : 6, + 'invite_register_field' => $invite['register_field'] ?? 'optional', + 'invite_reject_invalid' => $invite['reject_invalid'] ?? false, + ] + array_intersect_key($data, $allowed)]); + } + + $params = $this->get_json_params($request); + $settings = AKSRT_Settings::get_all(); + $sanitized = $this->sanitize_fields($params, $allowed); + $settings = array_merge($settings, $sanitized); + + if (array_key_exists('invite_registration_enabled', $params)) { + $settings['invite_registration_enabled'] = !empty($params['invite_registration_enabled']); + } + if (array_key_exists('invite_code_length', $params)) { + $settings['invite_code_length'] = max(4, min(12, (int) $params['invite_code_length'])); + } + if (array_key_exists('invite_register_field', $params)) { + $field = (string) $params['invite_register_field']; + $settings['invit_code_mode'] = $field === 'required' ? 'must' : ($field === 'hidden' ? 'close' : 'open'); + } + if (array_key_exists('invite_reject_invalid', $params)) { + $reward = is_array($settings['invite_reward'] ?? null) ? $settings['invite_reward'] : []; + $reward['reject_invalid'] = !empty($params['invite_reject_invalid']); + $settings['invite_reward'] = $reward; + } + if (array_key_exists('registration_email_verification_enabled', $params)) { + $settings['registration_email_verification_enabled'] = !empty($params['registration_email_verification_enabled']); + } + if (array_key_exists('registration_email_verification_method', $params)) { + $method = (string) $params['registration_email_verification_method']; + $settings['registration_email_verification_method'] = in_array($method, ['link', 'code'], true) ? $method : 'link'; + } + + update_option('aksrt_settings', $settings); + AKSRT_Settings::flush_settings_cache(); + + return $this->success(['message' => __('注册登录配置已保存', 'aksrt')]); + } + + public function rest_handle_notification_settings(WP_REST_Request $request): WP_REST_Response { + $provider = AKSRT_Admin_Schema_Provider::get_instance(); + $allowed = $provider->get_allowed_fields('users', 'notifications'); + + if ($request->get_method() === 'GET') { + $data = AKSRT_Settings::get_all(); + return $this->success(['data' => array_intersect_key($data, $allowed)]); + } + + $params = $this->get_json_params($request); + $settings = AKSRT_Settings::get_all(); + $settings = array_merge($settings, $this->sanitize_fields($params, $allowed)); + update_option('aksrt_settings', $settings); + AKSRT_Settings::flush_settings_cache(); + + return $this->success(['message' => __('消息通知配置已保存', 'aksrt')]); + } + + public function rest_get_notification_overview(WP_REST_Request $request): WP_REST_Response { + $page = max(1, (int) $request->get_param('page')); + $per_page = min(100, max(1, (int) $request->get_param('per_page'))); + $type = sanitize_key((string) $request->get_param('type')); + $is_read = $request->get_param('is_read'); + + $items = []; + $unread = 0; + if (class_exists('AKSRT_Message')) { + $args = ['limit' => $per_page, 'offset' => ($page - 1) * $per_page]; + if ($type !== '') { + $args['type'] = $type; + } + $items = AKSRT_Message::get_instance()->get_notifications(get_current_user_id(), $args); + if ($is_read !== null && $is_read !== '') { + $want_read = (string) $is_read === '1'; + $items = array_values(array_filter($items, static function ($item) use ($want_read) { + return !empty($item['is_read']) === $want_read; + })); + } + $unread = AKSRT_Message::get_instance()->get_unread_count(get_current_user_id()); + } + + $total = count($items); + $types = []; + $today = 0; + $system = 0; + foreach ($items as $item) { + $itemType = sanitize_key((string) ($item['type'] ?? 'system')); + $types[$itemType] = ($types[$itemType] ?? 0) + 1; + if ($itemType === 'system' || $itemType === 'notice') { + $system++; + } + $created = (string) ($item['created_at'] ?? ''); + if ($created !== '' && str_starts_with($created, gmdate('Y-m-d'))) { + $today++; + } + } + + return $this->success(['data' => [ + 'recent' => $items, + 'types' => array_map(static fn($key, $count) => ['type' => $key, 'count' => $count], array_keys($types), array_values($types)), + 'summary' => [ + 'total' => $total, + 'unread' => $unread, + 'today' => $today, + 'system' => $system, + ], + 'pagination' => [ + 'page' => $page, + 'per_page' => $per_page, + 'total_items' => $total, + 'total_pages' => 1, + ], + ]]); + } + + public function rest_mark_notification_read(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Message')) { + return $this->success(['message' => __('消息系统未启用', 'aksrt')]); + } + + $params = $this->get_json_params($request); + $msg = AKSRT_Message::get_instance(); + if (!empty($params['mark_all'])) { + $msg->mark_all_read(get_current_user_id()); + } else { + $id = sanitize_text_field((string) ($params['id'] ?? '')); + if ($id !== '') { + $msg->mark_read(get_current_user_id(), $id); + } + } + + return $this->success(['message' => __('已更新消息状态', 'aksrt')]); + } + + public function rest_delete_notification_item(WP_REST_Request $request): WP_REST_Response { + return $this->success(['message' => __('当前版本暂未提供后台删除单条消息能力', 'aksrt')]); + } + + public function rest_run_notification_cleanup(WP_REST_Request $request): WP_REST_Response { + return $this->success(['message' => __('消息清理任务已受理', 'aksrt')]); + } + + public function rest_handle_oauth(WP_REST_Request $request): WP_REST_Response { + $provider = AKSRT_Admin_Schema_Provider::get_instance(); + $allowed = $provider->get_allowed_fields('users', 'oauth'); + + if ($request->get_method() === 'GET') { + $data = AKSRT_Settings::get_all(); + return $this->success(['data' => array_intersect_key($data, $allowed)]); + } + + $params = $this->get_json_params($request); + $settings = AKSRT_Settings::get_all(); + $settings = array_merge($settings, $this->sanitize_fields($params, $allowed)); + update_option('aksrt_settings', $settings); + AKSRT_Settings::flush_settings_cache(); + + return $this->success(['message' => __('第三方登录配置已保存', 'aksrt')]); + } + + public function rest_handle_oauth_proxy(WP_REST_Request $request): WP_REST_Response { + $keys = [ + 'oauth_proxy_mode', + 'oauth_proxy_secret', + 'oauth_proxy_server_url', + 'oauth_proxy_server_providers', + 'oauth_proxy_client_providers', + 'oauth_proxy_gzh_type', + ]; + + if ($request->get_method() === 'GET') { + $all = AKSRT_Settings::get_all(); + $data = []; + foreach ($keys as $key) { + $data[$key] = $all[$key] ?? null; + } + return $this->success(['data' => $data]); + } + + $params = $this->get_json_params($request); + $settings = AKSRT_Settings::get_all(); + foreach ($keys as $key) { + if (!array_key_exists($key, $params)) { + continue; + } + $settings[$key] = is_array($params[$key]) + ? array_values(array_map('sanitize_key', (array) $params[$key])) + : sanitize_text_field((string) $params[$key]); + } + update_option('aksrt_settings', $settings); + AKSRT_Settings::flush_settings_cache(); + + return $this->success(['message' => __('OAuth 代理配置已保存', 'aksrt')]); + } + + public function rest_upload_user_background(WP_REST_Request $request): WP_REST_Response { + return $this->error(__('当前版本暂未提供后台 REST 背景图上传,请使用标准媒体上传流程。', 'aksrt'), 501); + } + + public function rest_upload_comment_image(WP_REST_Request $request): WP_REST_Response { + return $this->error(__('当前版本暂未提供后台 REST 评论图片上传,请使用标准媒体上传流程。', 'aksrt'), 501); + } + + public function rest_get_credits_me(WP_REST_Request $request): WP_REST_Response { + $user_id = get_current_user_id(); + return $this->success(['data' => [ + 'user_id' => $user_id, + 'balance' => class_exists('AKSRT_Credits') ? AKSRT_Credits::balance($user_id) : 0, + ]]); + } + + public function rest_list_credit_users(WP_REST_Request $request): WP_REST_Response { + $args = [ + 'search' => (string) $request->get_param('search'), + 'page' => (int) $request->get_param('page'), + 'per_page' => (int) $request->get_param('per_page'), + ]; + $data = class_exists('AKSRT_Credits') ? AKSRT_Credits::list_users($args) : ['items' => [], 'total' => 0, 'page' => 1, 'per_page' => 20]; + return $this->success(['data' => $data]); + } + + public function rest_adjust_user_credits(WP_REST_Request $request): WP_REST_Response { + if (!class_exists('AKSRT_Credits')) { + return $this->error(__('积分系统不可用', 'aksrt'), 500); + } + + $user_id = (int) $request->get_param('id'); + $params = $this->get_json_params($request); + $delta = (int) ($params['delta'] ?? 0); + $reason = sanitize_text_field((string) ($params['reason'] ?? 'admin')); + + if ($user_id <= 0 || $delta === 0) { + return $this->error(__('用户或积分变动值无效', 'aksrt'), 422); + } + + if ($delta > 0) { + $result = AKSRT_Credits::add($user_id, $delta, $reason, ['type' => 'user', 'id' => $user_id], 'Admin adjustment'); + } else { + $result = AKSRT_Credits::deduct($user_id, abs($delta), $reason, ['type' => 'user', 'id' => $user_id], 'Admin adjustment'); + } + + if (is_wp_error($result)) { + return $this->error($result->get_error_message(), 422); + } + + return $this->success(['data' => [ + 'user_id' => $user_id, + 'balance' => (int) $result, + ], 'message' => __('积分调整已保存', 'aksrt')]); + } + + public function rest_get_credits_log(WP_REST_Request $request): WP_REST_Response { + $args = [ + 'user_id' => (int) $request->get_param('user_id'), + 'reason' => (string) $request->get_param('reason'), + 'page' => (int) $request->get_param('page'), + 'per_page' => (int) $request->get_param('per_page'), + 'date_from' => (string) $request->get_param('date_from'), + 'date_to' => (string) $request->get_param('date_to'), + ]; + $data = class_exists('AKSRT_Credits') ? AKSRT_Credits::get_log($args) : ['items' => [], 'total' => 0, 'page' => 1, 'per_page' => 20]; + return $this->success(['data' => $data]); + } +} diff --git a/inc/classes/admin/forum-ctrl-traits/trait-aksrt-admin-forum-controller-part-1.php b/inc/classes/admin/forum-ctrl-traits/trait-aksrt-admin-forum-controller-part-1.php new file mode 100644 index 00000000..374b0a4f --- /dev/null +++ b/inc/classes/admin/forum-ctrl-traits/trait-aksrt-admin-forum-controller-part-1.php @@ -0,0 +1,384 @@ +get_forum_service(); + if (!$forum) { + return $this->error(__('论坛系统未启用', 'aksrt')); + } + + $plate_id = (int) $request->get_param('id'); + $params = $this->get_json_params($request); + + $result = $forum->update_plate($plate_id, $params); + if (!$result) { + return $this->error(__('更新版块失败', 'aksrt')); + } + + return $this->success(['message' => __('版块已更新', 'aksrt')]); + } + + public function delete_plate(WP_REST_Request $request): WP_REST_Response { + $forum = $this->get_forum_service(); + if (!$forum) { + return $this->error(__('论坛系统未启用', 'aksrt')); + } + + $plate_id = (int) $request->get_param('id'); + $params = $this->get_json_params($request); + $cascade = sanitize_key((string) ($params['cascade'] ?? 'trash')); + $move_to = (int) ($params['move_to'] ?? 0); + + $result = $forum->delete_plate($plate_id, $cascade, $move_to); + if (!$result) { + return $this->error(__('删除版块失败', 'aksrt')); + } + + return $this->success(['message' => __('版块已删除', 'aksrt')]); + } + + public function reorder_plates(WP_REST_Request $request): WP_REST_Response { + $forum = $this->get_forum_service(); + if (!$forum) { + return $this->error(__('论坛系统未启用', 'aksrt')); + } + + $params = $this->get_json_params($request); + $order = $params['order'] ?? []; + if (!is_array($order) || empty($order)) { + return $this->error(__('排序参数无效', 'aksrt')); + } + + $forum->reorder_plates($order); + + return $this->success(['message' => __('排序已更新', 'aksrt')]); + } + + public function toggle_follow_plate(WP_REST_Request $request): WP_REST_Response { + $forum = $this->get_forum_service(); + if (!$forum) { + return $this->error(__('论坛系统未启用', 'aksrt')); + } + + $plate_id = (int) $request->get_param('id'); + $user_id = get_current_user_id(); + + $result = $forum->toggle_follow_plate($user_id, $plate_id); + return $this->success($result); + } + + public function get_plate_categories(): WP_REST_Response { + $forum = $this->get_forum_service(); + if (!$forum) { + return $this->success(['data' => []]); + } + + return $this->success(['data' => $forum->get_plate_categories()]); + } + + public function get_sorted_plates(WP_REST_Request $request): WP_REST_Response { + $forum = $this->get_forum_service(); + if (!$forum) { + return $this->success(['data' => []]); + } + + $orderby = sanitize_key((string) ($request->get_param('orderby') ?: 'menu_order')); + $order = sanitize_key((string) ($request->get_param('order') ?: 'ASC')); + $limit = max(1, min(100, (int) ($request->get_param('limit') ?: 50))); + + return $this->success(['data' => $forum->get_sorted_plates($orderby, $order, $limit)]); + } + + public function get_topics(WP_REST_Request $request): WP_REST_Response { + $forum = $this->get_forum_service(); + if (!$forum) { + return $this->success(['data' => ['items' => [], 'total' => 0, 'page' => 1, 'total_pages' => 1]]); + } + + $args = [ + 'page' => max(1, (int) ($request->get_param('page') ?: 1)), + 'per_page' => max(1, min(100, (int) ($request->get_param('per_page') ?: 20))), + 'status' => sanitize_key((string) ($request->get_param('status') ?: 'all')), + 'board_id' => (int) ($request->get_param('board_id') ?: 0), + 'visibility' => sanitize_key((string) ($request->get_param('visibility') ?: '')), + 'review_status' => sanitize_key((string) ($request->get_param('review_status') ?: '')), + 'keyword' => sanitize_text_field((string) ($request->get_param('keyword') ?: '')), + 'orderby' => sanitize_key((string) ($request->get_param('orderby') ?: 'date')), + 'viewer_user_id' => get_current_user_id(), + 'include_hidden' => true, + ]; + + $view = $forum->get_topic_list($args); + + return $this->success([ + 'data' => [ + 'items' => $view['items'], + 'total' => $view['total'], + 'page' => $view['page'], + 'total_pages' => $view['max_pages'], + 'options' => [ + 'boards' => $forum->get_boards(), + 'visibility' => $forum->get_visibility_options(), + 'review' => $forum->get_review_options(), + 'status' => [ + ['label' => __('全部', 'aksrt'), 'value' => 'all'], + ['label' => __('已发布', 'aksrt'), 'value' => 'publish'], + ['label' => __('待审核', 'aksrt'), 'value' => 'pending'], + ['label' => __('草稿/拒绝', 'aksrt'), 'value' => 'draft'], + ], + ], + ], + ]); + } + + public function get_topic_detail(WP_REST_Request $request): WP_REST_Response { + $forum = $this->get_forum_service(); + if (!$forum) { + return $this->error(__('论坛系统未启用', 'aksrt')); + } + + $topic_id = (int) $request->get_param('id'); + if ($topic_id <= 0) { + return $this->error(__('参数无效', 'aksrt')); + } + + return $this->success([ + 'data' => $forum->get_topic_detail($topic_id, get_current_user_id()), + ]); + } + + public function moderate_topic(WP_REST_Request $request): WP_REST_Response { + $forum = $this->get_forum_service(); + if (!$forum) { + return $this->error(__('论坛系统未启用', 'aksrt')); + } + + $topic_id = (int) $request->get_param('id'); + $params = $this->get_json_params($request); + $action = sanitize_key((string) ($params['action'] ?? '')); + + if ($topic_id <= 0 || $action === '') { + return $this->error(__('参数无效', 'aksrt')); + } + + $result = $forum->moderate_topic($topic_id, $action, $params, get_current_user_id()); + if (is_wp_error($result)) { + return $this->error($result->get_error_message(), $result->get_error_code() === 'forbidden' ? 403 : 400); + } + + return $this->success([ + 'message' => $result['message'] ?? __('治理动作已执行', 'aksrt'), + 'data' => $result, + ]); + } + + public function vote_topic(WP_REST_Request $request): WP_REST_Response { + $forum = $this->get_forum_service(); + if (!$forum) { + return $this->error(__('论坛系统未启用', 'aksrt')); + } + + $topic_id = (int) $request->get_param('id'); + $params = $this->get_json_params($request); + $option_ids = $params['option_ids'] ?? []; + if (!is_array($option_ids)) { + $option_ids = []; + } + + $result = $forum->submit_vote($topic_id, get_current_user_id(), $option_ids); + if (is_wp_error($result)) { + $status = $result->get_error_code() === 'not_logged_in' ? 401 : 400; + return $this->error($result->get_error_message(), $status); + } + + return $this->success([ + 'message' => $result['message'] ?? __('投票成功', 'aksrt'), + 'data' => $result, + ]); + } + + /** + * Legacy admin moderation endpoint kept for compatibility. + */ + public function update_topic_legacy(WP_REST_Request $request): WP_REST_Response { + $params = $this->get_json_params($request); + $topic_id = (int) ($params['topic_id'] ?? 0); + if ($topic_id <= 0) { + return $this->error(__('参数无效', 'aksrt')); + } + + $forum = $this->get_forum_service(); + if (!$forum) { + return $this->error(__('论坛系统未启用', 'aksrt')); + } + + $actions = []; + if (isset($params['sticky'])) { + $actions[] = ['action' => 'sticky', 'value' => !empty($params['sticky'])]; + } + if (isset($params['featured'])) { + $actions[] = ['action' => 'featured', 'value' => !empty($params['featured'])]; + } + if (isset($params['locked'])) { + $actions[] = ['action' => 'locked', 'value' => !empty($params['locked'])]; + } + + foreach ($actions as $action) { + $result = $forum->moderate_topic($topic_id, $action['action'], ['value' => $action['value']], get_current_user_id()); + if (is_wp_error($result)) { + return $this->error($result->get_error_message()); + } + } + + return $this->success(['message' => __('治理状态已更新', 'aksrt')]); + } + + public function get_moderators(): WP_REST_Response { + $forum = $this->get_forum_service(); + if (!$forum) { + return $this->success(['data' => ['boards' => []]]); + } + + $board_id = isset($_GET['board_id']) ? (int) $_GET['board_id'] : 0; + $result = []; + foreach ($forum->get_boards() as $board) { + $current_board_id = (int) ($board['id'] ?? 0); + if ($board_id > 0 && $current_board_id !== $board_id) { + continue; + } + + $result[] = [ + 'board_id' => $current_board_id, + 'board_name' => (string) ($board['name'] ?? ''), + 'topic_count' => (int) ($board['post_count'] ?? 0), + 'pending_application_count' => (int) ($board['pending_application_count'] ?? 0), + 'moderators' => $forum->get_board_moderator_profiles_with_roles($current_board_id), + ]; + } + + return $this->success(['data' => ['boards' => $result]]); + } + + public function update_moderators(WP_REST_Request $request): WP_REST_Response { + $forum = $this->get_forum_service(); + if (!$forum) { + return $this->error(__('论坛系统未启用', 'aksrt')); + } + + $params = $this->get_json_params($request); + $board_id = (int) ($params['board_id'] ?? 0); + $user_id = (int) ($params['user_id'] ?? 0); + $action = sanitize_key((string) ($params['action'] ?? '')); + $role = sanitize_key((string) ($params['role'] ?? 'moderator')); + + if ($board_id <= 0 || $user_id <= 0 || !in_array($action, ['add', 'remove'], true)) { + return $this->error(__('参数无效', 'aksrt')); + } + + $valid_roles = ['super_moderator', 'moderator', 'junior_moderator']; + if (!in_array($role, $valid_roles, true)) { + $role = 'moderator'; + } + + $board = get_term($board_id, 'aksrt_forum_board'); + if (!$board || is_wp_error($board)) { + return $this->error(__('版块不存在', 'aksrt'), 404); + } + + $user = get_userdata($user_id); + if (!$user) { + return $this->error(__('用户不存在', 'aksrt'), 404); + } + + if ($action === 'add') { + $forum->add_moderator($user_id, $board_id, $role); + } else { + $forum->remove_moderator($user_id, $board_id); + } + + return $this->success([ + 'message' => $action === 'add' ? __('版主已添加', 'aksrt') : __('版主已移除', 'aksrt'), + ]); + } + + public function change_moderator_role(WP_REST_Request $request): WP_REST_Response { + $forum = $this->get_forum_service(); + if (!$forum) { + return $this->error(__('论坛系统未启用', 'aksrt')); + } + + $params = $this->get_json_params($request); + $board_id = (int) ($params['board_id'] ?? 0); + $user_id = (int) ($params['user_id'] ?? 0); + $role = sanitize_key((string) ($params['role'] ?? '')); + + if ($board_id <= 0 || $user_id <= 0 || !$role) { + return $this->error(__('参数无效', 'aksrt')); + } + + $valid_roles = ['super_moderator', 'moderator', 'junior_moderator']; + if (!in_array($role, $valid_roles, true)) { + return $this->error(__('无效的角色', 'aksrt')); + } + + $result = $forum->set_moderator_role($user_id, $board_id, $role); + if (!$result) { + return $this->error(__('设置角色失败,用户可能不是该版块版主', 'aksrt')); + } + + return $this->success(['message' => __('角色已更新', 'aksrt')]); + } + + public function apply_moderator(WP_REST_Request $request): WP_REST_Response { + $forum = $this->get_forum_service(); + if (!$forum) { + return $this->error(__('论坛系统未启用', 'aksrt')); + } + + $params = $this->get_json_params($request); + $board_id = (int) ($params['board_id'] ?? 0); + $reason = sanitize_textarea_field((string) ($params['reason'] ?? '')); + + $result = $forum->apply_moderator(get_current_user_id(), $board_id, $reason); + if (is_wp_error($result)) { + $status = $result->get_error_code() === 'not_logged_in' ? 401 : 400; + return $this->error($result->get_error_message(), $status); + } + + return $this->success([ + 'message' => $result['message'] ?? __('申请已提交', 'aksrt'), + 'data' => $result, + ]); + } + + public function get_moderator_applications(WP_REST_Request $request): WP_REST_Response { + $forum = $this->get_forum_service(); + if (!$forum) { + return $this->success(['data' => ['items' => [], 'total' => 0]]); + } + + $items = $forum->get_moderator_application_list([ + 'status' => sanitize_key((string) ($request->get_param('status') ?: '')), + 'board_id' => (int) ($request->get_param('board_id') ?: 0), + 'keyword' => sanitize_text_field((string) ($request->get_param('keyword') ?: '')), + ]); + + return $this->success([ + 'data' => [ + 'items' => $items, + 'total' => count($items), + 'options' => [ + 'boards' => $forum->get_boards(), + 'status' => [ + ['label' => __('全部', 'aksrt'), 'value' => ''], + ['label' => __('待处理', 'aksrt'), 'value' => AKSRT_Forum::APPLICATION_PENDING], + ['label' => __('已通过', 'aksrt'), 'value' => AKSRT_Forum::APPLICATION_APPROVED], + ['label' => __('已拒绝', 'aksrt'), 'value' => AKSRT_Forum::APPLICATION_REJECTED], + ], + ], + ], + ]); + } +} diff --git a/inc/classes/admin/forum-ctrl-traits/trait-aksrt-admin-forum-controller-part-2.php b/inc/classes/admin/forum-ctrl-traits/trait-aksrt-admin-forum-controller-part-2.php new file mode 100644 index 00000000..8a50571a --- /dev/null +++ b/inc/classes/admin/forum-ctrl-traits/trait-aksrt-admin-forum-controller-part-2.php @@ -0,0 +1,182 @@ +get_forum_service(); + if (!$forum) { + return $this->error(__('论坛系统未启用', 'aksrt')); + } + + $application_id = (string) $request->get_param('id'); + $params = $this->get_json_params($request); + $status = sanitize_key((string) ($params['status'] ?? '')); + $note = sanitize_textarea_field((string) ($params['note'] ?? '')); + + $result = $forum->review_moderator_application($application_id, $status, $note, get_current_user_id()); + if (is_wp_error($result)) { + return $this->error($result->get_error_message()); + } + + return $this->success([ + 'message' => $result['message'] ?? __('申请状态已更新', 'aksrt'), + 'data' => $result, + ]); + } + + public function toggle_favorite(WP_REST_Request $request): WP_REST_Response { + $forum = $this->get_forum_service(); + if (!$forum) { + return $this->error(__('论坛系统未启用', 'aksrt')); + } + + $topic_id = (int) $request->get_param('id'); + $result = $forum->toggle_favorite(get_current_user_id(), $topic_id); + if (is_wp_error($result)) { + $status = $result->get_error_code() === 'not_logged_in' ? 401 : 400; + return $this->error($result->get_error_message(), $status); + } + + return $this->success($result); + } + + public function score_topic(WP_REST_Request $request): WP_REST_Response { + $forum = $this->get_forum_service(); + if (!$forum) { + return $this->error(__('论坛系统未启用', 'aksrt')); + } + + $topic_id = (int) $request->get_param('id'); + $params = $this->get_json_params($request); + $amount = (int) ($params['amount'] ?? 0); + + $result = $forum->score_topic(get_current_user_id(), $topic_id, $amount); + if (is_wp_error($result)) { + $status = $result->get_error_code() === 'not_logged_in' ? 401 : 400; + return $this->error($result->get_error_message(), $status); + } + + return $this->success($result); + } + + public function adopt_answer(WP_REST_Request $request): WP_REST_Response { + $forum = $this->get_forum_service(); + if (!$forum) { + return $this->error(__('论坛系统未启用', 'aksrt')); + } + + $topic_id = (int) $request->get_param('id'); + $params = $this->get_json_params($request); + $comment_id = (int) ($params['comment_id'] ?? 0); + + $result = $forum->adopt_answer($topic_id, $comment_id, get_current_user_id()); + if (is_wp_error($result)) { + $status = $result->get_error_code() === 'forbidden' ? 403 : 400; + return $this->error($result->get_error_message(), $status); + } + + return $this->success($result); + } + + public function check_logged_in_permission(): bool { + return is_user_logged_in(); + } + + private function get_forum_service(): ?AKSRT_Forum { + return class_exists('AKSRT_Forum') ? AKSRT_Forum::get_instance() : null; + } + + public function frontend_create_plate(WP_REST_Request $request): WP_REST_Response { + $forum = $this->get_forum_service(); + if (!$forum) { + return $this->error(__('论坛系统未启用', 'aksrt')); + } + + $user_id = get_current_user_id(); + if (!$forum->is_forum_admin($user_id)) { + return $this->error(__('仅论坛管理员可创建版块', 'aksrt'), 403); + } + + $params = $this->get_json_params($request); + $data = [ + 'name' => sanitize_text_field((string) ($params['name'] ?? '')), + 'description' => sanitize_textarea_field((string) ($params['description'] ?? '')), + 'icon' => sanitize_text_field((string) ($params['icon'] ?? '')), + 'cover_url' => esc_url_raw((string) ($params['cover_url'] ?? '')), + 'parent_id' => (int) ($params['parent_id'] ?? 0), + 'plate_cat' => (int) ($params['plate_cat'] ?? 0), + ]; + + if ($data['name'] === '') { + return $this->error(__('版块名称不能为空', 'aksrt')); + } + + $result = $forum->create_plate($data); + if (is_wp_error($result)) { + return $this->error($result->get_error_message()); + } + + return $this->success($result); + } + + public function frontend_update_plate(WP_REST_Request $request): WP_REST_Response { + $forum = $this->get_forum_service(); + if (!$forum) { + return $this->error(__('论坛系统未启用', 'aksrt')); + } + + $user_id = get_current_user_id(); + $plate_id = (int) $request->get_param('id'); + + $post = get_post($plate_id); + if (!$post || $post->post_type !== 'aksrt_forum_plate') { + return $this->error(__('版块不存在', 'aksrt')); + } + + $is_author = (int) $post->post_author === $user_id; + if (!$is_author && !$forum->is_forum_admin($user_id)) { + return $this->error(__('无权编辑此版块', 'aksrt'), 403); + } + + $params = $this->get_json_params($request); + $data = [ + 'name' => sanitize_text_field((string) ($params['name'] ?? '')), + 'description' => sanitize_textarea_field((string) ($params['description'] ?? '')), + 'icon' => sanitize_text_field((string) ($params['icon'] ?? '')), + 'cover_url' => esc_url_raw((string) ($params['cover_url'] ?? '')), + ]; + + $result = $forum->update_plate($plate_id, $data); + if (is_wp_error($result)) { + return $this->error($result->get_error_message()); + } + + return $this->success($result); + } + + public function frontend_delete_plate(WP_REST_Request $request): WP_REST_Response { + $forum = $this->get_forum_service(); + if (!$forum) { + return $this->error(__('论坛系统未启用', 'aksrt')); + } + + $user_id = get_current_user_id(); + if (!$forum->is_forum_admin($user_id)) { + return $this->error(__('仅论坛管理员可删除版块', 'aksrt'), 403); + } + + $plate_id = (int) $request->get_param('id'); + $params = $this->get_json_params($request); + $cascade = sanitize_key((string) ($params['cascade'] ?? 'trash')); + $move_to = (int) ($params['move_to'] ?? 0); + + $result = $forum->delete_plate($plate_id, $cascade, $move_to); + if (!$result) { + return $this->error(__('删除版块失败', 'aksrt')); + } + + return $this->success(['message' => __('版块已删除', 'aksrt')]); + } +} diff --git a/inc/classes/admin/global-ctrl-traits/trait-aksrt-admin-global-controller-part-1.php b/inc/classes/admin/global-ctrl-traits/trait-aksrt-admin-global-controller-part-1.php new file mode 100644 index 00000000..b51bdbb6 --- /dev/null +++ b/inc/classes/admin/global-ctrl-traits/trait-aksrt-admin-global-controller-part-1.php @@ -0,0 +1,264 @@ +get_json_params($request); + $ordered_ids = $params['ids'] ?? []; + + if (!is_array($ordered_ids)) { + return new WP_REST_Response(['code' => 'error', 'message' => __('参数错误', 'aksrt')], 400); + } + + if (count($ordered_ids) > 5) { + return new WP_REST_Response(['code' => 'error', 'message' => __('Footer tabbar supports at most 5 items.', 'aksrt')], 400); + } + + $tabbar = get_option('aksrt_footer_tabbar', []); + $items = $tabbar['items'] ?? []; + + $order_map = array_flip($ordered_ids); + usort($items, function ($a, $b) use ($order_map) { + $idx_a = $order_map[($a['id'] ?? '')] ?? 999; + $idx_b = $order_map[($b['id'] ?? '')] ?? 999; + return $idx_a <=> $idx_b; + }); + + foreach ($items as $index => &$item) { + $item['sort_order'] = $index; + } + + $tabbar['items'] = $items; + update_option('aksrt_footer_tabbar', $tabbar); + + return rest_ensure_response(['code' => 'success', 'message' => __('Order saved.', 'aksrt')]); + } + + private function sanitize_tabbar_item(array $item): array { + $valid_types = ['home', 'category', 'link', 'user', 'auth', 'search', 'add']; + $type = sanitize_text_field($item['type'] ?? 'link'); + if (!in_array($type, $valid_types, true)) { + $type = 'link'; + } + $badge_mode = sanitize_text_field($item['badge_mode'] ?? 'static'); + if (!in_array($badge_mode, ['static', 'dynamic'], true)) { + $badge_mode = 'static'; + } + + $add_items = []; + if (!empty($item['add_items']) && is_array($item['add_items'])) { + foreach ($item['add_items'] as $sub_item) { + $label = sanitize_text_field($sub_item['label'] ?? ''); + $icon = sanitize_text_field($sub_item['icon'] ?? 'fas fa-pen'); + $url = esc_url_raw($sub_item['url'] ?? ''); + if ($label === '') { + continue; + } + $add_items[] = [ + 'id' => sanitize_text_field($sub_item['id'] ?? uniqid('add_')), + 'label' => $label, + 'icon' => $icon, + 'url' => $url, + ]; + } + } + + return [ + 'id' => sanitize_text_field($item['id'] ?? uniqid('tab_')), + 'type' => $type, + 'icon' => sanitize_text_field($item['icon'] ?? ''), + 'text' => sanitize_text_field($item['text'] ?? ''), + 'link' => esc_url_raw($item['link'] ?? ''), + 'badge' => sanitize_text_field($item['badge'] ?? ''), + 'badge_mode' => $badge_mode, + 'category_id' => absint($item['category_id'] ?? 0), + 'search_default_type' => (function() use ($item) { + $v = $item['search_default_type'] ?? 'post'; + return in_array($v, ['post', 'category', 'tag'], true) ? $v : 'post'; + })(), + 'new_window' => !empty($item['new_window']), + 'add_items' => $add_items, + 'enabled' => !empty($item['enabled']), + 'sort_order' => (int) ($item['sort_order'] ?? 0), + 'ontop' => !empty($item['ontop']), + ]; + } + + public function rest_handle_floating_buttons(WP_REST_Request $request): WP_REST_Response { + if ($request->get_method() === 'GET') { + $settings = class_exists('AKSRT_Settings') + ? AKSRT_Settings::get_floating_buttons_settings() + : [ + 'enabled' => true, + 'style' => 'round', + 'position' => 'bottom', + 'scroll_hide' => false, + 'scroll_down_hide' => true, + 'filter_blur' => [], + 'items' => [], + ]; + + return rest_ensure_response(['code' => 'success', 'data' => $settings]); + } + + $params = $this->get_json_params($request); + $items = isset($params['items']) ? array_map([$this, 'sanitize_floating_button_item'], (array) $params['items']) : []; + + $filter_blur = []; + if (!empty($params['filter_blur']) && is_array($params['filter_blur'])) { + foreach ($params['filter_blur'] as $device) { + $device = sanitize_text_field((string) $device); + if (in_array($device, ['pc', 'mobile'], true)) { + $filter_blur[] = $device; + } + } + $filter_blur = array_values(array_unique($filter_blur)); + } + + $settings = [ + 'enabled' => !empty($params['enabled']), + 'style' => in_array($params['style'] ?? '', ['round', 'square'], true) ? $params['style'] : 'round', + 'position' => in_array($params['position'] ?? '', ['bottom', 'center'], true) ? $params['position'] : 'bottom', + 'scroll_hide' => !empty($params['scroll_hide']), + 'scroll_down_hide' => !empty($params['scroll_down_hide']), + 'max' => max(1, min(10, (int) ($params['max'] ?? 5))), + 'size' => max(32, min(64, (int) ($params['size'] ?? 44))), + 'spacing' => max(4, min(24, (int) ($params['spacing'] ?? 10))), + 'shadow' => !empty($params['shadow']), + 'animation' => !empty($params['animation']), + 'opacity' => max(30, min(100, (int) ($params['opacity'] ?? 100))), + 'active_color' => sanitize_hex_color($params['active_color'] ?? '') ?: '', + 'bg_color' => sanitize_hex_color($params['bg_color'] ?? '') ?: '', + 'pc_show' => !empty($params['pc_show']), + 'm_show' => !empty($params['m_show']), + 'filter_blur' => $filter_blur, + 'items' => $items, + ]; + + update_option('aksrt_floating_buttons', $settings); + + if (class_exists('AKSRT_Settings')) { + AKSRT_Settings::flush_settings_cache(); + } + + return rest_ensure_response(['code' => 'success', 'message' => __('Floating button settings saved.', 'aksrt')]); + } + + public function rest_reorder_floating_buttons(WP_REST_Request $request): WP_REST_Response { + $params = $this->get_json_params($request); + $ordered_ids = $params['ids'] ?? []; + + if (!is_array($ordered_ids)) { + return new WP_REST_Response(['code' => 'error', 'message' => __('参数错误', 'aksrt')], 400); + } + + $settings = get_option('aksrt_floating_buttons', []); + $items = $settings['items'] ?? []; + + $order_map = array_flip($ordered_ids); + usort($items, function ($a, $b) use ($order_map) { + $idx_a = $order_map[($a['id'] ?? '')] ?? 999; + $idx_b = $order_map[($b['id'] ?? '')] ?? 999; + return $idx_a <=> $idx_b; + }); + + foreach ($items as $index => &$item) { + $item['sort_order'] = $index; + } + unset($item); + + $settings['items'] = $items; + update_option('aksrt_floating_buttons', $settings); + + if (class_exists('AKSRT_Settings')) { + AKSRT_Settings::flush_settings_cache(); + } + + return rest_ensure_response(['code' => 'success', 'message' => __('Order saved.', 'aksrt')]); + } + + private function sanitize_floating_button_item(array $item): array { + $valid_types = ['back_top', 'theme_mode', 'qrcode', 'service_qq', 'service_wechat', 'pay_vip', 'custom', 'add', 'shop_cart']; + $type = sanitize_text_field($item['type'] ?? 'custom'); + if (!in_array($type, $valid_types, true)) { + $type = 'custom'; + } + + $hover_width = (int) ($item['hover_width'] ?? 200); + if ($hover_width < 120) { + $hover_width = 120; + } + if ($hover_width > 480) { + $hover_width = 480; + } + + $qq = preg_replace('/\D+/', '', (string) ($item['qq'] ?? '')); + $action_mode = sanitize_text_field($item['action_mode'] ?? 'link'); + if (!in_array($action_mode, ['link', 'qrcode'], true)) { + $action_mode = 'link'; + } + $qr_size = (int) ($item['qr_size'] ?? 120); + if ($qr_size < 80) { + $qr_size = 80; + } + if ($qr_size > 240) { + $qr_size = 240; + } + + $add_items = []; + if (!empty($item['add_items']) && is_array($item['add_items'])) { + foreach ($item['add_items'] as $add_item) { + if (!is_array($add_item)) { + continue; + } + $label = sanitize_text_field($add_item['label'] ?? ''); + $url = esc_url_raw($add_item['url'] ?? ''); + $icon_item = sanitize_text_field($add_item['icon'] ?? ''); + if ($label === '' && $url === '') { + continue; + } + $add_items[] = [ + 'id' => sanitize_text_field($add_item['id'] ?? uniqid('add_')), + 'label' => $label, + 'url' => $url, + 'icon' => $icon_item, + ]; + } + } + + $icon = sanitize_text_field($item['icon'] ?? ''); + $icon_dark = sanitize_text_field($item['icon_dark'] ?? ''); + $icon_light = sanitize_text_field($item['icon_light'] ?? ''); + if ($type === 'theme_mode') { + $icon = 'fas fa-sun'; + $icon_dark = ''; + $icon_light = ''; + } + + return [ + 'id' => sanitize_text_field($item['id'] ?? uniqid('float_')), + 'type' => $type, + 'icon' => $icon, + 'icon_dark' => $icon_dark, + 'icon_light' => $icon_light, + 'text' => sanitize_text_field($item['text'] ?? ''), + 'desc' => sanitize_text_field($item['desc'] ?? ''), + 'action_mode' => $action_mode, + 'qq' => $qq, + 'qrcode_img' => esc_url_raw($item['qrcode_img'] ?? ''), + 'wechat_img' => esc_url_raw($item['wechat_img'] ?? ''), + 'link' => esc_url_raw($item['link'] ?? ''), + 'add_items' => $add_items, + 'hover' => wp_kses_post($item['hover'] ?? ''), + 'qr_size' => $qr_size, + 'hover_width' => $hover_width, + 'pc_s' => !empty($item['pc_s']), + 'm_s' => !empty($item['m_s']), + 'color' => sanitize_text_field($item['color'] ?? ''), + 'bg' => sanitize_text_field($item['bg'] ?? ''), + 'enabled' => !empty($item['enabled']), + 'sort_order' => (int) ($item['sort_order'] ?? 0), + ]; + } +} diff --git a/inc/classes/admin/global-ctrl-traits/trait-aksrt-admin-global-controller-part-2.php b/inc/classes/admin/global-ctrl-traits/trait-aksrt-admin-global-controller-part-2.php new file mode 100644 index 00000000..38410d24 --- /dev/null +++ b/inc/classes/admin/global-ctrl-traits/trait-aksrt-admin-global-controller-part-2.php @@ -0,0 +1,258 @@ +get_method() === 'GET') { + $data = AKSRT_Settings::get_homepage_fixed_settings(); + $data['homepage_modules'] = AKSRT_Settings::get_homepage_modules(); + return rest_ensure_response([ + 'code' => 'success', + 'data' => $data, + ]); + } + + $params = $this->get_json_params($request); + if (empty($params)) { + $params = $request->get_params(); + } + + $allowed_sorts = AKSRT_Settings::homepage_allowed_sorts(); + + $settings = [ + 'homepage_card_style' => in_array($params['homepage_card_style'] ?? '', ['default', 'style2', 'style3'], true) + ? $params['homepage_card_style'] + : 'default', + 'homepage_multi_thumb_enabled' => !isset($params['homepage_multi_thumb_enabled']) || !empty($params['homepage_multi_thumb_enabled']), + 'homepage_multi_thumb_min_images' => max(2, min(4, (int) ($params['homepage_multi_thumb_min_images'] ?? 3))), + 'homepage_sidebar_mini_enabled' => !isset($params['homepage_sidebar_mini_enabled']) || !empty($params['homepage_sidebar_mini_enabled']), + 'homepage_default_sort' => in_array($params['homepage_default_sort'] ?? '', $allowed_sorts, true) + ? $params['homepage_default_sort'] + : 'modified', + 'homepage_default_view' => in_array($params['homepage_default_view'] ?? '', ['list', 'card'], true) + ? $params['homepage_default_view'] + : 'list', + 'homepage_sort_ajax_enabled' => !isset($params['homepage_sort_ajax_enabled']) || !empty($params['homepage_sort_ajax_enabled']), + 'homepage_sticky_tabs_enabled' => !isset($params['homepage_sticky_tabs_enabled']) || !empty($params['homepage_sticky_tabs_enabled']), + 'homepage_content_model' => in_array($params['homepage_content_model'] ?? '', ['post', 'mixed'], true) + ? $params['homepage_content_model'] + : 'mixed', + 'home_posts_list_enabled' => !isset($params['home_posts_list_enabled']) || !empty($params['home_posts_list_enabled']), + 'home_list_title' => sanitize_text_field((string) ($params['home_list_title'] ?? __('Latest Posts', 'aksrt'))), + 'home_list_sort_enabled' => !isset($params['home_list_sort_enabled']) || !empty($params['home_list_sort_enabled']), + 'home_list_sort_dropdown' => !empty($params['home_list_sort_dropdown']), + 'homepage_tab_posts_per_page' => max(1, min(20, (int) ($params['homepage_tab_posts_per_page'] ?? 6))), + 'homepage_tab_limit' => max(1, min(12, (int) ($params['homepage_tab_limit'] ?? 4))), + ]; + + if (isset($params['home_list_sort_options']) && is_array($params['home_list_sort_options'])) { + $seen = []; + $options = []; + foreach ($params['home_list_sort_options'] as $key) { + $key = sanitize_key((string) $key); + if (in_array($key, $allowed_sorts, true) && !isset($seen[$key])) { + $options[] = $key; + $seen[$key] = true; + } + } + $settings['home_list_sort_options'] = !empty($options) ? $options : ['modified', 'date', 'views', 'likes', 'comments']; + } + + if (isset($params['home_exclude_post_ids'])) { + $settings['home_exclude_post_ids'] = array_values(array_filter( + array_map('absint', (array) $params['home_exclude_post_ids']), + static fn($value) => $value > 0 + )); + } + + if (isset($params['home_exclude_category_ids'])) { + $settings['home_exclude_category_ids'] = array_values(array_filter( + array_map('absint', (array) $params['home_exclude_category_ids']), + static fn($value) => $value > 0 + )); + } + + if (isset($params['homepage_tabs']) && is_array($params['homepage_tabs'])) { + $tabs = []; + foreach ($params['homepage_tabs'] as $tab) { + if (!is_array($tab)) { + continue; + } + $term_id = absint($tab['term_id'] ?? 0); + if ($term_id <= 0) { + continue; + } + $term = get_term($term_id, 'category'); + if (!$term || is_wp_error($term)) { + continue; + } + $orderby = sanitize_key($tab['orderby'] ?? 'date'); + if (!in_array($orderby, $allowed_sorts, true)) { + $orderby = 'date'; + } + $tabs[] = [ + 'term_id' => $term_id, + 'title' => sanitize_text_field($tab['title'] ?? ''), + 'orderby' => $orderby, + 'enabled' => !empty($tab['enabled']), + ]; + } + $settings['homepage_tabs'] = $tabs; + } + + if (isset($params['homepage_modules']) && is_array($params['homepage_modules'])) { + $modules = []; + $allowed_types = [ + 'hero', 'banner', 'category', 'tabs', 'ranking', 'recent', + 'tag_cloud', 'friend_links', 'html', 'hotspot', + 'category_recommend_cards', 'text_title', 'button_group', + 'spacing', 'faq', 'data_grid', 'highlight_cards', + 'partner_marquee', 'product_showcase', + ]; + foreach ($params['homepage_modules'] as $module) { + if (!is_array($module) || !in_array($module['type'] ?? '', $allowed_types, true)) { + continue; + } + $modules[] = [ + 'id' => sanitize_text_field($module['id'] ?? uniqid('mod_')), + 'type' => sanitize_text_field($module['type']), + 'config' => is_array($module['config'] ?? null) ? $module['config'] : [], + 'enabled' => !empty($module['enabled']), + 'title' => sanitize_text_field($module['title'] ?? ''), + ]; + } + $settings['homepage_modules'] = $modules; + } + + $current = get_option($option_key, []); + if (!is_array($current)) { + $current = []; + } + + foreach ($settings as $key => $value) { + $current[$key] = $value; + } + + update_option($option_key, $current); + AKSRT_Settings::flush_settings_cache(); + update_option('aksrt_homepage_schema_version', gmdate('Y-m-d')); + delete_transient('aksrt_homepage_cached_images'); + delete_transient('aksrt_homepage_modules_cache'); + do_action('aksrt_homepage_settings_changed'); + + return rest_ensure_response([ + 'code' => 'success', + 'message' => __('Homepage settings saved.', 'aksrt'), + 'data' => $settings, + ]); + } + + public function rest_handle_category_recommend(WP_REST_Request $request): WP_REST_Response { + if ($request->get_method() === 'GET') { + $settings = AKSRT_Settings::get_category_recommend_settings(); + return rest_ensure_response(['code' => 'success', 'data' => $settings]); + } + + $params = $this->get_json_params($request); + $category_ids = array_map('absint', (array) ($params['category_recommend_category_ids'] ?? [])); + $category_ids = array_filter($category_ids, fn($id) => $id > 0); + $category_ids = array_values(array_unique($category_ids)); + + $settings = [ + 'enable_category_recommend' => !empty($params['enable_category_recommend']), + 'category_recommend_title' => sanitize_text_field($params['category_recommend_title'] ?? ''), + 'category_recommend_category_ids' => $category_ids, + 'category_recommend_posts_per_category' => max(1, min(10, (int) ($params['category_recommend_posts_per_category'] ?? 5))), + 'category_recommend_columns' => max(1, min(4, (int) ($params['category_recommend_columns'] ?? 2))), + 'category_recommend_show_description' => !empty($params['category_recommend_show_description']), + 'category_recommend_show_dates' => !empty($params['category_recommend_show_dates']), + 'category_recommend_show_more_link' => !empty($params['category_recommend_show_more_link']), + ]; + update_option('aksrt_category_recommend', $settings); + AKSRT_Settings::flush_category_recommend_cache(); + + return rest_ensure_response(['code' => 'success', 'message' => __('Category recommendation settings saved.', 'aksrt')]); + } + + public function rest_get_categories(WP_REST_Request $request): WP_REST_Response { + $categories = get_categories([ + 'orderby' => 'count', + 'order' => 'DESC', + 'hide_empty' => false, + ]); + + $data = array_map(function ($cat) { + return [ + 'term_id' => (int) $cat->term_id, + 'name' => $cat->name, + 'slug' => $cat->slug, + 'count' => (int) $cat->count, + 'description' => $cat->description, + 'cover_id' => (int) get_term_meta($cat->term_id, 'aksrt_category_cover_id', true), + 'cover_url' => AKSRT_Settings::get_category_cover_url((int) $cat->term_id), + 'recommend_desc' => (string) get_term_meta($cat->term_id, 'aksrt_category_recommend_desc', true), + ]; + }, $categories); + + return rest_ensure_response(['code' => 'success', 'data' => array_values($data)]); + } + + public function rest_search_users(WP_REST_Request $request): WP_REST_Response { + $search = (string) $request->get_param('search'); + $number = max(1, min(50, (int) $request->get_param('number'))); + + $args = [ + 'number' => $number, + 'orderby' => 'display_name', + 'order' => 'ASC', + 'fields' => ['ID', 'display_name', 'user_login'], + ]; + if ($search !== '') { + $args['search'] = '*' . $search . '*'; + $args['search_columns'] = ['display_name', 'user_login', 'user_email']; + } + + $users = get_users($args); + $data = array_map(function ($user) { + return [ + 'id' => (int) $user->ID, + 'display_name' => $user->display_name, + 'user_login' => $user->user_login, + ]; + }, $users); + + return rest_ensure_response(['code' => 'success', 'data' => array_values($data)]); + } + + public function rest_update_category_meta(WP_REST_Request $request): WP_REST_Response { + $id = (int) $request->get_param('id'); + $term = get_term($id, 'category'); + if (!$term || is_wp_error($term)) { + return new WP_REST_Response(['code' => 'error', 'message' => __('Category does not exist.', 'aksrt')], 404); + } + + $params = $this->get_json_params($request); + $cover_id = absint($params['cover_id'] ?? 0); + $recommend_desc = sanitize_textarea_field($params['recommend_desc'] ?? ''); + + if ($cover_id > 0) { + update_term_meta($id, 'aksrt_category_cover_id', $cover_id); + } else { + delete_term_meta($id, 'aksrt_category_cover_id'); + } + + if ($recommend_desc !== '') { + update_term_meta($id, 'aksrt_category_recommend_desc', $recommend_desc); + } else { + delete_term_meta($id, 'aksrt_category_recommend_desc'); + } + + AKSRT_Settings::flush_category_recommend_cache(); + + return rest_ensure_response(['code' => 'success', 'message' => __('分类元数据已保存', 'aksrt')]); + } +} diff --git a/inc/classes/admin/import-export-traits/trait-aksrt-admin-import-export-controller-part-1.php b/inc/classes/admin/import-export-traits/trait-aksrt-admin-import-export-controller-part-1.php new file mode 100644 index 00000000..da3be079 --- /dev/null +++ b/inc/classes/admin/import-export-traits/trait-aksrt-admin-import-export-controller-part-1.php @@ -0,0 +1,406 @@ +get_log() : get_option('aksrt_settings_migration_log', []); + + return $this->success([ + 'schema_version' => $schema_version, + 'migration_version' => $migration ? $migration->get_version() : get_option('aksrt_settings_migration_version', '0.0.0'), + 'migration_log' => is_array($migration_log) ? $migration_log : [], + ]); + } + + /** + * Get the schema definition for a domain (for frontend introspection). + */ + public function rest_get_schema(WP_REST_Request $request): WP_REST_Response { + $domain = $request->get_param('domain'); + $schema_provider = AKSRT_Admin_Schema_Provider::get_instance(); + + $schema = $schema_provider->get_schema($domain); + if (!$schema) { + return $this->error('未知的设置域: ' . esc_html($domain), 404); + } + + return $this->success(['schema' => $schema]); + } + + public function rest_get_schema_section(WP_REST_Request $request): WP_REST_Response { + return $this->get_section_settings($request, (string) $request->get_param('domain')); + } + + public function rest_save_schema_section(WP_REST_Request $request): WP_REST_Response { + return $this->save_section_settings($request, (string) $request->get_param('domain')); + } + + public function list_setting_backups(WP_REST_Request $request): WP_REST_Response { + $backups = get_option('aksrt_settings_backups', []); + if (!is_array($backups)) { + $backups = []; + } + + usort($backups, static function ($a, $b) { + return strcmp((string) ($b['created_at'] ?? ''), (string) ($a['created_at'] ?? '')); + }); + + return $this->success([ + 'data' => [ + 'items' => array_values($backups), + 'total' => count($backups), + ], + ]); + } + + public function create_setting_backup(WP_REST_Request $request): WP_REST_Response { + $params = $this->get_json_params($request); + $type = sanitize_key((string) ($params['type'] ?? 'manual')); + $label = sanitize_text_field((string) ($params['label'] ?? '')); + + $allowed_types = ['manual', 'scheduled', 'reset_all', 'reset_domain', 'update_theme']; + if (!in_array($type, $allowed_types, true)) { + $type = 'manual'; + } + + $backup = $this->build_settings_backup($type, $label); + $this->persist_setting_backup($backup); + + return $this->success([ + 'message' => __('设置快照已创建', 'aksrt'), + 'data' => $backup, + ]); + } + + public function restore_setting_backup(WP_REST_Request $request): WP_REST_Response { + $id = (int) $request->get_param('id'); + $backups = get_option('aksrt_settings_backups', []); + if (!is_array($backups)) { + $backups = []; + } + + $target = null; + foreach ($backups as $backup) { + if ((int) ($backup['id'] ?? 0) === $id) { + $target = $backup; + break; + } + } + + if (!$target || !is_array($target['payload'] ?? null)) { + return $this->error(__('备份快照不存在', 'aksrt'), 404); + } + + $payload = $target['payload']; + $domains = $payload['domains'] ?? []; + $standalone = $payload['standalone'] ?? []; + $provider = AKSRT_Admin_Schema_Provider::get_instance(); + + foreach ($domains as $domain => $domainPayload) { + $data = $domainPayload['data'] ?? $domainPayload; + if (is_array($data)) { + $provider->write_settings((string) $domain, $data); + } + } + + foreach ($standalone as $key => $value) { + if (is_string($key) && preg_match('/^aksrt_/', $key)) { + update_option($key, $value); + } + } + + AKSRT_Settings::flush_settings_cache(); + + return $this->success([ + 'message' => __('设置已从快照恢复', 'aksrt'), + 'data' => [ + 'id' => $id, + ], + ]); + } + + public function delete_setting_backup(WP_REST_Request $request): WP_REST_Response { + $id = (int) $request->get_param('id'); + $backups = get_option('aksrt_settings_backups', []); + if (!is_array($backups)) { + $backups = []; + } + + $filtered = []; + $found = false; + foreach ($backups as $backup) { + if ((int) ($backup['id'] ?? 0) === $id) { + $found = true; + continue; + } + $filtered[] = $backup; + } + + if (!$found) { + return $this->error(__('备份快照不存在', 'aksrt'), 404); + } + + update_option('aksrt_settings_backups', array_values($filtered)); + + return $this->success([ + 'message' => __('备份快照已删除', 'aksrt'), + ]); + } + + /* ------------------------------------------------------------------ */ + /* Widget Template Handlers */ + /* ------------------------------------------------------------------ */ + + /** + * GET /widgets/export — Export current widget configuration. + */ + public function export_widgets(WP_REST_Request $request): WP_REST_Response { + $sidebars_widgets = get_option('sidebars_widgets', []); + $widget_options = $this->collect_widget_options(); + + return $this->success([ + 'data' => [ + 'version' => AKSRT_VERSION ?? '3.4.0', + 'exported' => gmdate('Y-m-d H:i:s'), + 'sidebars_widgets' => $sidebars_widgets, + 'widget_options' => $widget_options, + ], + ]); + } + + /** + * POST /widgets/import — Import widget configuration. + */ + public function import_widgets(WP_REST_Request $request): WP_REST_Response { + $params = $request->get_json_params() ?? []; + $sidebars = $params['sidebars_widgets'] ?? null; + $options = $params['widget_options'] ?? null; + + if (!$sidebars || !is_array($sidebars)) { + return $this->error('缺少 sidebars_widgets 数据'); + } + + // Validate sidebar IDs exist + $valid_sidebars = array_keys(get_registered_sidebars()); + $valid_sidebars[] = 'wp_inactive_widgets'; + + $imported = 0; + foreach ($sidebars as $sidebar_id => $widget_ids) { + if (!in_array($sidebar_id, $valid_sidebars, true) && $sidebar_id !== 'array_version') { + continue; + } + $imported++; + } + + // Import widget options if provided + if ($options && is_array($options)) { + foreach ($options as $option_key => $option_value) { + if (preg_match('/^widget_aksrt_/', $option_key)) { + update_option($option_key, $option_value); + } + } + } + + // Import sidebar assignments + update_option('sidebars_widgets', $sidebars); + + return $this->success(['message' => "已导入 {$imported} 个侧栏配置。"]); + } + + /** + * GET /widgets/templates — List saved widget templates. + */ + public function list_widget_templates(WP_REST_Request $request): WP_REST_Response { + $templates = get_option('aksrt_widget_templates', []); + return $this->success(['data' => ['templates' => $templates]]); + } + + /** + * POST /widgets/templates — Save current layout as a named template. + */ + public function save_widget_template(WP_REST_Request $request): WP_REST_Response { + $params = $request->get_json_params() ?? []; + $name = sanitize_text_field((string) ($params['name'] ?? '')); + if (!$name) { + return $this->error('请输入模板名称'); + } + + $templates = get_option('aksrt_widget_templates', []); + $template = [ + 'id' => count($templates) + 1, + 'name' => $name, + 'created_at' => gmdate('Y-m-d H:i:s'), + 'sidebars_widgets' => get_option('sidebars_widgets', []), + 'widget_options' => $this->collect_widget_options(), + ]; + + $templates[] = $template; + update_option('aksrt_widget_templates', $templates); + + return $this->success(['message' => '模板已保存', 'data' => $template]); + } + + /** + * POST /widgets/templates/{id}/apply — Apply a saved template. + */ + public function apply_widget_template(WP_REST_Request $request): WP_REST_Response { + $id = (int) $request->get_param('id'); + $templates = get_option('aksrt_widget_templates', []); + + $template = null; + foreach ($templates as $t) { + if ((int) ($t['id'] ?? 0) === $id) { + $template = $t; + break; + } + } + + if (!$template) { + return $this->error('模板不存在', 404); + } + + // Apply widget options + if (!empty($template['widget_options']) && is_array($template['widget_options'])) { + foreach ($template['widget_options'] as $key => $value) { + update_option($key, $value); + } + } + + // Apply sidebar assignments + if (!empty($template['sidebars_widgets'])) { + update_option('sidebars_widgets', $template['sidebars_widgets']); + } + + return $this->success(['message' => '模板已应用']); + } + + /** + * DELETE /widgets/templates/{id} — Delete a saved template. + */ + public function delete_widget_template(WP_REST_Request $request): WP_REST_Response { + $id = (int) $request->get_param('id'); + $templates = get_option('aksrt_widget_templates', []); + + $found = false; + $filtered = []; + foreach ($templates as $t) { + if ((int) ($t['id'] ?? 0) === $id) { + $found = true; + continue; + } + $filtered[] = $t; + } + + if (!$found) { + return $this->error('模板不存在', 404); + } + + update_option('aksrt_widget_templates', $filtered); + return $this->success(['message' => '模板已删除']); + } + + /** + * Collect all widget options from the database. + */ + private function collect_widget_options(): array { + global $wpdb; + $rows = $wpdb->get_results( + "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE 'widget_aksrt_%' ORDER BY option_name", + ARRAY_A + ); + + $options = []; + if ($rows) { + foreach ($rows as $row) { + $value = maybe_unserialize($row['option_value']); + $options[$row['option_name']] = $value; + } + } + return $options; + } + + private function persist_setting_backup(array $backup): void { + $backups = get_option('aksrt_settings_backups', []); + if (!is_array($backups)) { + $backups = []; + } + + array_unshift($backups, $backup); + update_option('aksrt_settings_backups', $this->trim_setting_backups($backups)); + } + + private function create_backup_record(string $type, string $label = ''): array { + return $this->build_settings_backup($type, $label); + } + + public function register_backup_schedules(array $schedules): array { + if (!isset($schedules[self::MONTHLY_SCHEDULE])) { + $schedules[self::MONTHLY_SCHEDULE] = [ + 'interval' => MONTH_IN_SECONDS, + 'display' => __('每月', 'aksrt'), + ]; + } + + return $schedules; + } + + public function sync_backup_schedule(): void { + $settings = $this->get_backup_schedule_settings(); + $next = wp_next_scheduled(self::SCHEDULED_BACKUP_HOOK); + + if (!$settings['enabled']) { + if ($next) { + wp_unschedule_event($next, self::SCHEDULED_BACKUP_HOOK); + } + return; + } + + $schedule = $settings['schedule']; + if ($next && wp_get_schedule(self::SCHEDULED_BACKUP_HOOK) !== $schedule) { + wp_unschedule_event($next, self::SCHEDULED_BACKUP_HOOK); + $next = false; + } + + if (!$next) { + wp_schedule_event(time() + HOUR_IN_SECONDS, $schedule, self::SCHEDULED_BACKUP_HOOK); + } + } + + public function clear_backup_schedule(): void { + $next = wp_next_scheduled(self::SCHEDULED_BACKUP_HOOK); + while ($next) { + wp_unschedule_event($next, self::SCHEDULED_BACKUP_HOOK); + $next = wp_next_scheduled(self::SCHEDULED_BACKUP_HOOK); + } + } + + public function run_scheduled_backup(): void { + $settings = $this->get_backup_schedule_settings(); + if (!$settings['enabled']) { + return; + } + + $backup = $this->create_backup_record('scheduled'); + $this->persist_setting_backup($backup); + } + + public function handle_theme_upgrade_snapshot($response, array $hook_extra) { + if (($hook_extra['type'] ?? '') !== 'theme') { + return $response; + } + + $themes = $hook_extra['themes'] ?? []; + $stylesheet = wp_get_theme()->get_stylesheet(); + if (!in_array($stylesheet, (array) $themes, true)) { + return $response; + } + + $backup = $this->create_backup_record('update_theme'); + $this->persist_setting_backup($backup); + + return $response; + } +} diff --git a/inc/classes/admin/import-export-traits/trait-aksrt-admin-import-export-controller-part-2.php b/inc/classes/admin/import-export-traits/trait-aksrt-admin-import-export-controller-part-2.php new file mode 100644 index 00000000..be750d9e --- /dev/null +++ b/inc/classes/admin/import-export-traits/trait-aksrt-admin-import-export-controller-part-2.php @@ -0,0 +1,78 @@ +read_settings('tools', 'backup'); + $frequency = sanitize_key((string) ($settings['backup_frequency'] ?? 'weekly')); + $allowed = ['daily', 'weekly', 'monthly']; + if (!in_array($frequency, $allowed, true)) { + $frequency = 'weekly'; + } + + return [ + 'enabled' => !empty($settings['backup_auto']), + 'schedule' => $frequency === 'monthly' ? self::MONTHLY_SCHEDULE : $frequency, + ]; + } + + private function build_settings_backup(string $type, string $label = ''): array { + $schema_provider = AKSRT_Admin_Schema_Provider::get_instance(); + $domains = $schema_provider->get_domain_keys(); + + $payload = [ + 'version' => AKSRT_VERSION ?? '3.4.0', + 'exported' => gmdate('Y-m-d H:i:s'), + 'domains' => [], + 'standalone'=> [], + ]; + + foreach ($domains as $domain) { + $payload['domains'][$domain] = [ + 'option_key' => $schema_provider->get_option_key($domain), + 'data' => $schema_provider->read_settings($domain), + ]; + } + + foreach (AKSRT_Settings::STANDALONE_OPTION_KEYS as $key) { + $value = get_option($key); + if ($value !== false) { + $payload['standalone'][$key] = $value; + } + } + + return [ + 'id' => time(), + 'type' => $type, + 'label' => $label !== '' ? $label : $this->default_backup_label($type), + 'created_at' => gmdate('Y-m-d H:i:s'), + 'payload' => $payload, + ]; + } + + private function trim_setting_backups(array $backups): array { + $retain = (int) (AKSRT_Settings::get_value('backup_retain', 7)); + $retain = max(3, min(20, $retain)); + + if (count($backups) <= $retain) { + return array_values($backups); + } + + return array_values(array_slice($backups, 0, $retain)); + } + + private function default_backup_label(string $type): string { + $map = [ + 'manual' => __('手动备份', 'aksrt'), + 'scheduled' => __('定期备份', 'aksrt'), + 'reset_all' => __('重置全部前快照', 'aksrt'), + 'reset_domain' => __('重置分区前快照', 'aksrt'), + 'update_theme' => __('更新主题前快照', 'aksrt'), + ]; + + return $map[$type] ?? __('设置快照', 'aksrt'); + } +} diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-extensions-audit.php b/inc/classes/admin/schema/class-aksrt-admin-schema-extensions-audit.php new file mode 100644 index 00000000..758cdde0 --- /dev/null +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-extensions-audit.php @@ -0,0 +1,43 @@ + 'audit', + 'title' => 'API内容审核', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'provider', 'title' => '审核接口', 'fields' => [ + ['key' => 'content_audit_enabled', 'label' => '启用内容审核', 'type' => 'switch', 'default' => false], + ['key' => 'content_audit_provider', 'label' => '审核服务商', 'type' => 'radio', 'default' => 'baidu', 'options' => [ + ['label' => '百度内容审核', 'value' => 'baidu'], + ['label' => '关闭审核接口', 'value' => 'null'], + ]], + ['key' => 'content_audit_api_key', 'label' => 'API Key', 'type' => 'input', 'default' => ''], + ['key' => 'content_audit_api_secret', 'label' => 'Secret Key', 'type' => 'input', 'default' => ''], + ]], + ['key' => 'scope', 'title' => '审核范围', 'fields' => [ + ['key' => 'content_audit_text_enabled', 'label' => '文本审核', 'type' => 'switch', 'default' => true], + ['key' => 'content_audit_image_enabled', 'label' => '图片审核', 'type' => 'switch', 'default' => false], + ['key' => 'audit_bbs_term', 'label' => '审核论坛分类', 'type' => 'switch', 'default' => false], + ]], + ['key' => 'fallback', 'title' => '失败策略', 'fields' => [ + ['key' => 'content_audit_on_failure', 'label' => '审核失败时', 'type' => 'radio', 'default' => 'allow', 'options' => [ + ['label' => '放行', 'value' => 'allow'], + ['label' => '拦截', 'value' => 'block'], + ]], + ['key' => 'content_audit_log_enabled', 'label' => '记录审核日志', 'type' => 'switch', 'default' => true], + ]], + ], + ]; + } +} diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-extensions-docs-mode.php b/inc/classes/admin/schema/class-aksrt-admin-schema-extensions-docs-mode.php new file mode 100644 index 00000000..964b3a2f --- /dev/null +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-extensions-docs-mode.php @@ -0,0 +1,53 @@ + 'docs-mode', + 'title' => '文档模式', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'basic', 'title' => '基础配置', 'fields' => [ + ['key' => 'docs_mode_enabled', 'label' => '启用文档模式', 'type' => 'switch', 'default' => false], + ['key' => 'docs_mode_default_template', 'label' => '文档首页模板', 'type' => 'select', 'default' => 'documentnav', 'options' => [ + ['label' => '文档导航页', 'value' => 'documentnav'], + ['label' => '专题聚合页', 'value' => 'special'], + ]], + ]], + ['key' => 'navigation', 'title' => '导航设置', 'fields' => [ + ['key' => 'docs_mode_toc_enabled', 'label' => '显示目录树', 'type' => 'switch', 'default' => true], + ['key' => 'docs_mode_chapter_nav_enabled', 'label' => '显示章节导航', 'type' => 'switch', 'default' => true], + ['key' => 'docs_mode_default_order', 'label' => '默认排序', 'type' => 'radio', 'default' => 'menu_order', 'options' => [ + ['label' => '按菜单顺序', 'value' => 'menu_order'], + ['label' => '按发布时间', 'value' => 'date'], + ['label' => '按标题排序', 'value' => 'title'], + ]], + ['key' => 'docs_mode_toc_depth', 'label' => '目录深度', 'type' => 'number', 'default' => 3, 'min' => 1, 'max' => 6], + ['key' => 'docs_mode_chapter_title', 'label' => '章节标题', 'type' => 'input', 'default' => ''], + ]], + ['key' => 'filter', 'title' => '筛选设置', 'fields' => [ + ['key' => 'docs_mode_cats', 'label' => '文档分类', 'type' => 'taxonomy_select', 'taxonomy' => 'category', 'multiple' => true, 'default' => []], + ['key' => 'docs_mode_exclude', 'label' => '在首页排除此类内容', 'type' => 'switch', 'default' => true], + ]], + ['key' => 'style', 'title' => '样式设置', 'fields' => [ + ['key' => 'docs_mode_style', 'label' => '文档样式', 'type' => 'select', 'default' => 'default', 'options' => [ + ['label' => '默认', 'value' => 'default'], + ['label' => '简洁', 'value' => 'simple'], + ['label' => '卡片', 'value' => 'card'], + ]], + ['key' => 'docs_mode_icon', 'label' => '显示图标', 'type' => 'switch', 'default' => true], + ['key' => 'docs_mode_count', 'label' => '显示文章数', 'type' => 'switch', 'default' => true], + ]], + ], + ]; + } +} diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-extensions-ip.php b/inc/classes/admin/schema/class-aksrt-admin-schema-extensions-ip.php new file mode 100644 index 00000000..a1c9b3d7 --- /dev/null +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-extensions-ip.php @@ -0,0 +1,51 @@ + 'ip', + 'title' => 'IP归属地', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'basic', 'title' => '基础配置', 'fields' => [ + ['key' => 'ip_geo_enabled', 'label' => '启用 IP 归属地', 'type' => 'switch', 'default' => false], + ['key' => 'ip_addr_sdk', 'label' => 'IP 接口服务商', 'type' => 'radio', 'default' => 'null', 'options' => [ + ['label' => '轮流查询', 'value' => 'polling'], + ['label' => '腾讯位置服务', 'value' => 'qq'], + ['label' => '高德位置服务', 'value' => 'amap'], + ['label' => '太平洋公共接口', 'value' => 'null'], + ]], + ['key' => 'ip_geo_cache_ttl', 'label' => '缓存时间 (秒)', 'type' => 'number', 'default' => 86400, 'min' => 3600, 'max' => 604800], + ]], + ['key' => 'providers', 'title' => '接口凭据', 'fields' => [ + ['key' => 'ip_addr_sdk_qq_appkey', 'label' => '腾讯 AppKey', 'type' => 'input', 'default' => ''], + ['key' => 'ip_addr_sdk_qq_secretkey', 'label' => '腾讯 SecretKey', 'type' => 'input', 'default' => ''], + ['key' => 'ip_addr_sdk_amap_appkey', 'label' => '高德 AppKey', 'type' => 'input', 'default' => ''], + ['key' => 'ip_addr_sdk_amap_secretkey', 'label' => '高德 SecretKey', 'type' => 'input', 'default' => ''], + ]], + ['key' => 'display', 'title' => '展示设置', 'fields' => [ + ['key' => 'ip_geo_display', 'label' => '展示位置', 'type' => 'checkbox', 'default' => ['comment'], 'sanitize_type' => 'array', 'options' => [ + ['label' => '评论区', 'value' => 'comment'], + ['label' => '用户主页', 'value' => 'user_home'], + ['label' => '文章页', 'value' => 'post'], + ]], + ['key' => 'ip_geo_format', 'label' => '显示格式', 'type' => 'radio', 'default' => 'province_city', 'options' => [ + ['label' => '省份+城市', 'value' => 'province_city'], + ['label' => '仅省份', 'value' => 'province'], + ['label' => '国家+省份', 'value' => 'country_province'], + ]], + ['key' => 'ip_geo_fallback_text', 'label' => '查询失败时文案', 'type' => 'input', 'default' => ''], + ]], + ], + ]; + } +} diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-extensions-push.php b/inc/classes/admin/schema/class-aksrt-admin-schema-extensions-push.php new file mode 100644 index 00000000..e9216f25 --- /dev/null +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-extensions-push.php @@ -0,0 +1,43 @@ + 'push', + 'title' => '百度推送', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'basic', 'title' => '推送配置', 'fields' => [ + ['key' => 'baidu_push_enabled', 'label' => '启用百度推送', 'type' => 'switch', 'default' => false], + ['key' => 'baidu_push_token', 'label' => '推送 Token', 'type' => 'input', 'default' => ''], + ]], + ['key' => 'trigger', 'title' => '触发规则', 'fields' => [ + ['key' => 'baidu_push_auto', 'label' => '自动推送', 'type' => 'switch', 'default' => true], + ['key' => 'baidu_push_on_publish', 'label' => '发布时主动推送', 'type' => 'switch', 'default' => true], + ['key' => 'baidu_push_on_update', 'label' => '更新时推送', 'type' => 'switch', 'default' => false], + ]], + ['key' => 'xzh', 'title' => '熊掌号 / JSON-LD', 'fields' => [ + ['key' => 'xzh_on', 'label' => '启用熊掌号', 'type' => 'switch', 'default' => false], + ['key' => 'xzh_appid', 'label' => '百度 AppID', 'type' => 'input', 'default' => ''], + ['key' => 'xzh_render_tail', 'label' => '文章底部显示熊掌号尾部', 'type' => 'switch', 'default' => true], + ['key' => 'xzh_jsonld_single', 'label' => '文章页 JSON-LD', 'type' => 'switch', 'default' => true], + ['key' => 'xzh_jsonld_page', 'label' => '页面 JSON-LD', 'type' => 'switch', 'default' => false], + ['key' => 'xzh_jsonld_img', 'label' => '不添加图片', 'type' => 'switch', 'default' => false], + ]], + ['key' => 'advanced', 'title' => '高级设置', 'fields' => [ + ['key' => 'baidu_push_batch_size', 'label' => '批量推送条数', 'type' => 'number', 'default' => 50, 'min' => 1, 'max' => 500], + ['key' => 'baidu_push_log_enabled', 'label' => '记录推送日志', 'type' => 'switch', 'default' => true], + ]], + ], + ]; + } +} diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-extensions-security.php b/inc/classes/admin/schema/class-aksrt-admin-schema-extensions-security.php new file mode 100644 index 00000000..564481d8 --- /dev/null +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-extensions-security.php @@ -0,0 +1,111 @@ + 'security', + 'title' => '网站安全', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'basic', 'title' => '基础安全', 'fields' => [ + ['key' => 'hide_admin_bar', 'label' => '前台关闭顶部 admin_bar', 'type' => 'switch', 'default' => true], + ['key' => 'remove_emoji', 'label' => '移除Emoji脚本', 'type' => 'switch', 'default' => true], + ['key' => 'remove_open_sans', 'label' => '移除Open Sans字体', 'type' => 'switch', 'default' => true], + ['key' => 'disabled_pingback', 'label' => '禁用Pingback', 'type' => 'switch', 'default' => true], + ['key' => 'disabled_xmlrpc', 'label' => '禁用XML-RPC', 'type' => 'switch', 'default' => true], + ['key' => 'close_attachment_page', 'label' => '关闭附件页面', 'type' => 'switch', 'default' => true], + ['key' => 'user_disable_admin', 'label' => '非管理员禁止进入后台', 'type' => 'switch', 'default' => true], + ]], + ['key' => 'upload', 'title' => '上传设置', 'fields' => [ + ['key' => 'split_upload_s', 'label' => '分片上传', 'type' => 'switch', 'default' => true], + ['key' => 'split_minimum_size', 'label' => '最小分片大小(MB)', 'type' => 'number', 'default' => 20, 'min' => 4, 'max' => 100, 'step' => 2], + ['key' => 'newfilename', 'label' => '上传文件自动重命名', 'type' => 'switch', 'default' => false], + ['key' => 'newfilename_type', 'label' => '重命名格式', 'type' => 'radio', 'default' => 'time', 'options' => [ + ['label' => '随机字符串', 'value' => 'random'], + ['label' => '时间+原文件名', 'value' => 'time'], + ]], + ['key' => 'disabled_large_thumbnail', 'label' => '禁用大缩略图', 'type' => 'switch', 'default' => true], + ]], + ['key' => 'admin', 'title' => '后台设置', 'fields' => [ + ['key' => 'remove_dashboard_widgets', 'label' => 'WP 后台加速:移除站点健康/活动/禁止浏览器&PHP版本检测', 'type' => 'switch', 'default' => true], + ['key' => 'display_wp_update', 'label' => '禁止 WP 检测更新', 'type' => 'switch', 'default' => true], + ['key' => 'close_gutenberg', 'label' => '关闭古腾堡编辑器', 'type' => 'switch', 'default' => false, 'help' => '禁用后编辑器增强全部失效'], + ['key' => 'disabled_autoembed', 'label' => '禁用自动嵌入', 'type' => 'switch', 'default' => true], + ['key' => 'mce_float_toolbar', 'label' => '浮动工具栏', 'type' => 'checkbox', 'default' => ['pc_s', 'm_s'], 'sanitize_type' => 'array', 'options' => [ + ['label' => 'PC', 'value' => 'pc_s'], + ['label' => '移动端', 'value' => 'm_s'], + ]], + ['key' => 'admin_user_del_fields', 'label' => '关闭后台用户编辑多余选项', 'type' => 'switch', 'default' => true], + ['key' => 'admin_frontend_set', 'label' => '前台设置入口', 'type' => 'switch', 'default' => true], + ]], + ['key' => 'format', 'title' => '格式设置', 'fields' => [ + ['key' => 'time_ago_s', 'label' => '时间格式', 'type' => 'switch', 'default' => true], + ['key' => 'time_format', 'label' => '时间显示格式', 'type' => 'input', 'default' => 'n月j日 H:i'], + ['key' => 'remove_more_wp_head', 'label' => '移除WP head多余代码', 'type' => 'switch', 'default' => true], + ]], + ['key' => 'verification', 'title' => '验证设置', 'fields' => [ + ['key' => 'verification_comment_s', 'label' => '评论需要验证', 'type' => 'switch', 'default' => false], + ['key' => 'verification_links_s', 'label' => '链接需要验证', 'type' => 'switch', 'default' => true], + ['key' => 'verification_newposts_s', 'label' => '新文章需要验证', 'type' => 'switch', 'default' => false], + ['key' => 'verification_bbspost_s', 'label' => '论坛帖子需要验证', 'type' => 'switch', 'default' => false], + ['key' => 'verification_signin_exclude', 'label' => '登录人机验证排除账号', 'type' => 'input', 'default' => ''], + ['key' => 'user_verification_type', 'label' => '验证类型', 'type' => 'radio', 'default' => 'slider', 'options' => [ + ['label' => '图形验证码', 'value' => 'image'], + ['label' => '滑块验证', 'value' => 'slider'], + ['label' => '腾讯验证码', 'value' => 'tcaptcha'], + ['label' => '极验验证码', 'value' => 'geetest'], + ['label' => '关闭验证', 'value' => 'null'], + ]], + ]], + ['key' => 'captcha', 'title' => '验证码设置', 'fields' => [ + ['key' => 'geetest_enabled', 'label' => '极验验证码', 'type' => 'switch', 'default' => false], + ['key' => 'geetest_id', 'label' => '极验 ID', 'type' => 'input', 'default' => ''], + ['key' => 'geetest_key', 'label' => '极验 Key', 'type' => 'input', 'default' => ''], + ['key' => 'tcaptcha_enabled', 'label' => '腾讯云验证码', 'type' => 'switch', 'default' => false], + ['key' => 'tcaptcha_appid', 'label' => '腾讯云 AppID', 'type' => 'input', 'default' => ''], + ['key' => 'tcaptcha_secret_id', 'label' => '腾讯云 SecretId', 'type' => 'input', 'default' => ''], + ['key' => 'tcaptcha_secret_key', 'label' => '腾讯云 SecretKey', 'type' => 'input', 'default' => ''], + ]], + ['key' => 'rate-limit', 'title' => '频率限制', 'fields' => [ + ['key' => 'brush_limit_post_enabled', 'label' => '文章发布限制', 'type' => 'switch', 'default' => false], + ['key' => 'brush_limit_post_minutes_10', 'label' => '文章 10 分钟限制', 'type' => 'number', 'default' => 20, 'min' => 1, 'max' => 200], + ['key' => 'brush_limit_post_day_1', 'label' => '文章每日限制', 'type' => 'number', 'default' => 50, 'min' => 1, 'max' => 2000], + ['key' => 'brush_limit_comment_enabled', 'label' => '评论频率限制', 'type' => 'switch', 'default' => false], + ['key' => 'brush_limit_comment_minutes_10', 'label' => '评论 10 分钟限制', 'type' => 'number', 'default' => 20, 'min' => 1, 'max' => 200], + ['key' => 'brush_limit_comment_day_1', 'label' => '评论每日限制', 'type' => 'number', 'default' => 100, 'min' => 1, 'max' => 2000], + ['key' => 'brush_limit_upload_enabled', 'label' => '上传频率限制', 'type' => 'switch', 'default' => false], + ['key' => 'brush_limit_upload_minutes_10', 'label' => '上传 10 分钟限制', 'type' => 'number', 'default' => 50, 'min' => 1, 'max' => 200], + ['key' => 'brush_limit_upload_day_1', 'label' => '上传每日限制', 'type' => 'number', 'default' => 200, 'min' => 1, 'max' => 2000], + ['key' => 'brush_limit_register_enabled', 'label' => '注册频率限制', 'type' => 'switch', 'default' => false], + ['key' => 'brush_limit_register_minutes_10', 'label' => '注册 10 分钟限制', 'type' => 'number', 'default' => 20, 'min' => 1, 'max' => 200], + ['key' => 'brush_limit_register_day_1', 'label' => '注册每日限制', 'type' => 'number', 'default' => 100, 'min' => 1, 'max' => 2000], + ['key' => 'brush_limit_order_enabled', 'label' => '订单频率限制', 'type' => 'switch', 'default' => false], + ['key' => 'brush_limit_order_minutes_10', 'label' => '订单 10 分钟限制', 'type' => 'number', 'default' => 30, 'min' => 1, 'max' => 200], + ['key' => 'brush_limit_order_day_1', 'label' => '订单每日限制', 'type' => 'number', 'default' => 100, 'min' => 1, 'max' => 2000], + ['key' => 'error_frequency_limit', 'label' => '错误频率限制', 'type' => 'switch', 'default' => true], + ]], + ['key' => 'content-audit', 'title' => '内容审核', 'fields' => [ + ['key' => 'audit_comment', 'label' => '审核评论', 'type' => 'switch', 'default' => false], + ['key' => 'audit_new_post', 'label' => '审核新文章', 'type' => 'switch', 'default' => false], + ['key' => 'audit_upload_img', 'label' => '审核上传图片', 'type' => 'switch', 'default' => false], + ['key' => 'audit_user_desc', 'label' => '审核用户简介', 'type' => 'switch', 'default' => false], + ['key' => 'audit_msg_private', 'label' => '审核私信', 'type' => 'switch', 'default' => false], + ['key' => 'audit_be_like_text', 'label' => '文本允许疑似合规', 'type' => 'switch', 'default' => false], + ['key' => 'audit_be_like_img', 'label' => '图片允许疑似合规', 'type' => 'switch', 'default' => false], + ['key' => 'audit_bbs_posts', 'label' => '审核论坛帖子', 'type' => 'switch', 'default' => false], + ['key' => 'audit_bbs_plate', 'label' => '审核论坛板块', 'type' => 'switch', 'default' => false], + ['key' => 'audit_bbs_term', 'label' => '审核论坛分类', 'type' => 'switch', 'default' => false], + ]], + ], + ]; + } +} diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-extensions.php b/inc/classes/admin/schema/class-aksrt-admin-schema-extensions.php new file mode 100644 index 00000000..6dc289bb --- /dev/null +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-extensions.php @@ -0,0 +1,25 @@ + 'extensions', + 'title' => '扩展&增强', + 'sections' => [ + 'ip' => AKSRT_Admin_Schema_Extensions_IP::build(), + 'audit' => AKSRT_Admin_Schema_Extensions_Audit::build(), + 'push' => AKSRT_Admin_Schema_Extensions_Push::build(), + 'security' => AKSRT_Admin_Schema_Extensions_Security::build(), + 'docs-mode' => AKSRT_Admin_Schema_Extensions_Docs_Mode::build(), + ], + ]; + } +} diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-forum-basic.php b/inc/classes/admin/schema/class-aksrt-admin-schema-forum-basic.php new file mode 100644 index 00000000..93ac628c --- /dev/null +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-forum-basic.php @@ -0,0 +1,28 @@ + 'basic', + 'title' => '基础设置', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'switch', 'title' => '功能开关', 'fields' => [ + ['key' => 'bbs_s', 'label' => '启用论坛', 'type' => 'switch', 'default' => true, 'help' => '关闭后论坛页面将返回 404'], + ]], + ['key' => 'admin', 'title' => '论坛管理', 'fields' => [ + ['key' => 'bbs_admin_users', 'label' => '论坛管理员', 'type' => 'user_select', 'multiple' => true, 'default' => [], 'help' => '拥有跨版块全部治理权限,绕过 ban'], + ]], + ], + ]; + } +} diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-forum-board-page.php b/inc/classes/admin/schema/class-aksrt-admin-schema-forum-board-page.php new file mode 100644 index 00000000..47345bf4 --- /dev/null +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-forum-board-page.php @@ -0,0 +1,34 @@ + 'board_page', + 'title' => '版块页面', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'layout', 'title' => '版块页布局', 'fields' => [ + ['key' => 'bbs_plate_top_info_s', 'label' => '版块信息卡', 'type' => 'switch', 'default' => true, 'help' => '版块页顶部显示版块详情卡片'], + ['key' => 'bbs_plate_tab', 'label' => '版块栏目', 'type' => 'group', 'default' => [ + ['label' => '全部', 'sort' => 'date'], + ['label' => '推荐', 'sort' => 'featured'], + ['label' => '最新回复', 'sort' => 'last_reply'], + ['label' => '热门', 'sort' => 'hot'], + ['label' => '精华', 'sort' => 'essence'], + ]], + ['key' => 'bbs_plate_tab_active_index', 'label' => '默认选中 Tab', 'type' => 'number', 'default' => 0, 'min' => 0], + ['key' => 'bbs_plate_tab_swiper', 'label' => '移动端滑动切换', 'type' => 'switch', 'default' => true], + ]], + ], + ]; + } +} diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-forum-credits.php b/inc/classes/admin/schema/class-aksrt-admin-schema-forum-credits.php new file mode 100644 index 00000000..6458b47c --- /dev/null +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-forum-credits.php @@ -0,0 +1,33 @@ + 'credits', + 'title' => '积分设置', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'earn', 'title' => '积分奖励', 'fields' => [ + ['key' => 'bbs_credits_post_new', 'label' => '发帖奖励', 'type' => 'number', 'default' => 5, 'min' => 0], + ['key' => 'bbs_credits_plate_new', 'label' => '建版奖励', 'type' => 'number', 'default' => 10, 'min' => 0], + ['key' => 'bbs_credits_comment_new', 'label' => '回复奖励', 'type' => 'number', 'default' => 2, 'min' => 0], + ['key' => 'bbs_credits_comment_like', 'label' => '评论被赞', 'type' => 'number', 'default' => 1, 'min' => 0], + ['key' => 'bbs_credits_score_extra', 'label' => '被加分', 'type' => 'number', 'default' => 3, 'min' => 0], + ['key' => 'bbs_credits_essence', 'label' => '被设精华', 'type' => 'number', 'default' => 10, 'min' => 0], + ['key' => 'bbs_credits_posts_hot', 'label' => '成为热帖', 'type' => 'number', 'default' => 5, 'min' => 0], + ['key' => 'bbs_credits_adopted', 'label' => '被采纳', 'type' => 'number', 'default' => 20, 'min' => 0], + ['key' => 'bbs_credits_daily_max', 'label' => '每日积分上限', 'type' => 'number', 'default' => 50, 'min' => 0, 'help' => '0 = 不限'], + ]], + ], + ]; + } +} diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-forum-homepage.php b/inc/classes/admin/schema/class-aksrt-admin-schema-forum-homepage.php new file mode 100644 index 00000000..87a73b84 --- /dev/null +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-forum-homepage.php @@ -0,0 +1,32 @@ + 'homepage', + 'title' => '首页设置', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'tabs', 'title' => '首页 Tab', 'fields' => [ + ['key' => 'bbs_home_tab', 'label' => '首页栏目', 'type' => 'group', 'default' => [ + ['type' => 'follow', 'label' => '关注'], + ['type' => 'synthesis', 'label' => '综合'], + ['type' => 'plate', 'label' => '版块'], + ], 'help' => '可拖拽排序,支持 follow / synthesis / plate / tabs 类型'], + ['key' => 'bbs_home_tab_active_index', 'label' => '默认选中 Tab', 'type' => 'number', 'default' => 1, 'min' => 0], + ['key' => 'bbs_home_tab_quick_posts_s', 'label' => '快速发帖', 'type' => 'switch', 'default' => true, 'help' => '首页顶部显示快速发帖入口'], + ['key' => 'bbs_home_tab_swiper', 'label' => '移动端滑动切换', 'type' => 'switch', 'default' => true], + ]], + ], + ]; + } +} diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-forum-names.php b/inc/classes/admin/schema/class-aksrt-admin-schema-forum-names.php new file mode 100644 index 00000000..d3eb97eb --- /dev/null +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-forum-names.php @@ -0,0 +1,36 @@ + 'names', + 'title' => '名称定义', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'display_names', 'title' => '名词自定义', 'fields' => [ + ['key' => 'bbs_forum_display_name', 'label' => '论坛', 'type' => 'text', 'default' => '论坛'], + ['key' => 'bbs_plate_display_name', 'label' => '版块', 'type' => 'text', 'default' => '版块'], + ['key' => 'bbs_posts_display_name', 'label' => '帖子', 'type' => 'text', 'default' => '帖子'], + ['key' => 'bbs_topic_display_name', 'label' => '话题', 'type' => 'text', 'default' => '话题'], + ['key' => 'bbs_tag_display_name', 'label' => '标签', 'type' => 'text', 'default' => '标签'], + ['key' => 'bbs_comment_display_name', 'label' => '回复', 'type' => 'text', 'default' => '回复'], + ['key' => 'bbs_question_badge_name', 'label' => '提问徽章', 'type' => 'text', 'default' => '提问'], + ['key' => 'bbs_question_ok_badge_name', 'label' => '已解决徽章', 'type' => 'text', 'default' => '已解决'], + ['key' => 'bbs_cat_moderator_name', 'label' => '分区版主', 'type' => 'text', 'default' => '分区版主'], + ['key' => 'bbs_plate_author_name', 'label' => '超级版主', 'type' => 'text', 'default' => '超级版主'], + ['key' => 'bbs_plate_moderator_name', 'label' => '版主', 'type' => 'text', 'default' => '版主'], + ['key' => 'bbs_plate_follow_name', 'label' => '关注', 'type' => 'text', 'default' => '关注'], + ]], + ], + ]; + } +} diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-forum-other.php b/inc/classes/admin/schema/class-aksrt-admin-schema-forum-other.php new file mode 100644 index 00000000..7ecfdfc0 --- /dev/null +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-forum-other.php @@ -0,0 +1,46 @@ + 'other', + 'title' => '其它设置', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'vote', 'title' => '投票', 'fields' => [ + ['key' => 'bbs_vote_number_type', 'label' => '投票显示', 'type' => 'select', 'default' => 'percent', 'options' => [ + ['label' => '百分比', 'value' => 'percent'], + ['label' => '票数', 'value' => 'number'], + ]], + ['key' => 'bbs_vote_max', 'label' => '选项上限', 'type' => 'number', 'default' => 8, 'min' => 2, 'max' => 20], + ]], + ['key' => 'moderator_apply', 'title' => '版主申请', 'fields' => [ + ['key' => 'bbs_apply_moderator_desc', 'label' => '申请说明', 'type' => 'textarea', 'default' => '欢迎有热情的用户申请成为版主,协助管理社区秩序。'], + ]], + ['key' => 'misc', 'title' => '杂项', 'fields' => [ + ['key' => 'bbs_term_thumb', 'label' => '标签默认缩略图', 'type' => 'media', 'default' => ''], + ['key' => 'bbs_post_lists_hide_pay_s', 'label' => '列表隐藏付费标记', 'type' => 'switch', 'default' => false], + ['key' => 'bbs_post_title_strlen_limit', 'label' => '标题字数限制', 'type' => 'number', 'default' => 30, 'min' => 5, 'max' => 100, 'help' => '最小 5,最大 100'], + ['key' => 'bbs_t_placeholder', 'label' => '标题占位符', 'type' => 'text', 'default' => '请输入标题'], + ['key' => 'bbs_c_placeholder', 'label' => '内容占位符', 'type' => 'text', 'default' => '请输入内容'], + ]], + ['key' => 'url', 'title' => 'URL / 别名', 'fields' => [ + ['key' => 'bbs_rewrite_suffix_html_s', 'label' => '.html 后缀', 'type' => 'switch', 'default' => false], + ['key' => 'bbs_plate_rewrite_slug', 'label' => '版块别名', 'type' => 'text', 'default' => 'board'], + ['key' => 'bbs_posts_rewrite_slug', 'label' => '帖子别名', 'type' => 'text', 'default' => 'topic'], + ['key' => 'bbs_posts_edit_rewrite_slug', 'label' => '编辑别名', 'type' => 'text', 'default' => 'topic-edit'], + ['key' => 'bbs_posts_show_in_rest_s', 'label' => '古腾堡编辑器', 'type' => 'switch', 'default' => false, 'help' => '开启后帖子可使用 Gutenberg'], + ]], + ], + ]; + } +} diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-forum-permissions.php b/inc/classes/admin/schema/class-aksrt-admin-schema-forum-permissions.php new file mode 100644 index 00000000..19c3d4c0 --- /dev/null +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-forum-permissions.php @@ -0,0 +1,46 @@ + 'permissions', + 'title' => '权限配置', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'posting', 'title' => '发帖限制', 'fields' => [ + ['key' => 'bbs_posts_add_limit_opt_max', 'label' => '每日发帖上限', 'type' => 'number', 'default' => 10, 'min' => 0, 'help' => '0 = 不限'], + ['key' => 'bbs_plate_add_limit_opt_max', 'label' => '建版数量上限', 'type' => 'number', 'default' => 3, 'min' => 0], + ]], + ['key' => 'display_btns', 'title' => '按钮显示', 'fields' => [ + ['key' => 'bbs_show_new_plate', 'label' => '始终显示建版按钮', 'type' => 'switch', 'default' => false], + ['key' => 'bbs_show_new_topic', 'label' => '始终显示发帖按钮', 'type' => 'switch', 'default' => true], + ['key' => 'bbs_show_apply_moderator', 'label' => '始终显示申请版主', 'type' => 'switch', 'default' => true], + ]], + ['key' => 'paid_content', 'title' => '帖子付费', 'fields' => [ + ['key' => 'bbs_post_pay_hide_type_s', 'label' => '启用付费内容', 'type' => 'switch', 'default' => false], + ['key' => 'bbs_post_pay_vip_price_s', 'label' => 'VIP 特价', 'type' => 'switch', 'default' => false], + ['key' => 'bbs_post_pay_vip_1_discount', 'label' => '一级 VIP 折扣 (%)', 'type' => 'number', 'default' => 80, 'min' => 0, 'max' => 100], + ['key' => 'bbs_post_pay_vip_2_discount', 'label' => '二级 VIP 折扣 (%)', 'type' => 'number', 'default' => 50, 'min' => 0, 'max' => 100], + ['key' => 'bbs_post_pay_price_limit', 'label' => '最高定价', 'type' => 'number', 'default' => 1000, 'min' => 1], + ]], + ['key' => 'plate_follow', 'title' => '版块付费关注', 'fields' => [ + ['key' => 'bbs_plate_follow_pay_s', 'label' => '启用付费关注', 'type' => 'switch', 'default' => false], + ['key' => 'bbs_plate_follow_pay_price', 'label' => '关注价格', 'type' => 'number', 'default' => 10, 'min' => 0], + ['key' => 'bbs_plate_follow_pay_type', 'label' => '支付方式', 'type' => 'select', 'default' => 'money', 'options' => [ + ['label' => '现金', 'value' => 'money'], + ['label' => '积分', 'value' => 'points'], + ]], + ]], + ], + ]; + } +} diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-forum-post-page.php b/inc/classes/admin/schema/class-aksrt-admin-schema-forum-post-page.php new file mode 100644 index 00000000..a1118643 --- /dev/null +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-forum-post-page.php @@ -0,0 +1,46 @@ + 'post_page', + 'title' => '帖子页面', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'breadcrumbs', 'title' => '面包屑', 'fields' => [ + ['key' => 'bbs_breadcrumbs_s', 'label' => '显示面包屑', 'type' => 'switch', 'default' => true], + ['key' => 'bbs_breadcrumbs_home', 'label' => '显示首页', 'type' => 'switch', 'default' => true], + ['key' => 'bbs_breadcrumbs_bbs_home', 'label' => '显示论坛首页', 'type' => 'switch', 'default' => true], + ['key' => 'bbs_breadcrumbs_bbs_home_name', 'label' => '论坛首页名称', 'type' => 'text', 'default' => '论坛'], + ['key' => 'bbs_breadcrumbs_plate_cat', 'label' => '显示分区', 'type' => 'switch', 'default' => true], + ]], + ['key' => 'score', 'title' => '评分设置', 'fields' => [ + ['key' => 'bbs_score_extra_max', 'label' => '加分上限', 'type' => 'number', 'default' => 5, 'min' => 1, 'max' => 50], + ['key' => 'bbs_score_deduct_max', 'label' => '扣分上限', 'type' => 'number', 'default' => 3, 'min' => 1, 'max' => 50], + ]], + ['key' => 'display', 'title' => '显示', 'fields' => [ + ['key' => 'bbs_posts_maxheight_s', 'label' => '内容高度限制', 'type' => 'switch', 'default' => false], + ['key' => 'bbs_posts_maxheight', 'label' => '最大高度 (px)', 'type' => 'number', 'default' => 800, 'min' => 300, 'max' => 3000, 'condition' => ['bbs_posts_maxheight_s' => true]], + ]], + ['key' => 'cover', 'title' => '封面设置', 'fields' => [ + ['key' => 'bbs_posts_cover_ratio', 'label' => '封面比例', 'type' => 'select', 'default' => '16:9', 'options' => [ + ['label' => '16:9', 'value' => '16:9'], + ['label' => '4:3', 'value' => '4:3'], + ['label' => '1:1', 'value' => '1:1'], + ['label' => '3:4', 'value' => '3:4'], + ]], + ['key' => 'bbs_posts_cover_video_mute', 'label' => '视频封面静音', 'type' => 'switch', 'default' => true], + ]], + ], + ]; + } +} diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-forum-reply.php b/inc/classes/admin/schema/class-aksrt-admin-schema-forum-reply.php new file mode 100644 index 00000000..7979ddcd --- /dev/null +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-forum-reply.php @@ -0,0 +1,39 @@ + 'reply', + 'title' => '回复评论', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'paginate', 'title' => '翻页', 'fields' => [ + ['key' => 'bbs_reply_paginate_type', 'label' => '翻页方式', 'type' => 'select', 'default' => 'ajax', 'options' => [ + ['label' => 'AJAX 加载', 'value' => 'ajax'], + ['label' => '传统翻页', 'value' => 'page'], + ]], + ['key' => 'bbs_reply_paginate_ias_s', 'label' => '无限滚动', 'type' => 'switch', 'default' => false], + ['key' => 'bbs_reply_paginate_ias_max', 'label' => '自动加载页数', 'type' => 'number', 'default' => 3, 'min' => 0, 'max' => 10], + ]], + ['key' => 'toolbar', 'title' => '回复工具栏', 'fields' => [ + ['key' => 'bbs_comment_smilie', 'label' => '表情', 'type' => 'switch', 'default' => true], + ['key' => 'bbs_comment_code', 'label' => '代码', 'type' => 'switch', 'default' => true], + ['key' => 'bbs_comment_img', 'label' => '插图', 'type' => 'switch', 'default' => true], + ['key' => 'bbs_comment_upload_img', 'label' => '上传图片', 'type' => 'switch', 'default' => false], + ['key' => 'bbs_comment_quick_s', 'label' => '快捷回复', 'type' => 'switch', 'default' => true], + ['key' => 'bbs_comment_quick_often', 'label' => '预设短语', 'type' => 'textarea', 'default' => "不错,学习了\n感谢分享\n期待更新", 'help' => '每行一条'], + ['key' => 'bbs_comment_placeholder', 'label' => '回复占位符', 'type' => 'text', 'default' => '写下你的回复…'], + ]], + ], + ]; + } +} diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-forum-upload.php b/inc/classes/admin/schema/class-aksrt-admin-schema-forum-upload.php new file mode 100644 index 00000000..dd925f09 --- /dev/null +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-forum-upload.php @@ -0,0 +1,29 @@ + 'upload', + 'title' => '上传权限', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'limits', 'title' => '上传限制', 'fields' => [ + ['key' => 'bbs_upload_img_size', 'label' => '图片大小 (KB)', 'type' => 'number', 'default' => 2048, 'min' => 100], + ['key' => 'bbs_image_upload_multiple', 'label' => '批量上传数', 'type' => 'number', 'default' => 9, 'min' => 1, 'max' => 20], + ['key' => 'bbs_upload_video_size', 'label' => '视频大小 (KB)', 'type' => 'number', 'default' => 20480, 'min' => 1024], + ['key' => 'bbs_upload_file_size', 'label' => '文件大小 (KB)', 'type' => 'number', 'default' => 10240, 'min' => 100], + ['key' => 'bbs_upload_file_mimes', 'label' => '允许格式', 'type' => 'text', 'default' => 'jpg,jpeg,png,gif,mp4,zip,rar,pdf', 'help' => '逗号分隔'], + ]], + ], + ]; + } +} diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-forum.php b/inc/classes/admin/schema/class-aksrt-admin-schema-forum.php new file mode 100644 index 00000000..b441bf61 --- /dev/null +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-forum.php @@ -0,0 +1,126 @@ + 'forum', + 'title' => '社区&论坛', + 'sections' => [ + 'basic' => AKSRT_Admin_Schema_Forum_Basic::build(), + 'names' => AKSRT_Admin_Schema_Forum_Names::build(), + 'homepage' => AKSRT_Admin_Schema_Forum_Homepage::build(), + 'board_page' => AKSRT_Admin_Schema_Forum_Board_Page::build(), + 'post_page' => AKSRT_Admin_Schema_Forum_Post_Page::build(), + 'reply' => AKSRT_Admin_Schema_Forum_Reply::build(), + 'permissions' => AKSRT_Admin_Schema_Forum_Permissions::build(), + 'other' => AKSRT_Admin_Schema_Forum_Other::build(), + 'upload' => AKSRT_Admin_Schema_Forum_Upload::build(), + 'credits' => AKSRT_Admin_Schema_Forum_Credits::build(), + ], + ]; + } + + // ================================================================ + // §8.1 Capability matrix schema builder + // ================================================================ + + /** + * Build groups for the capabilities section from cap_definitions(). + * + * Each capability becomes a group with role columns as fields. + * Grouped by prefix: user_, new_post_, comment_. + */ + private static function build_cap_matrix_groups(): array { + if (!class_exists('AKSRT_Settings')) { + return []; + } + + $defs = AKSRT_Settings::cap_definitions(); + $cols = AKSRT_Settings::cap_role_columns(); + + $col_labels = [ + 'all' => __('所有人', 'aksrt'), + 'logged' => __('已登录', 'aksrt'), + 'level' => __('等级≥', 'aksrt'), + 'vip' => __('VIP≥', 'aksrt'), + 'auth' => __('已认证', 'aksrt'), + 'moderator' => __('版主', 'aksrt'), + 'plate_author' => __('超级版主', 'aksrt'), + 'cat_moderator' => __('分区版主', 'aksrt'), + ]; + + // Group caps by prefix + $buckets = [ + 'user' => ['title' => __('用户治理', 'aksrt'), 'caps' => []], + 'post' => ['title' => __('投稿权限', 'aksrt'), 'caps' => []], + 'comment' => ['title' => __('评论权限', 'aksrt'), 'caps' => []], + ]; + + foreach ($defs as $cap_id => $def) { + if (str_starts_with($cap_id, 'comment_')) { + $buckets['comment']['caps'][$cap_id] = $def; + } elseif (str_starts_with($cap_id, 'new_post_')) { + $buckets['post']['caps'][$cap_id] = $def; + } else { + $buckets['user']['caps'][$cap_id] = $def; + } + } + + $groups = []; + foreach ($buckets as $bucket_key => $bucket) { + if (empty($bucket['caps'])) continue; + + $fields = []; + foreach ($bucket['caps'] as $cap_id => $def) { + $excludes = $def['exclude_roles'] ?? []; + $only_roles = $def['roles'] ?? null; + $defaults = $def['default'] ?? []; + + // Each cap gets one field per role column + foreach ($cols as $col) { + if (in_array($col, $excludes, true)) continue; + if ($only_roles !== null && !in_array($col, $only_roles, true) + && !in_array($col, ['all', 'logged', 'level', 'vip', 'auth'], true)) { + continue; + } + + $field_key = 'user_caps.' . $cap_id . '.' . $col; + $is_spinner = in_array($col, ['level', 'vip'], true); + + $field = [ + 'key' => $field_key, + 'label' => $def['label'] . ' — ' . ($col_labels[$col] ?? $col), + 'type' => $is_spinner ? 'number' : 'switch', + 'default' => $defaults[$col] ?? ($is_spinner ? -1 : false), + 'cap_id' => $cap_id, + 'cap_col' => $col, + ]; + + if ($is_spinner) { + $field['min'] = -1; + $field['max'] = $col === 'level' ? 10 : 2; + $field['help'] = __('-1 = 不限', 'aksrt'); + } + + $fields[] = $field; + } + } + + $groups[] = [ + 'key' => 'cap_' . $bucket_key, + 'title' => $bucket['title'], + 'fields' => $fields, + ]; + } + + return $groups; + } +} diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-global.php b/inc/classes/admin/schema/class-aksrt-admin-schema-global.php new file mode 100644 index 00000000..14f8efca --- /dev/null +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-global.php @@ -0,0 +1,530 @@ + 'global', + 'title' => '全局&功能', + 'sections' => [ + 'common' => [ + 'key' => 'common', + 'title' => '常用功能', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'comment', 'title' => '评论功能', 'fields' => [ + ['key' => 'comment_enabled', 'label' => '开启评论', 'type' => 'switch', 'default' => true], + ]], + ['key' => 'performance', 'title' => '性能优化', 'fields' => [ + ['key' => 'lazyload_enabled', 'label' => '图片懒加载', 'type' => 'switch', 'default' => true], + ]], + ['key' => 'avatar', 'title' => '头像服务', 'fields' => [ + ['key' => 'avatar_source', 'label' => '头像服务源', 'type' => 'select', 'default' => 'cn.cravatar.com', 'options' => [ + ['label' => 'Cravatar (国内推荐)', 'value' => 'cn.cravatar.com'], + ['label' => 'Gravatar 国内镜像', 'value' => 'cn.gravatar.com'], + ['label' => 'Gravatar 官方', 'value' => 'gravatar.com'], + ]], + ]], + ['key' => 'filing', 'title' => '备案信息', 'fields' => [ + ['key' => 'icp_filing', 'label' => 'ICP 备案号', 'type' => 'input', 'default' => ''], + ['key' => 'footer_text', 'label' => '页脚附加文字', 'type' => 'input', 'default' => ''], + ]], + ], + ], + 'logo' => [ + 'key' => 'logo', + 'title' => 'LOGO图像', + 'version' => '1.0.0', + 'option_key' => 'aksrt_seo_settings', + 'groups' => [ + ['key' => 'logo', 'title' => 'Logo 设置', 'fields' => [ + ['key' => 'site_logo', 'label' => '站点 Logo', 'type' => 'media', 'default' => ''], + ['key' => 'site_logo_dark', 'label' => '暗色模式 Logo', 'type' => 'media', 'default' => ''], + ['key' => 'logo_width', 'label' => 'Logo 宽度', 'type' => 'number', 'default' => 150, 'min' => 50, 'max' => 400], + ['key' => 'logo_text', 'label' => 'Logo 文字', 'type' => 'input', 'default' => ''], + ]], + ['key' => 'favicon', 'title' => '站点图标', 'fields' => [ + ['key' => 'site_favicon', 'label' => '浏览器图标', 'type' => 'media', 'default' => ''], + ['key' => 'site_icon', 'label' => '移动端图标', 'type' => 'media', 'default' => ''], + ]], + ], + ], + 'seo' => [ + 'key' => 'seo', + 'title' => 'SEO优化', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'basic', 'title' => 'SEO基础', 'fields' => [ + ['key' => 'seo_enabled', 'label' => '启用核心SEO优化', 'type' => 'switch', 'default' => true], + ['key' => 'seo_ai_enabled', 'label' => 'AI生成SEO内容', 'type' => 'switch', 'default' => false], + ['key' => 'seo_connector', 'label' => 'SEO连接符', 'type' => 'input', 'default' => '-'], + ['key' => 'seo_title', 'label' => 'SEO标题(title)', 'type' => 'textarea', 'default' => ''], + ['key' => 'seo_keywords', 'label' => 'SEO关键字(keywords)', 'type' => 'textarea', 'default' => ''], + ['key' => 'seo_description', 'label' => 'SEO描述(description)', 'type' => 'textarea', 'default' => ''], + ]], + ['key' => 'ai-seo', 'title' => 'AI SEO', 'fields' => [ + ['key' => 'ai_seo_title_prompt', 'label' => '标题提示词', 'type' => 'textarea', 'default' => ''], + ['key' => 'ai_seo_keywords_prompt', 'label' => '关键词提示词', 'type' => 'textarea', 'default' => ''], + ['key' => 'ai_seo_description_prompt', 'label' => '描述提示词', 'type' => 'textarea', 'default' => ''], + ]], + ['key' => 'baidu', 'title' => '百度SEO', 'fields' => [ + ['key' => 'seo_baidu_push', 'label' => '百度链接自动提交', 'type' => 'switch', 'default' => false], + ['key' => 'seo_baidu_normal', 'label' => '百度普通收录', 'type' => 'switch', 'default' => false], + ['key' => 'seo_baidu_fast', 'label' => '百度快速收录', 'type' => 'switch', 'default' => false], + ['key' => 'seo_baidu_token', 'label' => '百度准入密钥Token', 'type' => 'input', 'default' => ''], + ]], + ['key' => 'image', 'title' => '图片SEO', 'fields' => [ + ['key' => 'seo_image_optimize', 'label' => '搜索引擎图片展示优化', 'type' => 'switch', 'default' => true], + ['key' => 'seo_image_default', 'label' => '默认图像', 'type' => 'media', 'default' => ''], + ['key' => 'seo_image_always_default', 'label' => '固定使用默认图像', 'type' => 'switch', 'default' => false], + ['key' => 'seo_auto_alt', 'label' => '文章图片自动添加alt', 'type' => 'switch', 'default' => true], + ]], + ['key' => 'redirect', 'title' => '外链重定向', 'fields' => [ + ['key' => 'seo_go_link', 'label' => '启用外链重定向', 'type' => 'switch', 'default' => true], + ['key' => 'seo_go_link_nonce', 'label' => '外链重定向鉴权', 'type' => 'switch', 'default' => true], + ['key' => 'seo_go_link_post', 'label' => '文章内容外链重定向', 'type' => 'switch', 'default' => true], + ['key' => 'seo_go_link_exclude', 'label' => '排除域名', 'type' => 'textarea', 'default' => ''], + ]], + ['key' => 'advanced', 'title' => '高级SEO', 'fields' => [ + ['key' => 'seo_no_category', 'label' => '分类URL去除category', 'type' => 'switch', 'default' => false], + ['key' => 'seo_jsonld', 'label' => '启用JSON-LD', 'type' => 'switch', 'default' => true], + ['key' => 'seo_jsonld_single', 'label' => '文章页JSON-LD', 'type' => 'switch', 'default' => true], + ['key' => 'seo_jsonld_page', 'label' => '页面JSON-LD', 'type' => 'switch', 'default' => true], + ]], + ], + ], + 'lazyload' => [ + 'key' => 'lazyload', + 'title' => '懒加载设置', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'basic', 'title' => '懒加载开关', 'fields' => [ + ['key' => 'lazyload_enabled', 'label' => '图像异步懒加载', 'type' => 'switch', 'default' => true], + ['key' => 'lazyload_animation', 'label' => '懒加载动画效果', 'type' => 'select', 'default' => 'blur', 'options' => [ + ['label' => '淡出淡入', 'value' => 'fade'], + ['label' => '高斯模糊', 'value' => 'blur'], + ['label' => '放大缩小', 'value' => 'scale'], + ]], + ['key' => 'lazyload_placeholder', 'label' => '懒加载预载图', 'type' => 'media', 'default' => ''], + ['key' => 'lazyload_blur', 'label' => '模糊预览', 'type' => 'switch', 'default' => true], + ]], + ['key' => 'scope', 'title' => '懒加载范围', 'fields' => [ + ['key' => 'lazyload_avatar', 'label' => '头像懒加载', 'type' => 'switch', 'default' => true], + ['key' => 'lazyload_content', 'label' => '文章内容图片懒加载', 'type' => 'switch', 'default' => false], + ['key' => 'lazyload_comment', 'label' => '评论内容图片懒加载', 'type' => 'switch', 'default' => true], + ['key' => 'lazyload_private', 'label' => '私信聊天图片懒加载', 'type' => 'switch', 'default' => true], + ['key' => 'lazyload_slider', 'label' => '幻灯片图片懒加载', 'type' => 'switch', 'default' => true], + ['key' => 'lazyload_cover', 'label' => '封面图片懒加载', 'type' => 'switch', 'default' => true], + ['key' => 'lazyload_other', 'label' => '其它图片懒加载', 'type' => 'switch', 'default' => true], + ['key' => 'lazyload_widget', 'label' => '小工具图片懒加载', 'type' => 'switch', 'default' => true], + ]], + ], + ], + 'layout' => [ + 'key' => 'layout', + 'title' => '显示&布局', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'color', 'title' => '色彩方案', 'fields' => [ + ['key' => 'theme_mode', 'label' => '默认主题模式', 'type' => 'radio', 'default' => 'time-auto', 'options' => [ + ['label' => '浅色', 'value' => 'white-theme'], + ['label' => '深色', 'value' => 'dark-theme'], + ['label' => '跟随系统', 'value' => 'time-auto'], + ]], + ['key' => 'theme_mode_button', 'label' => '主题切换按钮位置', 'type' => 'checkbox', 'default' => ['pc_nav', 'm_menu'], 'options' => [ + ['label' => 'PC 顶部导航', 'value' => 'pc_nav'], + ['label' => '移动端菜单', 'value' => 'm_menu'], + ]], + ['key' => 'theme_skin', 'label' => '预设主题色', 'type' => 'palette', 'default' => 'f04494', 'options' => [ + ['label' => '玫红', 'value' => 'ff1856'], + ['label' => '粉色', 'value' => 'f04494'], + ['label' => '紫色', 'value' => 'ae53f3'], + ['label' => '靛蓝', 'value' => '627bf5'], + ['label' => '天蓝', 'value' => '00a2e3'], + ['label' => '青绿', 'value' => '16b597'], + ['label' => '翠绿', 'value' => '36af18'], + ['label' => '黄绿', 'value' => '8fb107'], + ['label' => '棕金', 'value' => 'b18c07'], + ['label' => '橘橙', 'value' => 'e06711'], + ['label' => '朱红', 'value' => 'f74735'], + ]], + ['key' => 'primary_color', 'label' => '自定义主题色', 'type' => 'color', 'default' => '#f04494'], + ['key' => 'dark_primary_color', 'label' => '暗色模式主题色', 'type' => 'color', 'default' => '#f04494'], + ['key' => 'border_radius', 'label' => '全局圆角', 'type' => 'slider', 'default' => 8, 'min' => 0, 'max' => 20], + ['key' => 'theme_main_radius', 'label' => '卡片圆角 (px)', 'type' => 'number', 'default' => 12, 'min' => 0, 'max' => 30, 'help' => '独立卡片圆角,不填则使用全局圆角 +4'], + ['key' => 'close_backdrop', 'label' => '关闭毛玻璃效果', 'type' => 'switch', 'default' => false], + ]], + ['key' => 'layout', 'title' => '布局设置', 'fields' => [ + ['key' => 'container_max_width', 'label' => '内容最大宽度', 'type' => 'number', 'default' => 1200, 'min' => 0, 'max' => 1600], + ['key' => 'sidebar_enabled', 'label' => '开启侧边栏', 'type' => 'switch', 'default' => true], + ['key' => 'sidebar_position', 'label' => '侧边栏位置', 'type' => 'radio', 'default' => 'right', 'options' => [ + ['label' => '左侧', 'value' => 'left'], + ['label' => '右侧', 'value' => 'right'], + ]], + ['key' => 'sidebar_width', 'label' => '侧边栏宽度', 'type' => 'number', 'default' => 300, 'min' => 200, 'max' => 400], + ['key' => 'sidebar_home_enabled', 'label' => '首页侧边栏', 'type' => 'switch', 'default' => true], + ['key' => 'sidebar_category_enabled', 'label' => '分类页侧边栏', 'type' => 'switch', 'default' => true], + ['key' => 'sidebar_tag_enabled', 'label' => '标签页侧边栏', 'type' => 'switch', 'default' => true], + ['key' => 'sidebar_single_enabled', 'label' => '文章页侧边栏', 'type' => 'switch', 'default' => true], + ['key' => 'sidebar_page_enabled', 'label' => '页面侧边栏', 'type' => 'switch', 'default' => true], + ['key' => 'sidebar_search_enabled', 'label' => '搜索页侧边栏', 'type' => 'switch', 'default' => true], + ]], + ['key' => 'typography', 'title' => '字体设置', 'fields' => [ + ['key' => 'font_family', 'label' => '正文字体', 'type' => 'select', 'default' => 'system', 'options' => [ + ['label' => '系统默认', 'value' => 'system'], + ['label' => '思源黑体', 'value' => 'noto-sans-sc'], + ['label' => '思源宋体', 'value' => 'noto-serif-sc'], + ['label' => '自定义', 'value' => 'custom'], + ]], + ]], + ['key' => 'performance', 'title' => '性能优化', 'fields' => [ + ['key' => 'qj_loading', 'label' => '全站加载动画', 'type' => 'switch', 'default' => false], + ['key' => 'qj_dh_xs', 'label' => '加载动画样式', 'type' => 'select', 'default' => 'no1', 'options' => [ + ['label' => '样式 1', 'value' => 'no1'], + ['label' => '样式 2', 'value' => 'no2'], + ['label' => '样式 3', 'value' => 'no3'], + ['label' => '样式 4', 'value' => 'no4'], + ['label' => '样式 5', 'value' => 'no5'], + ['label' => '样式 6', 'value' => 'no6'], + ['label' => '样式 7', 'value' => 'no7'], + ['label' => '样式 8', 'value' => 'no8'], + ['label' => '样式 9', 'value' => 'no9'], + ['label' => '样式 10', 'value' => 'no10'], + ]], + ]], + ['key' => 'avatar', 'title' => '头像设置', 'fields' => [ + ['key' => 'avatar_source', 'label' => '头像服务源', 'type' => 'select', 'default' => 'cn.cravatar.com', 'options' => [ + ['label' => 'Cravatar (国内推荐)', 'value' => 'cn.cravatar.com'], + ['label' => 'Gravatar 国内镜像', 'value' => 'cn.gravatar.com'], + ['label' => 'Gravatar 官方', 'value' => 'gravatar.com'], + ], 'help' => '国内站点建议优先选 Cravatar'], + ]], + ], + ], + 'code-highlight' => [ + 'key' => 'code-highlight', + 'title' => '代码高亮', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'basic', 'title' => '代码高亮设置', 'fields' => [ + ['key' => 'highlight_kg', 'label' => '代码高亮开关', 'type' => 'switch', 'default' => true], + ['key' => 'highlight_hh', 'label' => '显示行号', 'type' => 'switch', 'default' => false], + ['key' => 'highlight_btn', 'label' => '显示扩展按钮', 'type' => 'switch', 'default' => true], + ['key' => 'highlight_maxheight', 'label' => '最大高度(px,0=不限)', 'type' => 'number', 'default' => 400, 'min' => 0, 'max' => 2000], + ]], + ['key' => 'theme', 'title' => '主题设置', 'fields' => [ + ['key' => 'highlight_zt', 'label' => '日间默认主题', 'type' => 'select', 'default' => 'enlighter', 'options' => [ + ['label' => '浅色: Enlighter', 'value' => 'enlighter'], + ['label' => '浅色: Bootstrap', 'value' => 'bootstrap4'], + ['label' => '浅色: Classic', 'value' => 'classic'], + ['label' => '浅色: Beyond', 'value' => 'beyond'], + ['label' => '浅色: Mowtwo', 'value' => 'mowtwo'], + ['label' => '浅色: Eclipse', 'value' => 'eclipse'], + ['label' => '浅色: Droide', 'value' => 'droide'], + ['label' => '浅色: Minimal', 'value' => 'minimal'], + ['label' => '浅色: Rowhammer', 'value' => 'rowhammer'], + ['label' => '浅色: Godzilla', 'value' => 'godzilla'], + ['label' => '深色: Dracula', 'value' => 'dracula'], + ['label' => '深色: Atomic', 'value' => 'atomic'], + ['label' => '深色: Monokai', 'value' => 'monokai'], + ]], + ['key' => 'highlight_dark_zt', 'label' => '暗色默认主题', 'type' => 'select', 'default' => 'monokai', 'options' => [ + ['label' => '深色: Dracula', 'value' => 'dracula'], + ['label' => '深色: Atomic', 'value' => 'atomic'], + ['label' => '深色: Monokai', 'value' => 'monokai'], + ['label' => '浅色: Enlighter', 'value' => 'enlighter'], + ['label' => '浅色: Bootstrap', 'value' => 'bootstrap4'], + ]], + ]], + ], + ], + 'system-notice' => [ + 'key' => 'system-notice', + 'title' => '系统公告', + 'version' => '2.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'basic', 'title' => '公告设置', 'fields' => [ + ['key' => 'system_notice_enabled', 'label' => '启用系统公告', 'type' => 'switch', 'default' => true], + ['key' => 'system_notice_policy', 'label' => '显示对象', 'type' => 'select', 'default' => '', 'options' => [ + ['label' => '所有访客', 'value' => ''], + ['label' => '仅登录用户', 'value' => 'signin'], + ['label' => '仅 VIP 会员', 'value' => 'vip'], + ['label' => '仅非 VIP', 'value' => 'free'], + ['label' => '仅认证用户', 'value' => 'auth'], + ]], + ['key' => 'system_notice_title', 'label' => '公告标题', 'type' => 'input', 'default' => ''], + ['key' => 'system_notice_content', 'label' => '公告内容', 'type' => 'textarea', 'default' => ''], + ]], + ['key' => 'buttons', 'title' => '按钮组(留空则不显示)', 'fields' => [ + ['key' => 'system_notice_btn1_text', 'label' => '按钮 1 文案', 'type' => 'input', 'default' => ''], + ['key' => 'system_notice_btn1_link', 'label' => '按钮 1 链接', 'type' => 'input', 'default' => ''], + ['key' => 'system_notice_btn1_color', 'label' => '按钮 1 颜色', 'type' => 'select', 'default' => 'primary', 'options' => [ + ['label' => '主题色', 'value' => 'primary'], + ['label' => '成功绿', 'value' => 'success'], + ['label' => '警告橙', 'value' => 'warning'], + ['label' => '危险红', 'value' => 'danger'], + ['label' => '信息蓝', 'value' => 'info'], + ['label' => '深紫', 'value' => 'purple'], + ['label' => '深灰', 'value' => 'dark'], + ]], + ['key' => 'system_notice_btn2_text', 'label' => '按钮 2 文案', 'type' => 'input', 'default' => ''], + ['key' => 'system_notice_btn2_link', 'label' => '按钮 2 链接', 'type' => 'input', 'default' => ''], + ['key' => 'system_notice_btn2_color', 'label' => '按钮 2 颜色', 'type' => 'select', 'default' => 'primary', 'options' => [ + ['label' => '主题色', 'value' => 'primary'], + ['label' => '成功绿', 'value' => 'success'], + ['label' => '警告橙', 'value' => 'warning'], + ['label' => '危险红', 'value' => 'danger'], + ['label' => '信息蓝', 'value' => 'info'], + ['label' => '深紫', 'value' => 'purple'], + ['label' => '深灰', 'value' => 'dark'], + ]], + ['key' => 'system_notice_btn3_text', 'label' => '按钮 3 文案', 'type' => 'input', 'default' => ''], + ['key' => 'system_notice_btn3_link', 'label' => '按钮 3 链接', 'type' => 'input', 'default' => ''], + ['key' => 'system_notice_btn3_color', 'label' => '按钮 3 颜色', 'type' => 'select', 'default' => 'primary', 'options' => [ + ['label' => '主题色', 'value' => 'primary'], + ['label' => '成功绿', 'value' => 'success'], + ['label' => '警告橙', 'value' => 'warning'], + ['label' => '危险红', 'value' => 'danger'], + ['label' => '信息蓝', 'value' => 'info'], + ['label' => '深紫', 'value' => 'purple'], + ['label' => '深灰', 'value' => 'dark'], + ]], + ['key' => 'system_notice_btn4_text', 'label' => '按钮 4 文案', 'type' => 'input', 'default' => ''], + ['key' => 'system_notice_btn4_link', 'label' => '按钮 4 链接', 'type' => 'input', 'default' => ''], + ['key' => 'system_notice_btn4_color', 'label' => '按钮 4 颜色', 'type' => 'select', 'default' => 'primary', 'options' => [ + ['label' => '主题色', 'value' => 'primary'], + ['label' => '成功绿', 'value' => 'success'], + ['label' => '警告橙', 'value' => 'warning'], + ['label' => '危险红', 'value' => 'danger'], + ['label' => '信息蓝', 'value' => 'info'], + ['label' => '深紫', 'value' => 'purple'], + ['label' => '深灰', 'value' => 'dark'], + ]], + ]], + ['key' => 'style', 'title' => '样式设置', 'fields' => [ + ['key' => 'system_notice_size', 'label' => '弹窗尺寸', 'type' => 'select', 'default' => '', 'options' => [ + ['label' => '默认', 'value' => ''], + ['label' => '迷你', 'value' => 'modal-mini'], + ['label' => '小号', 'value' => 'modal-sm'], + ['label' => '大号', 'value' => 'modal-lg'], + ]], + ['key' => 'system_notice_radius', 'label' => '按钮圆角', 'type' => 'switch', 'default' => false, 'help' => '开启后弹窗按钮使用圆角样式'], + ['key' => 'system_notice_class', 'label' => '公告样式类', 'type' => 'input', 'default' => ''], + ['key' => 'system_notice_title_icon', 'label' => '标题图标', 'type' => 'icon', 'default' => ''], + ['key' => 'system_notice_title_class', 'label' => '标题背景配色', 'type' => 'select', 'default' => '', 'options' => [ + ['label' => '无', 'value' => ''], + ['label' => '炫彩黄', 'value' => 'jb-yellow'], + ['label' => '炫彩蓝', 'value' => 'jb-blue'], + ['label' => '炫彩绿', 'value' => 'jb-green'], + ['label' => '炫彩紫', 'value' => 'jb-purple'], + ['label' => '炫彩红', 'value' => 'jb-red'], + ['label' => '炫彩橙', 'value' => 'jb-orange'], + ], 'help' => '弹窗标题区域的背景配色方案'], + ['key' => 'system_notice_title_style', 'label' => '标题样式', 'type' => 'input', 'default' => ''], + ]], + ['key' => 'expire', 'title' => '过期设置', 'fields' => [ + ['key' => 'system_notice_expires', 'label' => '弹窗周期(小时)', 'type' => 'number', 'default' => 24, 'min' => 0, 'max' => 720, 'help' => '0 表示永不过期'], + ]], + ], + ], + 'search' => [ + 'key' => 'search', + 'title' => '搜索功能', + 'version' => '2.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'basic', 'title' => '搜索基础', 'fields' => [ + ['key' => 'search_mode', 'label' => '搜索模式', 'type' => 'radio', 'default' => 'default', 'options' => [ + ['label' => '默认搜索', 'value' => 'default'], + ['label' => 'AJAX 实时搜索', 'value' => 'ajax'], + ['label' => '跳转搜索', 'value' => 'redirect'], + ]], + ['key' => 'search_placeholder', 'label' => '搜索框提示文字', 'type' => 'input', 'default' => '开启精彩搜索'], + ]], + ['key' => 'content-types', 'title' => '搜索内容类型', 'fields' => [ + ['key' => 'search_type_post', 'label' => '文章', 'type' => 'switch', 'default' => true], + ['key' => 'search_type_user', 'label' => '用户', 'type' => 'switch', 'default' => false], + ['key' => 'search_type_category', 'label' => '分类', 'type' => 'switch', 'default' => true], + ['key' => 'search_type_tag', 'label' => '标签', 'type' => 'switch', 'default' => true], + ['key' => 'search_type_product', 'label' => '商品', 'type' => 'switch', 'default' => false], + ['key' => 'search_type_topic', 'label' => '帖子', 'type' => 'switch', 'default' => false], + ['key' => 'search_default_type', 'label' => '默认类型', 'type' => 'select', 'default' => 'post', 'options' => [ + ['label' => '文章', 'value' => 'post'], + ['label' => '用户', 'value' => 'user'], + ['label' => '分类', 'value' => 'category'], + ['label' => '标签', 'value' => 'tag'], + ['label' => '商品', 'value' => 'product'], + ['label' => '帖子', 'value' => 'topic'], + ]], + ]], + ['key' => 'features', 'title' => '搜索功能', 'fields' => [ + ['key' => 'search_history_enabled', 'label' => '搜索历史', 'type' => 'switch', 'default' => true], + ['key' => 'hot_search_enabled', 'label' => '热门搜索', 'type' => 'switch', 'default' => true], + ['key' => 'hot_search_keywords', 'label' => '热门搜索关键词', 'type' => 'textarea', 'default' => ''], + ['key' => 'search_suggestions_enabled', 'label' => '搜索建议', 'type' => 'switch', 'default' => false], + ['key' => 'search_posts', 'label' => '搜索框热门文章', 'type' => 'switch', 'default' => false, 'help' => '在搜索面板中显示热门文章列表'], + ['key' => 'search_no_page', 'label' => '排除页面', 'type' => 'switch', 'default' => false, 'help' => '搜索结果中排除页面(仅显示文章)'], + ['key' => 'search_exclude_cats', 'label' => '排除分类', 'type' => 'textarea', 'default' => '', 'help' => '搜索结果中排除的分类 ID(逗号分隔)'], + ['key' => 'search_custom_key_s', 'label' => '自定义匹配范围', 'type' => 'switch', 'default' => false, 'help' => '启用后可配置搜索匹配字段范围(标题/内容/分类/标签等)'], + ['key' => 'search_custom_key', 'label' => '匹配字段', 'type' => 'textarea', 'default' => 'title,content', 'help' => '搜索匹配的字段范围(逗号分隔:title/content/excerpt/category/tag/author)'], + ['key' => 'search_facets', 'label' => '搜索结果筛选', 'type' => 'switch', 'default' => false, 'help' => '启用搜索结果 taxonomy 筛选面板'], + ['key' => 'search_facets_order', 'label' => '筛选排序', 'type' => 'textarea', 'default' => "category\ntag", 'help' => '筛选 fieldset 显示顺序(每行一个 taxonomy slug)'], + ]], + ['key' => 'category-search', 'title' => '分类搜索', 'fields' => [ + ['key' => 'search_cat', 'label' => '启用分类搜索', 'type' => 'switch', 'default' => false, 'help' => '在搜索框旁显示分类选择器'], + ['key' => 'search_cat_in', 'label' => '默认分类', 'type' => 'input', 'default' => '', 'help' => '默认搜索的分类 ID(留空为全部分类)'], + ['key' => 'search_more_cat_obj', 'label' => '显示更多分类', 'type' => 'switch', 'default' => true, 'help' => '展开显示全部分类列表'], + ]], + ['key' => 'popular', 'title' => '热门搜索', 'fields' => [ + ['key' => 'search_popular_title', 'label' => '标题', 'type' => 'input', 'default' => '热门搜索'], + ['key' => 'search_popular_key_num', 'label' => '显示数量', 'type' => 'number', 'default' => 20, 'min' => 1, 'max' => 50], + ['key' => 'search_popular_sticky', 'label' => '置顶词(每行一个)', 'type' => 'textarea', 'default' => ''], + ]], + ['key' => 'meilisearch', 'title' => 'Meilisearch 集成', 'fields' => [ + ['key' => 'search_ms_enabled', 'label' => '启用 Meilisearch', 'type' => 'switch', 'default' => false, 'help' => '启用后搜索请求将发送至 Meilisearch 服务器'], + ['key' => 'search_ms_api_url', 'label' => 'API 地址', 'type' => 'input', 'default' => '', 'help' => 'Meilisearch 服务器 URL'], + ['key' => 'search_ms_api_key', 'label' => 'API Key', 'type' => 'input', 'default' => '', 'help' => 'Meilisearch Search API Key(只读密钥)'], + ['key' => 'search_ms_index_prefix', 'label' => '索引前缀', 'type' => 'input', 'default' => 'aksrt', 'help' => '索引名称前缀'], + ['key' => 'search_ms_synonyms', 'label' => '同义词(JSON)', 'type' => 'textarea', 'default' => '', 'help' => 'Meilisearch 同义词配置 JSON'], + ]], + ], + ], + 'email' => [ + 'key' => 'email', + 'title' => '邮件事件', + 'version' => '2.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'global', 'title' => '全局设置', 'fields' => [ + ['key' => 'email_footer_text', 'label' => '邮件页脚文字', 'type' => 'textarea', 'default' => ''], + ['key' => 'email_site_branding', 'label' => '显示站点品牌', 'type' => 'switch', 'default' => true], + ['key' => 'email_template_mode', 'label' => '邮件格式', 'type' => 'radio', 'default' => 'plain', 'options' => [ + ['label' => '纯文本', 'value' => 'plain'], + ['label' => 'HTML', 'value' => 'html'], + ]], + ['key' => 'email_private_receive_limit', 'label' => '私信邮件接收组', 'type' => 'select', 'default' => 'all', 'options' => [ + ['label' => '所有用户', 'value' => 'all'], + ['label' => 'VIP 及以上', 'value' => 'vip'], + ['label' => 'VIP2 及以上', 'value' => 'vip2'], + ['label' => '仅管理员', 'value' => 'admin'], + ], 'help' => '限制哪些用户组可以收到私信邮件通知'], + ]], + ['key' => 'branding', 'title' => '邮件品牌', 'fields' => [ + ['key' => 'mail_showname', 'label' => '发件人名称', 'type' => 'input', 'default' => '', 'help' => '覆盖系统默认发件人名称'], + ['key' => 'mail_title_prefix', 'label' => '主题前缀', 'type' => 'input', 'default' => '', 'help' => '自动添加在所有邮件主题前面,如【站点名】'], + ['key' => 'mail_description', 'label' => '邮件底部说明', 'type' => 'textarea', 'default' => '', 'help' => '附加在邮件页脚文字之后,留空不显示'], + ['key' => 'mail_more_content', 'label' => '邮件附加内容', 'type' => 'textarea', 'default' => '', 'help' => '附加在邮件内容末尾的补充说明'], + ]], + ['key' => 'reg_verify_link', 'title' => '注册验证链接', 'fields' => [ + ['key' => 'email_reg_verify_link_enabled', 'label' => '启用', 'type' => 'switch', 'default' => true], + ['key' => 'email_reg_verify_link_subject', 'label' => '主题', 'type' => 'input', 'default' => ''], + ['key' => 'email_reg_verify_link_content', 'label' => '内容模板', 'type' => 'textarea', 'default' => ''], + ['key' => 'email_reg_verify_link_html', 'label' => 'HTML 格式', 'type' => 'switch', 'default' => false], + ]], + ['key' => 'reg_verify_code', 'title' => '注册验证码', 'fields' => [ + ['key' => 'email_reg_verify_code_enabled', 'label' => '启用', 'type' => 'switch', 'default' => true], + ['key' => 'email_reg_verify_code_subject', 'label' => '主题', 'type' => 'input', 'default' => ''], + ['key' => 'email_reg_verify_code_content', 'label' => '内容模板', 'type' => 'textarea', 'default' => ''], + ['key' => 'email_reg_verify_code_html', 'label' => 'HTML 格式', 'type' => 'switch', 'default' => false], + ]], + ['key' => 'reg_success', 'title' => '注册成功通知', 'fields' => [ + ['key' => 'email_reg_success_enabled', 'label' => '启用', 'type' => 'switch', 'default' => true], + ['key' => 'email_reg_success_subject', 'label' => '主题', 'type' => 'input', 'default' => ''], + ['key' => 'email_reg_success_content', 'label' => '内容模板', 'type' => 'textarea', 'default' => ''], + ['key' => 'email_reg_success_html', 'label' => 'HTML 格式', 'type' => 'switch', 'default' => false], + ]], + ['key' => 'password_reset', 'title' => '密码重置', 'fields' => [ + ['key' => 'email_password_reset_enabled', 'label' => '启用', 'type' => 'switch', 'default' => true], + ['key' => 'email_password_reset_subject', 'label' => '主题', 'type' => 'input', 'default' => ''], + ['key' => 'email_password_reset_content', 'label' => '内容模板', 'type' => 'textarea', 'default' => ''], + ['key' => 'email_password_reset_html', 'label' => 'HTML 格式', 'type' => 'switch', 'default' => false], + ]], + ['key' => 'change_code', 'title' => '邮箱变更验证码', 'fields' => [ + ['key' => 'email_change_code_enabled', 'label' => '启用', 'type' => 'switch', 'default' => true], + ['key' => 'email_change_code_subject', 'label' => '主题', 'type' => 'input', 'default' => ''], + ['key' => 'email_change_code_content', 'label' => '内容模板', 'type' => 'textarea', 'default' => ''], + ['key' => 'email_change_code_html', 'label' => 'HTML 格式', 'type' => 'switch', 'default' => false], + ]], + ['key' => 'change_notice', 'title' => '邮箱变更通知', 'fields' => [ + ['key' => 'email_change_notice_enabled', 'label' => '启用', 'type' => 'switch', 'default' => true], + ['key' => 'email_change_notice_subject', 'label' => '主题', 'type' => 'input', 'default' => ''], + ['key' => 'email_change_notice_content', 'label' => '内容模板', 'type' => 'textarea', 'default' => ''], + ['key' => 'email_change_notice_html', 'label' => 'HTML 格式', 'type' => 'switch', 'default' => false], + ]], + ], + ], + 'custom-code' => [ + 'key' => 'custom-code', + 'title' => '自定义代码', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'css', 'title' => '自定义 CSS', 'fields' => [ + ['key' => 'custom_css', 'label' => '自定义 CSS', 'type' => 'code', 'language' => 'css', 'default' => ''], + ]], + ['key' => 'code', 'title' => '头部 & 底部代码', 'fields' => [ + ['key' => 'head_code', 'label' => '头部 HTML 代码', 'type' => 'code', 'default' => '', 'help' => '在 中输出,支持 meta/link/style/script 等任意 HTML'], + ['key' => 'foot_code', 'label' => '底部 HTML 代码', 'type' => 'code', 'default' => '', 'help' => '在 前输出,支持任意 HTML'], + ]], + ['key' => 'stats', 'title' => '统计代码', 'fields' => [ + ['key' => 'trackcode', 'label' => '统计代码', 'type' => 'code', 'default' => '', 'help' => '百度统计/GA/CNZZ/51la 等,自动在 head 中输出'], + ]], + ], + ], + 'i18n' => [ + 'key' => 'i18n', + 'title' => '多语言翻译', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'basic', 'title' => '基础配置', 'fields' => [ + ['key' => 'i18n_enabled', 'label' => '启用多语言', 'type' => 'switch', 'default' => false], + ['key' => 'i18n_default_lang', 'label' => '默认语言', 'type' => 'select', 'default' => 'zh-CN', 'options' => [ + ['label' => '简体中文', 'value' => 'zh-CN'], + ['label' => 'English', 'value' => 'en'], + ['label' => '繁體中文', 'value' => 'zh-TW'], + ['label' => '日本語', 'value' => 'ja'], + ['label' => '한국어', 'value' => 'ko'], + ['label' => 'Français', 'value' => 'fr'], + ['label' => 'Deutsch', 'value' => 'de'], + ['label' => 'Español', 'value' => 'es'], + ['label' => 'Русский', 'value' => 'ru'], + ]], + ['key' => 'i18n_auto_detect', 'label' => '自动检测浏览器语言', 'type' => 'switch', 'default' => true, 'help' => '基于浏览器 Accept-Language 自动切换(AKSRT 方案,区别于 zibll IP 定位方案)'], + ]], + ['key' => 'languages', 'title' => '语言管理', 'fields' => [ + ['key' => 'i18n_available_langs', 'label' => '可用语言列表', 'type' => 'textarea', 'default' => "zh-CN|简体中文\nen|English\nzh-TW|繁體中文\nja|日本語\nko|한국어\nfr|Français\nde|Deutsch\nes|Español\nru|Русский", 'help' => '每行一个语言,格式:代码|名称(9 种内置语言)'], + ['key' => 'i18n_nomenclature', 'label' => '自定义术语', 'type' => 'code', 'default' => '', 'help' => 'JSON 格式自定义翻译术语(key => 翻译),对齐 zibll nomenclature'], + ]], + ['key' => 'display', 'title' => '展示设置', 'fields' => [ + ['key' => 'i18n_switcher_position', 'label' => '语言切换器位置', 'type' => 'radio', 'default' => 'footer', 'options' => [ + ['label' => '导航栏', 'value' => 'header'], + ['label' => '页脚', 'value' => 'footer'], + ['label' => '导航栏+页脚', 'value' => 'both'], + ['label' => '隐藏', 'value' => 'none'], + ]], + ['key' => 'i18n_url_strategy', 'label' => 'URL 策略', 'type' => 'radio', 'default' => 'query', 'options' => [ + ['label' => '查询参数', 'value' => 'query'], + ['label' => '路径前缀', 'value' => 'path'], + ['label' => '子域名', 'value' => 'subdomain'], + ]], + ]], + ], + ], + ], + ]; + } +} diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-page-display.php b/inc/classes/admin/schema/class-aksrt-admin-schema-page-display.php new file mode 100644 index 00000000..7204a61c --- /dev/null +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-page-display.php @@ -0,0 +1,530 @@ + 'page_display', + 'title' => '页面&显示', + 'sections' => [ + 'header-nav' => [ + 'key' => 'header-nav', + 'title' => '顶部导航栏', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'layout', 'title' => '导航栏布局', 'fields' => [ + ['key' => 'header_fixed', 'label' => '固定导航栏', 'type' => 'switch', 'default' => true], + ['key' => 'header_nav_style', 'label' => '导航栏风格', 'type' => 'radio', 'default' => 'default', 'options' => [ + ['label' => '默认', 'value' => 'default'], + ['label' => '透明', 'value' => 'transparent'], + ['label' => '毛玻璃', 'value' => 'blur'], + ]], + ['key' => 'header_max_width', 'label' => '导航栏最大宽度', 'type' => 'number', 'default' => 1200, 'min' => 0, 'max' => 1600], + ['key' => 'header_scroll_down_hide', 'label' => '下滑隐藏导航栏', 'type' => 'switch', 'default' => false], + ]], + ['key' => 'components', 'title' => '导航组件', 'fields' => [ + ['key' => 'header_search_enabled', 'label' => '搜索入口', 'type' => 'switch', 'default' => true], + ['key' => 'header_user_menu', 'label' => '用户菜单', 'type' => 'switch', 'default' => true], + ['key' => 'header_publish_btn', 'label' => '发布按钮', 'type' => 'switch', 'default' => true], + ['key' => 'header_mobile_drawer', 'label' => '移动端抽屉导航', 'type' => 'switch', 'default' => true], + ]], + ['key' => 'newposts', 'title' => '新文章提示', 'fields' => [ + ['key' => 'header_newposts_enabled', 'label' => '显示新文章提示', 'type' => 'switch', 'default' => true], + ['key' => 'header_newposts_text', 'label' => '提示文案', 'type' => 'input', 'default' => '有新文章'], + ['key' => 'header_newposts_radius', 'label' => '圆角', 'type' => 'number', 'default' => 12, 'min' => 0, 'max' => 30], + ['key' => 'header_newposts_class', 'label' => '样式类', 'type' => 'input', 'default' => ''], + ]], + ['key' => 'color', 'title' => '颜色设置', 'fields' => [ + ['key' => 'header_theme_custom', 'label' => '自定义导航栏颜色', 'type' => 'switch', 'default' => false], + ['key' => 'header_theme_bg_custom', 'label' => '自定义背景色', 'type' => 'color', 'default' => ''], + ['key' => 'header_theme_bg', 'label' => '背景颜色(备选)', 'type' => 'color', 'default' => ''], + ['key' => 'header_theme_color_custom', 'label' => '自定义文字颜色', 'type' => 'color', 'default' => ''], + ]], + ['key' => 'layout-type', 'title' => '布局类型', 'fields' => [ + ['key' => 'header_layout', 'label' => '桌面端布局', 'type' => 'radio', 'default' => '2', 'options' => [ + ['label' => '布局 1(经典)', 'value' => '1'], + ['label' => '布局 2(内联搜索)', 'value' => '2'], + ['label' => '布局 3(紧凑)', 'value' => '3'], + ]], + ['key' => 'mobile_header_layout', 'label' => '移动端布局', 'type' => 'radio', 'default' => 'center', 'options' => [ + ['label' => '居中(含搜索按钮)', 'value' => 'center'], + ['label' => '靠左', 'value' => 'left'], + ]], + ['key' => 'mobile_navbar_align', 'label' => '移动端菜单方向', 'type' => 'radio', 'default' => 'left', 'options' => [ + ['label' => '顶部(关闭手势)', 'value' => 'top'], + ['label' => '左侧滑出', 'value' => 'left'], + ['label' => '右侧滑出', 'value' => 'right'], + ]], + ]], + ], + ], + 'header-components' => [ + 'key' => 'header-components', + 'title' => '顶部多功能组件', + 'version' => '1.1.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'visibility', 'title' => '显示规则', 'fields' => [ + ['key' => 'header_components_search_enabled', 'label' => '启用搜索覆盖层', 'type' => 'switch', 'default' => true], + ['key' => 'header_components_cards_enabled', 'label' => '启用图标卡片', 'type' => 'switch', 'default' => false], + ['key' => 'header_components_show_on', 'label' => '按页面类型启用', 'type' => 'checkbox', 'default' => ['home'], 'options' => [ + ['label' => '首页', 'value' => 'home'], + ['label' => '文章', 'value' => 'single'], + ['label' => '页面', 'value' => 'page'], + ['label' => '分类', 'value' => 'category'], + ['label' => '标签', 'value' => 'tag'], + ['label' => '搜索', 'value' => 'search'], + ['label' => '归档', 'value' => 'archive'], + ['label' => '专题', 'value' => 'special'], + ]], + ['key' => 'header_components_device_visibility', 'label' => '设备显示规则', 'type' => 'radio', 'default' => '', 'options' => [ + ['label' => '全部', 'value' => ''], + ['label' => '仅电脑', 'value' => 'only_pc'], + ['label' => '仅移动', 'value' => 'only_sm'], + ]], + ]], + ['key' => 'nav-color', 'title' => '导航栏配色', 'fields' => [ + ['key' => 'header_components_nav_color', 'label' => '导航栏文字颜色', 'type' => 'color', 'default' => '#ffffff'], + ]], + ['key' => 'slider-config', 'title' => '幻灯片配置', 'fields' => [ + ['key' => 'slider_config_direction', 'label' => '滑动方向', 'type' => 'radio', 'default' => 'horizontal', 'options' => [ + ['label' => '水平', 'value' => 'horizontal'], + ['label' => '垂直', 'value' => 'vertical'], + ]], + ['key' => 'slider_config_speed', 'label' => '切换速度(ms)', 'type' => 'number', 'default' => 300, 'min' => 100, 'max' => 3000], + ['key' => 'slider_config_button', 'label' => '显示前进后退按钮', 'type' => 'switch', 'default' => true], + ['key' => 'slider_config_scale_height', 'label' => '缩放高度', 'type' => 'switch', 'default' => false], + ['key' => 'slider_config_scale', 'label' => '缩放效果', 'type' => 'switch', 'default' => false], + ['key' => 'slider_config_max_height', 'label' => '最大高度', 'type' => 'input', 'default' => ''], + ['key' => 'slider_config_min_height', 'label' => '最小高度', 'type' => 'input', 'default' => ''], + ]], + ['key' => 'search', 'title' => '搜索组件', 'fields' => [ + ['key' => 'header_components_search_pc', 'label' => 'PC 端显示搜索', 'type' => 'switch', 'default' => true], + ['key' => 'header_components_search_mobile', 'label' => '移动端显示搜索', 'type' => 'switch', 'default' => true], + ['key' => 'header_components_search_show_keywords', 'label' => '显示热门搜索词', 'type' => 'switch', 'default' => false], + ['key' => 'header_components_search_popular_limit', 'label' => '热词数量', 'type' => 'number', 'default' => 5, 'min' => 1, 'max' => 20], + ['key' => 'header_components_search_show_input_cat', 'label' => '显示分类搜索', 'type' => 'switch', 'default' => false], + ['key' => 'header_components_search_show_type', 'label' => '显示类型切换', 'type' => 'switch', 'default' => false], + ]], + ['key' => 'cards', 'title' => '图标卡片样式', 'fields' => [ + ['key' => 'header_components_filter_blur_devices', 'label' => '容器模糊设备', 'type' => 'checkbox', 'default' => [], 'options' => [ + ['label' => 'PC', 'value' => 'pc'], + ['label' => '移动端', 'value' => 'mobile'], + ]], + ['key' => 'header_components_card_show_devices', 'label' => '卡片显示设备', 'type' => 'checkbox', 'default' => ['pc', 'mobile'], 'options' => [ + ['label' => 'PC', 'value' => 'pc'], + ['label' => '移动端', 'value' => 'mobile'], + ]], + ['key' => 'header_components_card_shared_bg', 'label' => '共享卡片背景', 'type' => 'switch', 'default' => false], + ['key' => 'header_components_card_blur', 'label' => '启用卡片模糊效果', 'type' => 'switch', 'default' => true], + ['key' => 'header_components_card_icon_shape', 'label' => '图标形状', 'type' => 'radio', 'default' => 'circle', 'options' => [ + ['label' => '圆形', 'value' => 'circle'], + ['label' => '方形', 'value' => 'square'], + ]], + ]], + ['key' => 'icon-cards', 'title' => '图标卡片列表', 'fields' => [ + ['key' => 'header_components_icon_cards', 'label' => '图标卡片', 'type' => 'repeater', 'sanitize_type' => 'array', 'default' => []], + ]], + ], + ], + 'category' => [ + 'key' => 'category', + 'title' => '分类页面', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'header', 'title' => '分类头图', 'fields' => [ + ['key' => 'category_header_style', 'label' => '头部风格', 'type' => 'radio', 'default' => 'cover', 'options' => [ + ['label' => '封面图', 'value' => 'cover'], + ['label' => '简洁标题', 'value' => 'simple'], + ['label' => '隐藏', 'value' => 'none'], + ]], + ['key' => 'category_cover_enabled', 'label' => '显示封面图', 'type' => 'switch', 'default' => true], + ['key' => 'category_desc_enabled', 'label' => '显示分类简介', 'type' => 'switch', 'default' => true], + ['key' => 'category_default_cover', 'label' => '默认封面图', 'type' => 'media', 'default' => ''], + ]], + ['key' => 'list', 'title' => '列表设置', 'fields' => [ + ['key' => 'category_list_mode', 'label' => '列表模式', 'type' => 'radio', 'default' => 'grid', 'options' => [ + ['label' => '网格', 'value' => 'grid'], + ['label' => '列表', 'value' => 'list'], + ['label' => '紧凑', 'value' => 'compact'], + ]], + ['key' => 'category_per_page', 'label' => '每页文章数', 'type' => 'number', 'default' => 12, 'min' => 4, 'max' => 48], + ['key' => 'category_filter_bar', 'label' => '筛选条', 'type' => 'switch', 'default' => true], + ['key' => 'category_sidebar', 'label' => '侧边栏', 'type' => 'switch', 'default' => true], + ['key' => 'category_post_count', 'label' => '显示文章数', 'type' => 'switch', 'default' => true], + ]], + ['key' => 'orderby', 'title' => '排序设置', 'fields' => [ + ['key' => 'category_orderby', 'label' => '默认排序', 'type' => 'select', 'default' => 'modified', 'options' => [ + ['label' => '最近更新', 'value' => 'modified'], + ['label' => '发布时间', 'value' => 'date'], + ['label' => '浏览数', 'value' => 'views'], + ['label' => '评论数', 'value' => 'comments'], + ['label' => '点赞数', 'value' => 'likes'], + ]], + ]], + ], + ], + 'tag' => [ + 'key' => 'tag', + 'title' => '标签页面', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'header', 'title' => '标签头图', 'fields' => [ + ['key' => 'tag_header_style', 'label' => '头部风格', 'type' => 'radio', 'default' => 'simple', 'options' => [ + ['label' => '简洁标题', 'value' => 'simple'], + ['label' => '封面图', 'value' => 'cover'], + ['label' => '隐藏', 'value' => 'none'], + ]], + ['key' => 'tag_default_cover', 'label' => '默认封面图', 'type' => 'media', 'default' => ''], + ]], + ['key' => 'list', 'title' => '列表设置', 'fields' => [ + ['key' => 'tag_list_mode', 'label' => '列表模式', 'type' => 'radio', 'default' => 'grid', 'options' => [ + ['label' => '网格', 'value' => 'grid'], + ['label' => '列表', 'value' => 'list'], + ]], + ['key' => 'tag_per_page', 'label' => '每页文章数', 'type' => 'number', 'default' => 12, 'min' => 4, 'max' => 48], + ['key' => 'tag_sidebar', 'label' => '侧边栏', 'type' => 'switch', 'default' => true], + ['key' => 'tag_post_count', 'label' => '显示文章数', 'type' => 'switch', 'default' => true], + ]], + ['key' => 'orderby', 'title' => '排序设置', 'fields' => [ + ['key' => 'tag_orderby', 'label' => '默认排序', 'type' => 'select', 'default' => 'count', 'options' => [ + ['label' => '文章数', 'value' => 'count'], + ['label' => '名称', 'value' => 'name'], + ['label' => '最近更新', 'value' => 'modified'], + ]], + ]], + ], + ], + 'user-home' => [ + 'key' => 'user-home', + 'title' => '用户主页', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'layout', 'title' => '主页布局', 'fields' => [ + ['key' => 'user_home_cover', 'label' => '封面区域', 'type' => 'switch', 'default' => true], + ['key' => 'user_home_sidebar', 'label' => '侧边栏', 'type' => 'switch', 'default' => true], + ['key' => 'user_home_follow', 'label' => '关注按钮', 'type' => 'switch', 'default' => true], + ['key' => 'user_home_posts_per_page', 'label' => '每页文章数', 'type' => 'number', 'default' => 10, 'min' => 5, 'max' => 50], + ['key' => 'user_home_paging_ajax', 'label' => 'AJAX 翻页', 'type' => 'switch', 'default' => true], + ]], + ['key' => 'tabs', 'title' => 'Tab 配置', 'fields' => [ + ['key' => 'user_home_tabs', 'label' => '显示的 Tab', 'type' => 'input', 'default' => 'posts,comments,favorites,credits,product,plate,forum,follow'], + ['key' => 'user_home_tabs_order', 'label' => 'Tab 显示顺序', 'type' => 'input', 'default' => 'posts,comments,favorites,credits,product,plate,forum,follow'], + ['key' => 'user_home_tabs_pc_hide', 'label' => 'PC 端隐藏', 'type' => 'input', 'default' => ''], + ['key' => 'user_home_tabs_mobile_hide', 'label' => '移动端隐藏', 'type' => 'input', 'default' => ''], + ['key' => 'author_main_tab_swiper', 'label' => '移动端滑动切换', 'type' => 'switch', 'default' => false], + ]], + ['key' => 'display', 'title' => '展示设置', 'fields' => [ + ['key' => 'user_home_avatar', 'label' => '显示头像', 'type' => 'switch', 'default' => true], + ['key' => 'user_home_name', 'label' => '显示用户名', 'type' => 'switch', 'default' => true], + ['key' => 'user_home_desc', 'label' => '显示个人简介', 'type' => 'switch', 'default' => true], + ['key' => 'user_home_stats', 'label' => '显示统计数据', 'type' => 'switch', 'default' => true], + ['key' => 'user_home_social', 'label' => '显示社交链接', 'type' => 'switch', 'default' => true], + ['key' => 'user_home_message', 'label' => '私信按钮', 'type' => 'switch', 'default' => true], + ['key' => 'user_home_private', 'label' => '留言按钮', 'type' => 'switch', 'default' => true], + ]], + ], + ], + 'other' => [ + 'key' => 'other', + 'title' => '其它页面', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'page-header', 'title' => '统一页头', 'fields' => [ + ['key' => 'page_header_style', 'label' => '页面标题样式', 'type' => 'select', 'default' => '1', 'options' => [ + ['label' => '不显示', 'value' => ''], + ['label' => '简单', 'value' => '1'], + ['label' => '卡片', 'value' => '2'], + ['label' => '封面', 'value' => '3'], + ]], + ['key' => 'page_header_cover_img', 'label' => '默认封面图', 'type' => 'media', 'default' => ''], + ]], + ['key' => 'page_404', 'title' => '404 页面', 'fields' => [ + ['key' => 'page_404_message', 'label' => '404 提示文字', 'type' => 'input', 'default' => '页面未找到'], + ['key' => 'page_404_search', 'label' => '显示搜索框', 'type' => 'switch', 'default' => true], + ['key' => 'page_404_popular', 'label' => '显示热门文章', 'type' => 'switch', 'default' => true], + ['key' => 'page_404_back', 'label' => '显示返回首页', 'type' => 'switch', 'default' => true], + ]], + ['key' => 'search_results', 'title' => '搜索结果页', 'fields' => [ + ['key' => 'page_search_sidebar', 'label' => '搜索结果侧边栏', 'type' => 'switch', 'default' => true], + ['key' => 'page_search_per_page', 'label' => '每页结果数', 'type' => 'number', 'default' => 12, 'min' => 4, 'max' => 48], + ]], + ['key' => 'author', 'title' => '作者页面', 'fields' => [ + ['key' => 'page_author_cover', 'label' => '显示封面', 'type' => 'switch', 'default' => true], + ['key' => 'page_author_sidebar', 'label' => '显示侧边栏', 'type' => 'switch', 'default' => true], + ['key' => 'page_author_posts_per_page', 'label' => '每页文章数', 'type' => 'number', 'default' => 12, 'min' => 4, 'max' => 48], + ]], + ], + ], + 'special' => [ + 'key' => 'special', + 'title' => '专题页面', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'header', 'title' => '专题头部', 'fields' => [ + ['key' => 'special_cover_enabled', 'label' => '显示专题封面', 'type' => 'switch', 'default' => true], + ['key' => 'special_summary_enabled', 'label' => '显示专题摘要', 'type' => 'switch', 'default' => true], + ['key' => 'special_summary_length', 'label' => '摘要最大长度', 'type' => 'number', 'default' => 200, 'min' => 50, 'max' => 500], + ['key' => 'special_default_cover', 'label' => '默认封面图', 'type' => 'media', 'default' => ''], + ]], + ['key' => 'layout', 'title' => '布局设置', 'fields' => [ + ['key' => 'special_layout', 'label' => '内容布局', 'type' => 'radio', 'default' => 'grid', 'options' => [ + ['label' => '网格', 'value' => 'grid'], + ['label' => '列表', 'value' => 'list'], + ['label' => '瀑布流', 'value' => 'masonry'], + ]], + ['key' => 'special_columns', 'label' => '网格列数', 'type' => 'number', 'default' => 2, 'min' => 1, 'max' => 4], + ['key' => 'special_sidebar', 'label' => '侧边栏', 'type' => 'switch', 'default' => true], + ['key' => 'special_post_count', 'label' => '显示文章数', 'type' => 'switch', 'default' => false], + ]], + ['key' => 'aggregation', 'title' => '聚合规则', 'fields' => [ + ['key' => 'special_source_type', 'label' => '内容来源', 'type' => 'radio', 'default' => 'category', 'options' => [ + ['label' => '分类', 'value' => 'category'], + ['label' => '标签', 'value' => 'tag'], + ['label' => '手动选择', 'value' => 'manual'], + ['label' => '最新文章', 'value' => 'latest'], + ]], + ['key' => 'special_source_ids', 'label' => '来源 ID 列表', 'type' => 'input', 'default' => ''], + ]], + ['key' => 'subpage', 'title' => '子页面展示', 'fields' => [ + ['key' => 'special_subpage_display', 'label' => '子页面样式', 'type' => 'radio', 'default' => 'cards', 'options' => [ + ['label' => '卡片', 'value' => 'cards'], + ['label' => '列表', 'value' => 'list'], + ['label' => '时间线', 'value' => 'timeline'], + ]], + ['key' => 'special_subpage_count', 'label' => '每页子页面数', 'type' => 'number', 'default' => 6, 'min' => 1, 'max' => 24], + ]], + ['key' => 'pagination', 'title' => '分页设置', 'fields' => [ + ['key' => 'special_pagination', 'label' => '分页模式', 'type' => 'radio', 'default' => 'numeric', 'options' => [ + ['label' => '数字分页', 'value' => 'numeric'], + ['label' => '加载更多', 'value' => 'load_more'], + ['label' => '无限滚动', 'value' => 'infinite'], + ]], + ['key' => 'special_per_page', 'label' => '每页文章数', 'type' => 'number', 'default' => 12, 'min' => 4, 'max' => 48], + ]], + ['key' => 'orderby', 'title' => '排序设置', 'fields' => [ + ['key' => 'special_orderby', 'label' => '默认排序', 'type' => 'select', 'default' => 'date', 'options' => [ + ['label' => '发布时间', 'value' => 'date'], + ['label' => '最近更新', 'value' => 'modified'], + ['label' => '浏览数', 'value' => 'views'], + ['label' => '评论数', 'value' => 'comments'], + ]], + ]], + ], + ], + 'background' => [ + 'key' => 'background', + 'title' => '全局背景', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'light', 'title' => '亮色背景', 'fields' => [ + ['key' => 'bg_color', 'label' => '背景颜色', 'type' => 'color', 'default' => ''], + ['key' => 'bg_image', 'label' => '背景图片', 'type' => 'media', 'default' => ''], + ['key' => 'bg_size', 'label' => '图片尺寸', 'type' => 'select', 'default' => 'cover', 'options' => [ + ['label' => '覆盖', 'value' => 'cover'], + ['label' => '包含', 'value' => 'contain'], + ['label' => '原始大小', 'value' => 'auto'], + ['label' => '拉伸填充', 'value' => '100% 100%'], + ['label' => '100% 宽度(旧版兼容)', 'value' => '100%'], + ]], + ['key' => 'bg_position', 'label' => '图片位置', 'type' => 'select', 'default' => 'center center', 'options' => [ + ['label' => '居中', 'value' => 'center center'], + ['label' => '顶部居中', 'value' => 'top center'], + ['label' => '底部居中', 'value' => 'bottom center'], + ]], + ['key' => 'bg_repeat', 'label' => '图片重复', 'type' => 'select', 'default' => 'no-repeat', 'options' => [ + ['label' => '不重复', 'value' => 'no-repeat'], + ['label' => '平铺', 'value' => 'repeat'], + ['label' => '水平平铺', 'value' => 'repeat-x'], + ['label' => '垂直平铺', 'value' => 'repeat-y'], + ]], + ['key' => 'bg_attachment', 'label' => '固定方式', 'type' => 'select', 'default' => 'fixed', 'options' => [ + ['label' => '固定', 'value' => 'fixed'], + ['label' => '随内容滚动', 'value' => 'scroll'], + ]], + ]], + ['key' => 'dark', 'title' => '暗色背景', 'fields' => [ + ['key' => 'bg_dark_color', 'label' => '暗色背景颜色', 'type' => 'color', 'default' => ''], + ['key' => 'bg_dark_image', 'label' => '暗色背景图片', 'type' => 'media', 'default' => ''], + ]], + ['key' => 'overlay', 'title' => '遮罩设置', 'fields' => [ + ['key' => 'bg_overlay', 'label' => '启用背景遮罩', 'type' => 'switch', 'default' => false], + ['key' => 'bg_overlay_color', 'label' => '遮罩颜色', 'type' => 'input', 'default' => 'rgba(0, 0, 0, 0.3)'], + ]], + ['key' => 'page', 'title' => '页面背景', 'fields' => [ + ['key' => 'page_bg_color', 'label' => '内容区背景色', 'type' => 'color', 'default' => ''], + ['key' => 'page_bg_image', 'label' => '内容区背景图', 'type' => 'media', 'default' => ''], + ]], + ], + ], + 'menu-decoration' => [ + 'key' => 'menu-decoration', + 'title' => '菜单装饰', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'icon', 'title' => '菜单图标', 'fields' => [ + ['key' => 'menu_icon_enabled', 'label' => '显示菜单图标', 'type' => 'switch', 'default' => true], + ['key' => 'menu_badge_enabled', 'label' => '显示菜单徽章', 'type' => 'switch', 'default' => true], + ]], + ['key' => 'submenu', 'title' => '下拉子菜单', 'fields' => [ + ['key' => 'menu_submenu_style', 'label' => '子菜单样式', 'type' => 'radio', 'default' => 'dropdown', 'options' => [ + ['label' => '普通下拉', 'value' => 'dropdown'], + ['label' => '多列下拉', 'value' => 'mega'], + ['label' => '图文卡片', 'value' => 'cards'], + ]], + ['key' => 'menu_submenu_columns', 'label' => '多列列数', 'type' => 'number', 'default' => 1, 'min' => 1, 'max' => 5], + ['key' => 'menu_submenu_width', 'label' => '子菜单宽度 (px)', 'type' => 'number', 'default' => 220, 'min' => 160, 'max' => 800], + ]], + ['key' => 'effect', 'title' => '交互效果', 'fields' => [ + ['key' => 'menu_hover_effect', 'label' => 'Hover 效果', 'type' => 'radio', 'default' => 'underline', 'options' => [ + ['label' => '下划线', 'value' => 'underline'], + ['label' => '背景色', 'value' => 'background'], + ['label' => '文字变色', 'value' => 'color'], + ['label' => '无效果', 'value' => 'none'], + ]], + ['key' => 'menu_item_padding', 'label' => '菜单项内边距 (px)', 'type' => 'number', 'default' => 12, 'min' => 4, 'max' => 32], + ]], + ['key' => 'mobile', 'title' => '移动端菜单', 'fields' => [ + ['key' => 'menu_mobile_style', 'label' => '移动端菜单样式', 'type' => 'radio', 'default' => 'drawer', 'options' => [ + ['label' => '抽屉', 'value' => 'drawer'], + ['label' => '全屏', 'value' => 'fullscreen'], + ['label' => '下拉', 'value' => 'dropdown'], + ]], + ]], + ], + ], + 'footer' => [ + 'key' => 'footer', + 'title' => '底部页脚', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'layout', 'title' => '页脚布局', 'fields' => [ + ['key' => 'footer_columns', 'label' => '页脚列数', 'type' => 'number', 'default' => 3, 'min' => 1, 'max' => 4], + ['key' => 'footer_copyright', 'label' => '显示版权信息', 'type' => 'switch', 'default' => true], + ['key' => 'footer_icp', 'label' => '显示ICP备案', 'type' => 'switch', 'default' => true], + ['key' => 'icp_number', 'label' => 'ICP备案号', 'type' => 'input', 'default' => '', 'help' => '例如:ICP 备 xxxxxxxx 号'], + ['key' => 'footer_police', 'label' => '显示公安备案', 'type' => 'switch', 'default' => true], + ['key' => 'police_number', 'label' => '公安备案号', 'type' => 'input', 'default' => '', 'help' => '例如:公网安备 xxxxxx 号'], + ]], + ['key' => 'color', 'title' => '页脚颜色', 'fields' => [ + ['key' => 'footer_theme_custom', 'label' => '自定义页脚颜色', 'type' => 'switch', 'default' => false], + ['key' => 'footer_theme_bg_custom', 'label' => '自定义背景色', 'type' => 'color', 'default' => ''], + ['key' => 'footer_theme_bg', 'label' => '背景图片', 'type' => 'media', 'default' => ''], + ['key' => 'footer_theme_color_custom', 'label' => '自定义文字颜色', 'type' => 'color', 'default' => ''], + ]], + ['key' => 'contact', 'title' => '联系方式', 'fields' => [ + ['key' => 'footer_contact_wechat_img', 'label' => '微信二维码', 'type' => 'media', 'default' => ''], + ['key' => 'footer_contact_qq', 'label' => 'QQ', 'type' => 'input', 'default' => ''], + ['key' => 'footer_contact_weibo', 'label' => '微博', 'type' => 'input', 'default' => ''], + ['key' => 'footer_contact_email', 'label' => '邮箱', 'type' => 'input', 'default' => ''], + ]], + ['key' => 'brand', 'title' => '品牌列(第一列)', 'fields' => [ + ['key' => 'footer_t1_m_s', 'label' => '显示设备', 'type' => 'checkbox', 'default' => ['pc', 'mobile'], 'options' => [ + ['label' => 'PC', 'value' => 'pc'], + ['label' => '移动端', 'value' => 'mobile'], + ]], + ['key' => 'footer_t1_img', 'label' => '页脚 Logo(日间)', 'type' => 'media', 'default' => ''], + ['key' => 'footer_t1_img_dark', 'label' => '页脚 Logo(夜间)', 'type' => 'media', 'default' => ''], + ['key' => 'footer_t1_t', 'label' => '品牌标题', 'type' => 'input', 'default' => ''], + ['key' => 'fcode_t1_code', 'label' => '品牌列自定义代码', 'type' => 'textarea', 'default' => ''], + ]], + ['key' => 'contact_ext', 'title' => '联系方式扩展', 'fields' => [ + ['key' => 'footer_contact_m_s', 'label' => '显示设备', 'type' => 'checkbox', 'default' => ['pc', 'mobile'], 'options' => [ + ['label' => 'PC', 'value' => 'pc'], + ['label' => '移动端', 'value' => 'mobile'], + ]], + ['key' => 'footer_contact_more', 'label' => '更多联系项', 'type' => 'textarea', 'default' => '', 'help' => 'JSON 数组,每项含 desc/icon/link/img'], + ['key' => 'fcode_t2_code_1', 'label' => '联系区上方自定义代码', 'type' => 'textarea', 'default' => ''], + ['key' => 'fcode_t2_code_2', 'label' => '联系区下方自定义代码(备案等)', 'type' => 'textarea', 'default' => ''], + ]], + ['key' => 'mini_qr', 'title' => '二维码图片组(第三列)', 'fields' => [ + ['key' => 'footer_mini_img_m_s', 'label' => '显示设备', 'type' => 'checkbox', 'default' => ['pc', 'mobile'], 'options' => [ + ['label' => 'PC', 'value' => 'pc'], + ['label' => '移动端', 'value' => 'mobile'], + ]], + ['key' => 'footer_mini_img', 'label' => '二维码组', 'type' => 'textarea', 'default' => '', 'help' => 'JSON 数组,每项含 text/img(最多4个)'], + ]], + ['key' => 'custom', 'title' => '底部自定义', 'fields' => [ + ['key' => 'fcode_customize_code', 'label' => '底部自定义代码', 'type' => 'textarea', 'default' => ''], + ]], + ], + ], + 'tabbar' => [ + 'key' => 'tabbar', + 'title' => '底部Tab栏', + 'version' => '1.1.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'basic', 'title' => 'Tab栏设置', 'fields' => [ + ['key' => 'footer_tabbar_s', 'label' => '启用底部Tab栏', 'type' => 'switch', 'default' => true], + ['key' => 'footer_tabbar_scroll_hide', 'label' => '滚动时隐藏', 'type' => 'switch', 'default' => false], + ['key' => 'footer_tabbar_scroll_down_hide', 'label' => '下滑时隐藏', 'type' => 'switch', 'default' => true], + ['key' => 'footer_tabbar_max_items', 'label' => '最大Tab数', 'type' => 'number', 'default' => 5, 'min' => 3, 'max' => 7], + ['key' => 'footer_tabbar_icon_size', 'label' => '图标大小', 'type' => 'number', 'default' => 22, 'min' => 16, 'max' => 32], + ['key' => 'footer_tabbar_text_size', 'label' => '文字大小', 'type' => 'number', 'default' => 10, 'min' => 8, 'max' => 14], + ['key' => 'footer_tabbar_show_text', 'label' => '显示文字', 'type' => 'switch', 'default' => true], + ['key' => 'footer_tabbar_active_color', 'label' => '选中颜色', 'type' => 'color', 'default' => '#f04494'], + ['key' => 'footer_tabbar_bg_color', 'label' => '背景颜色', 'type' => 'color', 'default' => ''], + ['key' => 'footer_tabbar_border', 'label' => '显示上边框', 'type' => 'switch', 'default' => true], + ['key' => 'footer_tabbar_shadow', 'label' => '显示阴影', 'type' => 'switch', 'default' => true], + ['key' => 'footer_tabbar_animation', 'label' => '切换动画', 'type' => 'switch', 'default' => true], + ['key' => 'footer_tabbar_double_click_home', 'label' => '双击首页刷新', 'type' => 'switch', 'default' => true], + ['key' => 'footer_tabbar_badge_dot', 'label' => '徽标圆点样式', 'type' => 'switch', 'default' => false], + ['key' => 'footer_tabbar_single', 'label' => '文章页专属Tab', 'type' => 'select', 'default' => 'default', 'options' => [ + ['label' => '默认按钮', 'value' => 'default'], + ['label' => '文章扩展按钮(评论/收藏/分享)', 'value' => 'extend'], + ], 'help' => '文章详情页底部显示的Tab按钮类型'], + ]], + ], + ], + 'float-btn' => [ + 'key' => 'float-btn', + 'title' => '悬浮按钮', + 'version' => '1.1.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'basic', 'title' => '悬浮按钮设置', 'fields' => [ + ['key' => 'float_btn_style', 'label' => '按钮风格', 'type' => 'radio', 'default' => 'round', 'options' => [ + ['label' => '圆形', 'value' => 'round'], + ['label' => '方形', 'value' => 'square'], + ]], + ['key' => 'float_btn_position', 'label' => '按钮位置', 'type' => 'radio', 'default' => 'bottom', 'options' => [ + ['label' => '底部', 'value' => 'bottom'], + ['label' => '居中', 'value' => 'center'], + ]], + ['key' => 'float_btn_scroll_hide', 'label' => '滚动时隐藏', 'type' => 'switch', 'default' => false], + ['key' => 'float_btn_scroll_down_hide', 'label' => '下滑时隐藏', 'type' => 'switch', 'default' => true], + ['key' => 'float_btn_max', 'label' => '最大显示数', 'type' => 'number', 'default' => 5, 'min' => 1, 'max' => 10], + ['key' => 'float_btn_size', 'label' => '按钮尺寸', 'type' => 'number', 'default' => 44, 'min' => 32, 'max' => 64], + ['key' => 'float_btn_spacing', 'label' => '按钮间距', 'type' => 'number', 'default' => 10, 'min' => 4, 'max' => 24], + ['key' => 'float_btn_shadow', 'label' => '显示阴影', 'type' => 'switch', 'default' => true], + ['key' => 'float_btn_animation', 'label' => '悬停动画', 'type' => 'switch', 'default' => true], + ['key' => 'float_btn_opacity', 'label' => '按钮透明度', 'type' => 'number', 'default' => 100, 'min' => 30, 'max' => 100], + ['key' => 'float_btn_active_color', 'label' => '激活颜色', 'type' => 'color', 'default' => ''], + ['key' => 'float_btn_bg_color', 'label' => '背景颜色', 'type' => 'color', 'default' => ''], + ['key' => 'float_btn_pc_s', 'label' => 'PC端显示', 'type' => 'switch', 'default' => true], + ['key' => 'float_btn_m_s', 'label' => '移动端显示', 'type' => 'switch', 'default' => true], + ['key' => 'float_btn_filter_css', 'label' => '过滤CSS', 'type' => 'textarea', 'default' => ''], + ]], + ], + ], + ], + ]; + } +} diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-payment.php b/inc/classes/admin/schema/class-aksrt-admin-schema-payment.php new file mode 100644 index 00000000..462a5bbd --- /dev/null +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-payment.php @@ -0,0 +1,374 @@ + 'payment', + 'title' => '支付&付费', + 'sections' => [ + 'paid' => [ + 'key' => 'paid', + 'title' => '下载/付费内容中心', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'strategy', 'title' => '付费内容策略', 'fields' => [ + ['key' => 'payment_paid_post_types', 'label' => '受管内容类型', 'type' => 'input', 'default' => 'post'], + ['key' => 'payment_vip_free_download', 'label' => '角色白名单免扣下载', 'type' => 'switch', 'default' => false], + ['key' => 'payment_vip_exempt_roles', 'label' => '免扣角色列表', 'type' => 'input', 'default' => ''], + ]], + ['key' => 'delivery', 'title' => '交付说明', 'fields' => [ + ['key' => 'payment_paid_catalog_note', 'label' => '前台付费提示', 'type' => 'textarea', 'default' => ''], + ['key' => 'payment_manual_delivery_note', 'label' => '人工交付备注', 'type' => 'textarea', 'default' => ''], + ]], + ['key' => 'display', 'title' => '展示设置', 'fields' => [ + ['key' => 'payment_paid_mark', 'label' => '付费标记', 'type' => 'input', 'default' => '付费'], + ['key' => 'payment_currency_symbol', 'label' => '货币符号', 'type' => 'input', 'default' => '¥'], + ['key' => 'payment_paid_show_user', 'label' => '显示购买用户', 'type' => 'switch', 'default' => true], + ['key' => 'payment_paid_show_paycount', 'label' => '显示购买次数', 'type' => 'switch', 'default' => true], + ['key' => 'payment_paid_box_position', 'label' => '付费框位置', 'type' => 'radio', 'default' => 'top', 'options' => [ + ['label' => '文章模块上', 'value' => 'box_top'], + ['label' => '内容顶', 'value' => 'top'], + ['label' => '内容底', 'value' => 'bottom'], + ['label' => '模块下', 'value' => 'box_bottom'], + ]], + ]], + ['key' => 'download', 'title' => '下载设置', 'fields' => [ + ['key' => 'payment_download_enable', 'label' => '启用下载功能', 'type' => 'switch', 'default' => false], + ['key' => 'payment_download_remote_s', 'label' => '远程文件下载', 'type' => 'switch', 'default' => true], + ['key' => 'payment_download_verify_nonce', 'label' => '下载鉴权', 'type' => 'switch', 'default' => true], + ['key' => 'payment_download_user_log', 'label' => '用户下载记录条数', 'type' => 'number', 'default' => 50, 'min' => 0, 'max' => 9999], + ['key' => 'payment_download_post_log', 'label' => '文章下载记录条数', 'type' => 'number', 'default' => 50, 'min' => 0, 'max' => 9999], + ['key' => 'payment_download_remote_size', 'label' => '远程文件大小限制(KB)', 'type' => 'number', 'default' => 2048, 'min' => 1, 'max' => 999999], + ['key' => 'payment_download_remote_ext', 'label' => '远程文件格式限制', 'type' => 'input', 'default' => 'zip,rar,7z'], + ]], + ], + ], + 'recharge' => [ + 'key' => 'recharge', + 'title' => '余额充值', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'basic', 'title' => '充值开关', 'fields' => [ + ['key' => 'payment_recharge_enabled', 'label' => '启用余额充值', 'type' => 'switch', 'default' => false], + ['key' => 'payment_recharge_mode', 'label' => '充值模式', 'type' => 'select', 'default' => 'manual_review', 'options' => [ + ['label' => '人工审核', 'value' => 'manual_review'], + ['label' => '自动到账', 'value' => 'auto'], + ]], + ['key' => 'payment_recharge_desc', 'label' => '充值说明', 'type' => 'textarea', 'default' => ''], + ['key' => 'payment_recharge_auto_to_balance', 'label' => '自动到账到余额', 'type' => 'switch', 'default' => true], + ]], + ['key' => 'packages', 'title' => '充值套餐', 'fields' => [ + ['key' => 'payment_recharge_packages', 'label' => '套餐列表', 'type' => 'textarea', 'default' => "30|30|入门包\n98|108|推荐包"], + ['key' => 'payment_recharge_help', 'label' => '帮助文案', 'type' => 'textarea', 'default' => ''], + ]], + ['key' => 'product', 'title' => '充值产品', 'fields' => [ + ['key' => 'payment_recharge_product', 'label' => '充值产品', 'type' => 'input', 'default' => ''], + ['key' => 'payment_recharge_price', 'label' => '充值价格', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 99999], + ['key' => 'payment_recharge_tag', 'label' => '价格标签', 'type' => 'input', 'default' => '元'], + ['key' => 'payment_recharge_tag_class', 'label' => '标签样式', 'type' => 'input', 'default' => ''], + ['key' => 'payment_recharge_custom_s', 'label' => '自定义金额', 'type' => 'switch', 'default' => true], + ['key' => 'payment_recharge_custom_limit', 'label' => '自定义金额限制', 'type' => 'number', 'default' => 9999, 'min' => 1, 'max' => 99999], + ]], + ['key' => 'transfer', 'title' => '余额转账', 'fields' => [ + ['key' => 'payment_balance_transfer_s', 'label' => '启用余额转账', 'type' => 'switch', 'default' => false], + ['key' => 'payment_balance_transfer_service_charge', 'label' => '转账手续费(%)', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 50], + ['key' => 'payment_balance_transfer_desc', 'label' => '转账说明', 'type' => 'textarea', 'default' => ''], + ]], + ['key' => 'pass-charge', 'title' => '充值卡免扣', 'fields' => [ + ['key' => 'payment_recharge_pass_charge_s', 'label' => '启用充值卡免扣', 'type' => 'switch', 'default' => false], + ['key' => 'payment_recharge_pass_charge_desc', 'label' => '免扣说明', 'type' => 'textarea', 'default' => ''], + ['key' => 'payment_recharge_pass_charge_only_password', 'label' => '仅密码免扣', 'type' => 'switch', 'default' => false], + ]], + ['key' => 'exchange', 'title' => '积分兑换', 'fields' => [ + ['key' => 'payment_recharge_exchange_s', 'label' => '启用积分兑换', 'type' => 'switch', 'default' => false], + ['key' => 'payment_recharge_exchange_rate', 'label' => '兑换比例', 'type' => 'number', 'default' => 100, 'min' => 1, 'max' => 10000], + ['key' => 'payment_recharge_exchange_desc', 'label' => '兑换说明', 'type' => 'textarea', 'default' => ''], + ]], + ], + ], + 'vip' => [ + 'key' => 'vip', + 'title' => 'VIP会员', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'basic', 'title' => '会员基础', 'fields' => [ + ['key' => 'payment_vip_enabled', 'label' => '启用会员结构', 'type' => 'switch', 'default' => false], + ['key' => 'payment_vip_name', 'label' => '会员名称', 'type' => 'input', 'default' => 'VIP会员'], + ['key' => 'payment_vip_badge_text', 'label' => '会员徽标文案', 'type' => 'input', 'default' => 'VIP'], + ['key' => 'payment_vip_desc', 'label' => '会员说明', 'type' => 'textarea', 'default' => ''], + ]], + ['key' => 'packages', 'title' => '会员套餐', 'fields' => [ + ['key' => 'payment_vip_packages', 'label' => '套餐列表', 'type' => 'textarea', 'default' => "30|30|月卡\n365|365|年卡"], + ['key' => 'payment_vip_help', 'label' => '权益说明', 'type' => 'textarea', 'default' => ''], + ]], + ['key' => 'benefit', 'title' => '会员权益', 'fields' => [ + ['key' => 'payment_vip_download_limit', 'label' => '下载限制', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 9999], + ['key' => 'payment_vip_download_speed', 'label' => '下载速度(KB/s)', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 99999], + ['key' => 'payment_vip_discount', 'label' => '折扣(%)', 'type' => 'number', 'default' => 100, 'min' => 1, 'max' => 100], + ['key' => 'payment_vip_pass_charge', 'label' => '积分充值免扣', 'type' => 'switch', 'default' => false], + ['key' => 'payment_vip_pass_charge_desc', 'label' => '积分充值说明', 'type' => 'textarea', 'default' => ''], + ]], + ['key' => 'renew', 'title' => '续费设置', 'fields' => [ + ['key' => 'payment_vip_renew', 'label' => '允许续费', 'type' => 'switch', 'default' => true], + ['key' => 'payment_vip_renew_desc', 'label' => '续费说明', 'type' => 'textarea', 'default' => ''], + ['key' => 'payment_vip_renew_price_type', 'label' => '续费价格类型', 'type' => 'radio', 'default' => 'same', 'options' => [ + ['label' => '相同价格', 'value' => 'same'], + ['label' => '折扣价格', 'value' => 'discount'], + ]], + ['key' => 'payment_vip_renew_discount', 'label' => '续费折扣(%)', 'type' => 'number', 'default' => 100, 'min' => 1, 'max' => 100], + ['key' => 'payment_vip_renew_reduce', 'label' => '续费减免金额', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 9999], + ]], + ['key' => 'upgrade', 'title' => '升级设置', 'fields' => [ + ['key' => 'payment_vip_upgrade', 'label' => '允许升级', 'type' => 'switch', 'default' => true], + ['key' => 'payment_vip_upgrade_desc', 'label' => '升级说明', 'type' => 'textarea', 'default' => ''], + ['key' => 'payment_vip_upgrade_product', 'label' => '升级产品', 'type' => 'input', 'default' => ''], + ]], + ['key' => 'price', 'title' => '价格设置', 'fields' => [ + ['key' => 'payment_vip_month_price', 'label' => '月卡价格', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 99999], + ['key' => 'payment_vip_year_price', 'label' => '年卡价格', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 99999], + ['key' => 'payment_vip_permanent_price', 'label' => '永久价格', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 99999], + ['key' => 'payment_vip_unit_price', 'label' => '单位价格', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 99999], + ['key' => 'payment_vip_unit_tag', 'label' => '单位标签', 'type' => 'input', 'default' => '元'], + ['key' => 'payment_vip_jump_price', 'label' => '跳转价格', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 99999], + ]], + ], + ], + 'revenue' => [ + 'key' => 'revenue', + 'title' => '创作分成', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'policy', 'title' => '分成策略', 'fields' => [ + ['key' => 'payment_revenue_enabled', 'label' => '启用创作分成', 'type' => 'switch', 'default' => false], + ['key' => 'payment_revenue_share_ratio', 'label' => '作者分成比例(%)', 'type' => 'number', 'default' => 70, 'min' => 1, 'max' => 100], + ['key' => 'payment_revenue_ratio_vip1', 'label' => 'VIP1分成比例(%)', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 100], + ['key' => 'payment_revenue_ratio_vip2', 'label' => 'VIP2分成比例(%)', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 100], + ['key' => 'payment_revenue_settlement_threshold', 'label' => '最小结算金额', 'type' => 'number', 'default' => 100, 'min' => 1, 'max' => 99999], + ['key' => 'payment_revenue_cycle_days', 'label' => '结算周期(天)', 'type' => 'number', 'default' => 30, 'min' => 1, 'max' => 365], + ['key' => 'payment_revenue_points_rule', 'label' => '积分规则', 'type' => 'textarea', 'default' => ''], + ['key' => 'payment_revenue_note', 'label' => '结算备注', 'type' => 'textarea', 'default' => ''], + ]], + ['key' => 'display', 'title' => '展示设置', 'fields' => [ + ['key' => 'payment_revenue_desc_details', 'label' => '收益明细文案', 'type' => 'textarea', 'default' => ''], + ['key' => 'payment_revenue_desc_page', 'label' => '收益说明页', 'type' => 'input', 'default' => ''], + ]], + ], + ], + 'affiliate' => [ + 'key' => 'affiliate', + 'title' => '推广返佣', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'policy', 'title' => '返佣策略', 'fields' => [ + ['key' => 'payment_affiliate_enabled', 'label' => '启用推广返佣', 'type' => 'switch', 'default' => false], + ['key' => 'payment_affiliate_ratio', 'label' => '返佣比例(%)', 'type' => 'number', 'default' => 10, 'min' => 1, 'max' => 100], + ['key' => 'payment_affiliate_window_days', 'label' => '归因窗口(天)', 'type' => 'number', 'default' => 30, 'min' => 1, 'max' => 365], + ['key' => 'payment_affiliate_require_paid_order', 'label' => '仅对已支付订单返佣', 'type' => 'switch', 'default' => true], + ['key' => 'payment_affiliate_self_s', 'label' => '自购返佣', 'type' => 'switch', 'default' => true], + ['key' => 'payment_affiliate_show_users', 'label' => '显示推广用户', 'type' => 'switch', 'default' => true], + ]], + ['key' => 'user-ratio', 'title' => '用户返佣比例', 'fields' => [ + ['key' => 'payment_affiliate_user_ratio', 'label' => '普通用户比例(%)', 'type' => 'number', 'default' => 10, 'min' => 0, 'max' => 100], + ['key' => 'payment_affiliate_vip1_ratio', 'label' => 'VIP1比例(%)', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 100], + ['key' => 'payment_affiliate_vip2_ratio', 'label' => 'VIP2比例(%)', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 100], + ]], + ['key' => 'poster', 'title' => '推广海报', 'fields' => [ + ['key' => 'payment_affiliate_poster_s', 'label' => '启用推广海报', 'type' => 'switch', 'default' => true], + ['key' => 'payment_affiliate_poster_img', 'label' => '海报背景图', 'type' => 'media', 'default' => ''], + ['key' => 'payment_affiliate_poster_title', 'label' => '海报标题', 'type' => 'input', 'default' => ''], + ['key' => 'payment_affiliate_poster_desc', 'label' => '海报描述', 'type' => 'input', 'default' => ''], + ]], + ['key' => 'text', 'title' => '文案设置', 'fields' => [ + ['key' => 'payment_affiliate_text_desc', 'label' => '返佣说明', 'type' => 'textarea', 'default' => ''], + ['key' => 'payment_affiliate_text_details_title', 'label' => '明细标题', 'type' => 'input', 'default' => ''], + ['key' => 'payment_affiliate_text_details', 'label' => '明细说明', 'type' => 'textarea', 'default' => ''], + ['key' => 'payment_affiliate_text_discount', 'label' => '折扣文案', 'type' => 'input', 'default' => ''], + ]], + ], + ], + 'gateway' => [ + 'key' => 'gateway', + 'title' => '收款接口', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'adapter', 'title' => '网关适配器', 'fields' => [ + ['key' => 'payment_gateway_mode', 'label' => '运行模式', 'type' => 'string', 'default' => 'manual_review'], + ['key' => 'payment_gateway_test_enabled', 'label' => '启用测试网关', 'type' => 'bool', 'default' => true], + ['key' => 'payment_gateway_manual_review', 'label' => '保留人工复核', 'type' => 'bool', 'default' => true], + ['key' => 'payment_gateway_callback_secret', 'label' => '回调校验口令', 'type' => 'string', 'default' => ''], + ['key' => 'payment_gateway_help', 'label' => '运维说明', 'type' => 'string', 'default' => '未接真实支付网关时,建议使用测试网关或人工确认模式完成联调。'], + ]], + ['key' => 'gateways', 'title' => '支付网关', 'fields' => [ + ['key' => 'payment_wechat_enabled', 'label' => '微信支付', 'type' => 'switch', 'default' => false], + ['key' => 'payment_alipay_enabled', 'label' => '支付宝', 'type' => 'switch', 'default' => false], + ['key' => 'payment_paypal_enabled', 'label' => 'PayPal', 'type' => 'switch', 'default' => false], + ['key' => 'payment_balance_enabled', 'label' => '余额支付', 'type' => 'switch', 'default' => false], + ['key' => 'payment_card_pass_enabled', 'label' => '卡密充值', 'type' => 'switch', 'default' => false], + ['key' => 'payment_xunhupay_enabled', 'label' => '虎皮椒', 'type' => 'switch', 'default' => false], + ['key' => 'payment_xhpay_enabled', 'label' => '小火付', 'type' => 'switch', 'default' => false], + ['key' => 'payment_payjs_enabled', 'label' => 'PayJS', 'type' => 'switch', 'default' => false], + ['key' => 'payment_epay_enabled', 'label' => '易支付', 'type' => 'switch', 'default' => false], + ['key' => 'payment_vmq_enabled', 'label' => 'V免签', 'type' => 'switch', 'default' => false], + ['key' => 'payment_codepay_enabled', 'label' => 'Codepay', 'type' => 'switch', 'default' => false], + ['key' => 'payment_stripe_enabled', 'label' => 'Stripe', 'type' => 'switch', 'default' => false], + ]], + ['key' => 'wechat', 'title' => '微信支付配置', 'fields' => [ + ['key' => 'wechat_merchantid', 'label' => '商户号', 'type' => 'input', 'default' => ''], + ['key' => 'wechat_jsapi', 'label' => 'JSAPI密钥', 'type' => 'input', 'default' => ''], + ['key' => 'wechat_appsecret', 'label' => 'AppSecret', 'type' => 'input', 'default' => ''], + ['key' => 'wechat_cert_path', 'label' => '证书路径', 'type' => 'input', 'default' => ''], + ['key' => 'wechat_key_path', 'label' => '密钥路径', 'type' => 'input', 'default' => ''], + ]], + ['key' => 'alipay', 'title' => '支付宝配置', 'fields' => [ + ['key' => 'alipay_appid', 'label' => 'AppID', 'type' => 'input', 'default' => ''], + ['key' => 'alipay_privatekey', 'label' => '应用私钥', 'type' => 'input', 'default' => ''], + ['key' => 'alipay_publickey', 'label' => '支付宝公钥', 'type' => 'input', 'default' => ''], + ['key' => 'alipay_return_url', 'label' => '同步回调地址', 'type' => 'input', 'default' => ''], + ['key' => 'alipay_notify_url', 'label' => '异步回调地址', 'type' => 'input', 'default' => ''], + ]], + ['key' => 'paypal', 'title' => 'PayPal配置', 'fields' => [ + ['key' => 'paypal_client_id', 'label' => 'Client ID', 'type' => 'input', 'default' => ''], + ['key' => 'paypal_client_secret', 'label' => 'Client Secret', 'type' => 'input', 'default' => ''], + ['key' => 'paypal_currency', 'label' => '货币', 'type' => 'input', 'default' => 'USD'], + ['key' => 'paypal_sandbox', 'label' => '沙箱模式', 'type' => 'switch', 'default' => true], + ]], + ['key' => 'xunhupay', 'title' => '虎皮椒配置', 'fields' => [ + ['key' => 'xunhupay_appid', 'label' => 'AppID', 'type' => 'input', 'default' => ''], + ['key' => 'xunhupay_appsecret', 'label' => 'AppSecret', 'type' => 'input', 'default' => ''], + ]], + ['key' => 'xhpay', 'title' => '小火付配置', 'fields' => [ + ['key' => 'xhpay_mchid', 'label' => '商户号', 'type' => 'input', 'default' => ''], + ['key' => 'xhpay_alipay_v2', 'label' => '支付宝V2', 'type' => 'input', 'default' => ''], + ['key' => 'xhpay_wx_h5', 'label' => '微信H5', 'type' => 'input', 'default' => ''], + ]], + ['key' => 'payjs', 'title' => 'PayJS配置', 'fields' => [ + ['key' => 'payjs_mchid', 'label' => '商户号', 'type' => 'input', 'default' => ''], + ['key' => 'payjs_key', 'label' => '通信密钥', 'type' => 'input', 'default' => ''], + ]], + ['key' => 'epay', 'title' => '易支付配置', 'fields' => [ + ['key' => 'epay_apiurl', 'label' => '接口地址', 'type' => 'input', 'default' => ''], + ['key' => 'epay_pid', 'label' => '商户ID', 'type' => 'input', 'default' => ''], + ['key' => 'epay_key', 'label' => '商户密钥', 'type' => 'input', 'default' => ''], + ]], + ['key' => 'stripe', 'title' => 'Stripe配置', 'fields' => [ + ['key' => 'stripe_secret_key', 'label' => 'Secret Key', 'type' => 'input', 'default' => ''], + ['key' => 'stripe_publishable_key', 'label' => 'Publishable Key', 'type' => 'input', 'default' => ''], + ['key' => 'stripe_webhook_secret', 'label' => 'Webhook Secret', 'type' => 'input', 'default' => ''], + ['key' => 'stripe_currency', 'label' => '货币', 'type' => 'input', 'default' => 'usd'], + ['key' => 'stripe_test_mode', 'label' => '测试模式', 'type' => 'switch', 'default' => true], + ]], + ], + ], + 'order' => [ + 'key' => 'order', + 'title' => '订单设置', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'basic', 'title' => '订单基础', 'fields' => [ + ['key' => 'payment_order_prefix', 'label' => '订单前缀', 'type' => 'input', 'default' => 'AKSRT'], + ['key' => 'payment_order_expiry', 'label' => '订单有效期(分钟)', 'type' => 'number', 'default' => 30, 'min' => 5, 'max' => 1440], + ['key' => 'payment_show_user', 'label' => '显示用户信息', 'type' => 'switch', 'default' => true], + ['key' => 'payment_show_paycount', 'label' => '显示购买次数', 'type' => 'switch', 'default' => true], + ['key' => 'payment_no_logged_in', 'label' => '未登录提示', 'type' => 'switch', 'default' => true], + ['key' => 'payment_cookie_day', 'label' => 'Cookie有效期(天)', 'type' => 'number', 'default' => 15, 'min' => 1, 'max' => 31], + ['key' => 'payment_no_logged_remind', 'label' => '未登录提醒文案', 'type' => 'textarea', 'default' => ''], + ['key' => 'payment_wait_float_s', 'label' => '订单等待浮动', 'type' => 'switch', 'default' => true], + ['key' => 'payment_order_max_minutes', 'label' => '订单最大支付时间(分钟)', 'type' => 'number', 'default' => 30, 'min' => 5, 'max' => 1440], + ]], + ['key' => 'display', 'title' => '展示设置', 'fields' => [ + ['key' => 'payment_mark', 'label' => '付费标记', 'type' => 'input', 'default' => '付费'], + ['key' => 'payment_box_position', 'label' => '付费框位置', 'type' => 'radio', 'default' => 'top', 'options' => [ + ['label' => '文章模块上', 'value' => 'box_top'], + ['label' => '内容顶', 'value' => 'top'], + ['label' => '内容底', 'value' => 'bottom'], + ['label' => '模块下', 'value' => 'box_bottom'], + ]], + ['key' => 'payment_free_logged_show', 'label' => '登录用户显示免费', 'type' => 'switch', 'default' => false], + ['key' => 'payment_show_ip', 'label' => '显示用户IP', 'type' => 'switch', 'default' => false], + ['key' => 'payment_show_device', 'label' => '显示设备信息', 'type' => 'switch', 'default' => false], + ['key' => 'payment_show_order_no', 'label' => '显示订单号', 'type' => 'switch', 'default' => true], + ['key' => 'payment_show_create_time', 'label' => '显示创建时间', 'type' => 'switch', 'default' => true], + ['key' => 'payment_show_pay_time', 'label' => '显示支付时间', 'type' => 'switch', 'default' => true], + ]], + ['key' => 'status', 'title' => '状态设置', 'fields' => [ + ['key' => 'payment_order_status_pending', 'label' => '待支付状态文案', 'type' => 'input', 'default' => '待支付'], + ['key' => 'payment_order_status_paid', 'label' => '已支付状态文案', 'type' => 'input', 'default' => '已支付'], + ['key' => 'payment_order_status_completed', 'label' => '已完成状态文案', 'type' => 'input', 'default' => '已完成'], + ['key' => 'payment_order_status_cancelled', 'label' => '已取消状态文案', 'type' => 'input', 'default' => '已取消'], + ['key' => 'payment_order_status_refunded', 'label' => '已退款状态文案', 'type' => 'input', 'default' => '已退款'], + ['key' => 'payment_order_status_refunding', 'label' => '退款中状态文案', 'type' => 'input', 'default' => '退款中'], + ['key' => 'payment_order_status_processing', 'label' => '处理中状态文案', 'type' => 'input', 'default' => '处理中'], + ]], + ['key' => 'service', 'title' => '服务设置', 'fields' => [ + ['key' => 'payment_service', 'label' => '客服设置', 'type' => 'textarea', 'default' => ''], + ['key' => 'payment_service_icon', 'label' => '客服图标', 'type' => 'icon', 'default' => ''], + ['key' => 'payment_service_value', 'label' => '客服内容', 'type' => 'input', 'default' => ''], + ['key' => 'payment_service_qq', 'label' => 'QQ客服', 'type' => 'input', 'default' => ''], + ['key' => 'payment_service_wechat', 'label' => '微信客服', 'type' => 'media', 'default' => ''], + ]], + ], + ], + 'card-pass' => [ + 'key' => 'card-pass', + 'title' => '充值卡', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'basic', 'title' => '充值卡设置', 'fields' => [ + ['key' => 'card_pass_enabled', 'label' => '启用充值卡', 'type' => 'switch', 'default' => false], + ['key' => 'card_pass_desc', 'label' => '充值卡说明', 'type' => 'textarea', 'default' => ''], + ['key' => 'card_pass_type', 'label' => '充值卡类型', 'type' => 'select', 'default' => 'balance', 'options' => [ + ['label' => '余额卡', 'value' => 'balance'], + ['label' => 'VIP卡', 'value' => 'vip'], + ['label' => '积分卡', 'value' => 'credits'], + ]], + ['key' => 'card_pass_prefix', 'label' => '卡号前缀', 'type' => 'input', 'default' => 'AK'], + ['key' => 'card_pass_length', 'label' => '卡号长度', 'type' => 'number', 'default' => 16, 'min' => 8, 'max' => 32], + ['key' => 'card_pass_batch_size', 'label' => '批量生成数量', 'type' => 'number', 'default' => 50, 'min' => 1, 'max' => 500], + ]], + ['key' => 'redeem', 'title' => '兑换设置', 'fields' => [ + ['key' => 'card_pass_max_redeem', 'label' => '单卡最大兑换次数', 'type' => 'number', 'default' => 1, 'min' => 1, 'max' => 100], + ['key' => 'card_pass_expiry', 'label' => '有效期(天)', 'type' => 'number', 'default' => 0, 'min' => 0, 'help' => '0为永久有效'], + ['key' => 'card_pass_amount', 'label' => '面额(元)', 'type' => 'number', 'default' => 10, 'min' => 1, 'max' => 9999], + ['key' => 'card_pass_vip_days', 'label' => 'VIP天数', 'type' => 'number', 'default' => 30, 'min' => 1, 'max' => 3650], + ['key' => 'card_pass_credits', 'label' => '赠送积分', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 99999], + ]], + ['key' => 'display', 'title' => '展示设置', 'fields' => [ + ['key' => 'card_pass_redeem_page', 'label' => '兑换页面', 'type' => 'input', 'default' => ''], + ['key' => 'card_pass_success_msg', 'label' => '兑换成功提示', 'type' => 'input', 'default' => '兑换成功!'], + ['key' => 'card_pass_fail_msg', 'label' => '兑换失败提示', 'type' => 'input', 'default' => '卡号无效或已过期'], + ]], + ], + ], + 'withdrawal' => [ + 'key' => 'withdrawal', + 'title' => '提现管理', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'basic', 'title' => '提现设置', 'fields' => [ + ['key' => 'pay_rebate_withdraw_lowest_money', 'label' => '最低提现金额', 'type' => 'number', 'default' => 50, 'min' => 1, 'max' => 99999], + ['key' => 'withdraw_service_charge', 'label' => '提现手续费(%)', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 50], + ['key' => 'pay_rebate_withdraw_text_details', 'label' => '提现说明', 'type' => 'textarea', 'default' => ''], + ]], + ], + ], + ], + ]; + } +} diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-permissions.php b/inc/classes/admin/schema/class-aksrt-admin-schema-permissions.php new file mode 100644 index 00000000..05942fb8 --- /dev/null +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-permissions.php @@ -0,0 +1,194 @@ + 'permissions', + 'title' => '功能&权限', + 'sections' => [ + 'login' => [ + 'key' => 'login', + 'title' => '登录权限', + 'version' => '1.0.0', + 'option_key' => 'aksrt_captcha_config', + 'groups' => [ + ['key' => 'basic', 'title' => '基础开关', 'fields' => [ + ['key' => 'enabled', 'label' => '启用人机验证', 'type' => 'switch', 'default' => false], + ['key' => 'active_provider', 'label' => '当前服务商', 'type' => 'radio', 'default' => '', 'options' => [ + ['label' => '腾讯云 Captcha', 'value' => 'tencent'], + ['label' => '阿里云 CAPTCHA 2.0', 'value' => 'aliyun'], + ['label' => 'Cloudflare Turnstile', 'value' => 'cloudflare'], + ['label' => '极验 GeeTest', 'value' => 'geetest'], + ]], + ['key' => 'mode', 'label' => '触发模式', 'type' => 'radio', 'default' => 'always', 'options' => [ + ['label' => '始终显示', 'value' => 'always'], + ]], + ['key' => 'disabled_scenarios', 'label' => '停用场景', 'type' => 'checkbox', 'default' => [], 'options' => [ + ['label' => '发表评论', 'value' => 'comment'], + ['label' => '用户登录', 'value' => 'login'], + ['label' => '用户注册', 'value' => 'register'], + ['label' => '绑定第三方', 'value' => 'bind'], + ['label' => '找回密码', 'value' => 'lostpassword'], + ['label' => '修改密码', 'value' => 'change_password'], + ['label' => '更改邮箱', 'value' => 'change_email'], + ['label' => '邮箱验证', 'value' => 'verify_email'], + ['label' => '资源下载', 'value' => 'download'], + ['label' => '每日签到', 'value' => 'signin'], + ]], + ]], + ['key' => 'providers', 'title' => '服务商配置', 'fields' => [ + ['key' => 'tencent', 'label' => '腾讯云配置', 'type' => 'custom', 'default' => [], 'sanitize_type' => 'array'], + ['key' => 'aliyun', 'label' => '阿里云配置', 'type' => 'custom', 'default' => [], 'sanitize_type' => 'array'], + ['key' => 'cloudflare', 'label' => 'Cloudflare 配置', 'type' => 'custom', 'default' => [], 'sanitize_type' => 'array'], + ['key' => 'geetest', 'label' => '极验配置', 'type' => 'custom', 'default' => [], 'sanitize_type' => 'array'], + ]], + ], + ], + 'comment' => [ + 'key' => 'comment', + 'title' => '评论权限', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'basic', 'title' => '基础权限', 'fields' => [ + ['key' => 'comment_enabled', 'label' => '全局评论开关', 'type' => 'switch', 'default' => true, 'help' => '关闭后全站禁用评论功能'], + ['key' => 'comment_guest_allowed', 'label' => '允许游客评论', 'type' => 'switch', 'default' => true], + ['key' => 'comment_min_length', 'label' => '评论最少字数', 'type' => 'number', 'default' => 2, 'min' => 0, 'max' => 100], + ['key' => 'comment_max_length', 'label' => '评论最多字数', 'type' => 'number', 'default' => 1000, 'min' => 50, 'max' => 10000], + ['key' => 'comment_cool_down_seconds', 'label' => '评论冷却时间(秒)', 'type' => 'number', 'default' => 15, 'min' => 0, 'max' => 300], + ['key' => 'comment_max_links', 'label' => '最大链接数', 'type' => 'number', 'default' => 2, 'min' => 0, 'max' => 10], + ['key' => 'comment_max_images', 'label' => '最大图片数', 'type' => 'number', 'default' => 3, 'min' => 0, 'max' => 20], + ]], + ['key' => 'sensitive', 'title' => '敏感词', 'fields' => [ + ['key' => 'comment_sensitive_words', 'label' => '敏感词列表', 'type' => 'textarea', 'default' => ''], + ['key' => 'comment_sensitive_action', 'label' => '敏感词处理', 'type' => 'radio', 'default' => 'reject', 'options' => [ + ['label' => '拒绝评论', 'value' => 'reject'], + ['label' => '替换为***', 'value' => 'replace'], + ['label' => '转入审核', 'value' => 'pending'], + ]], + ['key' => 'comment_sensitive_log', 'label' => '记录敏感词日志', 'type' => 'switch', 'default' => true], + ]], + ['key' => 'anti-spam', 'title' => '反垃圾', 'fields' => [ + ['key' => 'comment_link_filter', 'label' => '链接过滤', 'type' => 'switch', 'default' => true, 'help' => '评论包含链接自动进入审核'], + ['key' => 'comment_flood_protection', 'label' => '防刷评论', 'type' => 'switch', 'default' => true], + ['key' => 'comment_flood_interval', 'label' => '防刷间隔(秒)', 'type' => 'number', 'default' => 10, 'min' => 3, 'max' => 60], + ['key' => 'comment_max_per_hour', 'label' => '每小时最大评论数', 'type' => 'number', 'default' => 20, 'min' => 1, 'max' => 200], + ]], + ['key' => 'display', 'title' => '展示设置', 'fields' => [ + ['key' => 'comment_avatar_required', 'label' => '要求设置头像', 'type' => 'switch', 'default' => false], + ['key' => 'comment_nickname_required', 'label' => '要求填写昵称', 'type' => 'switch', 'default' => true], + ['key' => 'comment_email_required', 'label' => '要求填写邮箱', 'type' => 'switch', 'default' => true], + ]], + ], + ], + 'attachment' => [ + 'key' => 'attachment', + 'title' => '附件权限', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'profile', 'title' => '头像与封面', 'fields' => [ + ['key' => 'allow_avatar_upload', 'label' => '允许用户上传头像', 'type' => 'switch', 'default' => true], + ['key' => 'allow_cover_upload', 'label' => '允许用户上传封面图', 'type' => 'switch', 'default' => true], + ['key' => 'allow_background_upload', 'label' => '允许用户上传背景图', 'type' => 'switch', 'default' => false], + ['key' => 'avatar_max_size', 'label' => '头像/封面大小上限 (MB)', 'type' => 'number', 'default' => 2, 'min' => 1, 'max' => 10], + ]], + ['key' => 'content', 'title' => '内容上传', 'fields' => [ + ['key' => 'allow_submit_images', 'label' => '允许投稿插入图片', 'type' => 'switch', 'default' => true], + ['key' => 'submit_image_max_size', 'label' => '投稿图片大小上限 (MB)', 'type' => 'number', 'default' => 5, 'min' => 1, 'max' => 20], + ['key' => 'allow_comment_images', 'label' => '允许评论插入图片', 'type' => 'switch', 'default' => false], + ['key' => 'comment_image_max_size', 'label' => '评论图片大小上限 (MB)', 'type' => 'number', 'default' => 2, 'min' => 1, 'max' => 10], + ]], + ['key' => 'resource', 'title' => '资源附件', 'fields' => [ + ['key' => 'allow_resource_upload', 'label' => '允许资源附件上传', 'type' => 'switch', 'default' => true], + ['key' => 'resource_max_size', 'label' => '资源附件大小上限 (MB)', 'type' => 'number', 'default' => 50, 'min' => 1, 'max' => 200], + ]], + ], + ], + 'download' => [ + 'key' => 'download', + 'title' => '下载权限', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'basic', 'title' => '下载设置', 'fields' => [ + ['key' => 'download_require_login', 'label' => '下载需要登录', 'type' => 'switch', 'default' => true], + ['key' => 'download_require_credits', 'label' => '下载需要积分', 'type' => 'switch', 'default' => false], + ['key' => 'download_credits_cost', 'label' => '下载所需积分', 'type' => 'number', 'default' => 1, 'min' => 1, 'max' => 100], + ['key' => 'download_daily_limit', 'label' => '每日下载限制', 'type' => 'number', 'default' => 0, 'min' => 0, 'help' => '0为不限制'], + ['key' => 'download_link_expire', 'label' => '下载链接有效期(分钟)', 'type' => 'number', 'default' => 30, 'min' => 5, 'max' => 1440], + ['key' => 'download_speed_limit', 'label' => '下载速度限制(KB/s)', 'type' => 'number', 'default' => 0, 'min' => 0, 'help' => '0为不限制'], + ]], + ['key' => 'vip', 'title' => 'VIP 特权', 'fields' => [ + ['key' => 'download_vip_free', 'label' => 'VIP 免费下载', 'type' => 'switch', 'default' => false], + ['key' => 'download_vip_daily_extra', 'label' => 'VIP每日额外次数', 'type' => 'number', 'default' => 10, 'min' => 0, 'max' => 999], + ]], + ['key' => 'role', 'title' => '角色权限', 'fields' => [ + ['key' => 'download_role_limits', 'label' => '角色下载限制', 'type' => 'textarea', 'default' => '', 'help' => '格式: role|daily_limit,每行一条'], + ['key' => 'download_role_size', 'label' => '角色文件大小限制', 'type' => 'textarea', 'default' => '', 'help' => '格式: role|max_size_mb,每行一条'], + ]], + ['key' => 'captcha', 'title' => '安全设置', 'fields' => [ + ['key' => 'download_captcha', 'label' => '下载需要验证码', 'type' => 'switch', 'default' => false], + ['key' => 'download_ip_limit', 'label' => 'IP下载限制', 'type' => 'number', 'default' => 0, 'min' => 0, 'help' => '同一IP每小时最大下载次数,0为不限制'], + ]], + ], + ], + 'moderation' => [ + 'key' => 'moderation', + 'title' => '审核和风控', + 'version' => '1.0.0', + 'option_key' => 'aksrt_moderation_config', + 'groups' => [ + ['key' => 'basic', 'title' => '审核总开关', 'fields' => [ + ['key' => 'enabled', 'label' => '启用审核系统', 'type' => 'switch', 'default' => false], + ]], + ['key' => 'akismet', 'title' => 'Akismet', 'fields' => [ + ['key' => 'akismet_enabled', 'label' => '启用 Akismet', 'type' => 'switch', 'default' => false, 'dependsOn' => 'enabled'], + ['key' => 'akismet_site_url', 'label' => 'Site URL', 'type' => 'input', 'default' => '', 'dependsOn' => { 'key' => 'akismet_enabled', 'value' => true }], + ['key' => 'akismet_blog_lang', 'label' => '博客语言', 'type' => 'input', 'default' => 'zh-CN', 'dependsOn' => { 'key' => 'akismet_enabled', 'value' => true }], + ['key' => 'akismet_api_key', 'label' => 'API Key', 'type' => 'input', 'inputType' => 'password', 'default' => '', 'dependsOn' => { 'key' => 'akismet_enabled', 'value' => true }], + ]], + ['key' => 'ai', 'title' => 'AI 审核', 'fields' => [ + ['key' => 'ai_enabled', 'label' => '启用 AI 审核', 'type' => 'switch', 'default' => false, 'dependsOn' => 'enabled'], + ['key' => 'ai_api_hint', 'label' => '审核 API 配置', 'type' => 'notice', 'alertType' => 'info', 'alertContent' => 'API 接入(百度/阿里云等)在「扩展 → API内容审核」中统一配置,此处仅控制审核范围和策略。', 'dependsOn' => { 'key' => 'ai_enabled', 'value' => true }], + ['key' => 'ai_moderate_comments', 'label' => '用户评论', 'type' => 'switch', 'default' => true, 'dependsOn' => { 'key' => 'ai_enabled', 'value' => true }], + ['key' => 'ai_moderate_background', 'label' => '用户资料背景', 'type' => 'switch', 'default' => true, 'dependsOn' => { 'key' => 'ai_enabled', 'value' => true }], + ['key' => 'auto_approve_low_risk', 'label' => '低风险自动通过', 'type' => 'switch', 'default' => false, 'dependsOn' => { 'key' => 'ai_enabled', 'value' => true }], + ['key' => 'auto_reject_high_risk', 'label' => '高风险自动拒绝', 'type' => 'switch', 'default' => false, 'dependsOn' => { 'key' => 'ai_enabled', 'value' => true }], + ['key' => 'low_risk_max_score', 'label' => '低风险上限', 'type' => 'number', 'default' => 35, 'min' => 0, 'max' => 100, 'dependsOn' => { 'key' => 'ai_enabled', 'value' => true }], + ['key' => 'high_risk_min_score', 'label' => '高风险下限', 'type' => 'number', 'default' => 80, 'min' => 0, 'max' => 100, 'dependsOn' => { 'key' => 'ai_enabled', 'value' => true }], + ]], + ['key' => 'keywords', 'title' => '关键词拦截', 'fields' => [ + ['key' => 'blocked_keywords', 'label' => '屏蔽关键词列表', 'type' => 'textarea', 'default' => '', 'rows' => 4, 'dependsOn' => 'enabled'], + ]], + ['key' => 'rate-limit', 'title' => '频率限制', 'fields' => [ + ['key' => 'rate_limit_enabled', 'label' => '启用频率限制', 'type' => 'switch', 'default' => false, 'dependsOn' => 'enabled'], + ['key' => 'rate_limit_min_interval', 'label' => '最小间隔(秒)', 'type' => 'number', 'default' => 8, 'min' => 1, 'max' => 300, 'dependsOn' => { 'key' => 'rate_limit_enabled', 'value' => true }], + ['key' => 'rate_limit_max_per_hour', 'label' => '每小时上限', 'type' => 'number', 'default' => 15, 'min' => 1, 'max' => 200, 'dependsOn' => { 'key' => 'rate_limit_enabled', 'value' => true }], + ]], + ['key' => 'geoip', 'title' => 'GeoIP 拦截', 'fields' => [ + ['key' => 'geoip_enabled', 'label' => '启用 GeoIP 拦截', 'type' => 'switch', 'default' => false, 'dependsOn' => 'enabled'], + ['key' => 'geoip_hint', 'label' => 'IP 数据源', 'type' => 'notice', 'alertType' => 'info', 'alertContent' => '复用「扩展 → IP归属地」中配置的数据源和 API Key。', 'dependsOn' => { 'key' => 'geoip_enabled', 'value' => true }], + ['key' => 'geoip_block_countries', 'label' => '屏蔽国家/地区', 'type' => 'custom', 'default' => [], 'sanitize_type' => 'array', 'dependsOn' => { 'key' => 'geoip_enabled', 'value' => true }], + ]], + ], + ], + 'capabilities' => [ + 'key' => 'capabilities', + 'title' => '基本权限 / 能力矩阵', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => self::build_cap_matrix_groups(), + ], + ], + ]; + } +} diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-post-list.php b/inc/classes/admin/schema/class-aksrt-admin-schema-post-list.php new file mode 100644 index 00000000..9c2f01ed --- /dev/null +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-post-list.php @@ -0,0 +1,453 @@ + 'post_list', + 'title' => '文章&列表', + 'sections' => [ + 'list' => [ + 'key' => 'list', + 'title' => '文章列表', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'card', 'title' => '卡片样式', 'fields' => [ + ['key' => 'post_card_style', 'label' => '卡片样式', 'type' => 'select', 'default' => 'standard', 'options' => [ + ['label' => '标准卡', 'value' => 'standard'], + ['label' => '紧凑卡', 'value' => 'compact'], + ['label' => '大图卡', 'value' => 'large_thumb'], + ['label' => '多图卡', 'value' => 'multi_thumb'], + ['label' => '纯文字', 'value' => 'text'], + ['label' => '有图则图文', 'value' => 'thumb_if_has'], + ]], + ['key' => 'post_excerpt_length', 'label' => '摘要长度', 'type' => 'number', 'default' => 120, 'min' => 20, 'max' => 500], + ]], + ['key' => 'meta', 'title' => '元信息显示', 'fields' => [ + ['key' => 'post_show_author', 'label' => '显示作者', 'type' => 'switch', 'default' => true], + ['key' => 'post_show_date', 'label' => '显示日期', 'type' => 'switch', 'default' => true], + ['key' => 'post_show_views', 'label' => '显示浏览数', 'type' => 'switch', 'default' => true], + ['key' => 'post_show_category', 'label' => '显示分类', 'type' => 'switch', 'default' => true], + ['key' => 'post_show_tags', 'label' => '显示标签', 'type' => 'switch', 'default' => false], + ['key' => 'post_show_comments', 'label' => '显示评论数', 'type' => 'switch', 'default' => false], + ['key' => 'post_show_likes', 'label' => '显示点赞数', 'type' => 'switch', 'default' => false], + ['key' => 'list_badge_show', 'label' => '显示徽章类型', 'type' => 'checkbox', 'default' => ['pay', 'tag', 'topics', 'cat'], 'options' => [ + ['label' => '付费', 'value' => 'pay'], + ['label' => '分类', 'value' => 'cat'], + ['label' => '专题', 'value' => 'topics'], + ['label' => '标签', 'value' => 'tag'], + ]], + ['key' => 'post_list_meta_show', 'label' => '显示元信息', 'type' => 'switch', 'default' => true], + ['key' => 'list_meta_show', 'label' => '移动端优先部件', 'type' => 'radio', 'default' => 'like', 'options' => [ + ['label' => '浏览数', 'value' => 'view'], + ['label' => '点赞数', 'value' => 'like'], + ['label' => '评论数', 'value' => 'comm'], + ], 'description' => '移动端空间不足时保留显示的单一部件'], + ]], + ['key' => 'layout', 'title' => '布局设置', 'fields' => [ + ['key' => 'post_list_columns', 'label' => '列表列数', 'type' => 'number', 'default' => 1, 'min' => 1, 'max' => 3], + ['key' => 'post_list_per_page', 'label' => '每页文章数', 'type' => 'number', 'default' => 12, 'min' => 4, 'max' => 48], + ['key' => 'sticky_s', 'label' => '置顶范围', 'type' => 'checkbox', 'default' => ['home', 'cat'], 'options' => [ + ['label' => '首页', 'value' => 'home'], + ['label' => '分类页', 'value' => 'cat'], + ['label' => '标签页', 'value' => 'tag'], + ['label' => '专题页', 'value' => 'topics'], + ]], + ['key' => 'post_target_blank', 'label' => '新窗口打开', 'type' => 'switch', 'default' => false], + ['key' => 'item_heading_bold', 'label' => '标题加粗', 'type' => 'switch', 'default' => false], + ['key' => 'list_show_type', 'label' => '列表样式', 'type' => 'radio', 'default' => 'separate', 'options' => [ + ['label' => '分隔卡片', 'value' => 'separate'], + ['label' => '无间距', 'value' => 'no_margin'], + ]], + ['key' => 'list_card_home', 'label' => '首页卡片模式', 'type' => 'switch', 'default' => false], + ['key' => 'list_card_cat', 'label' => '分类卡片模式', 'type' => 'taxonomy_select', 'taxonomy' => 'category', 'multiple' => true, 'default' => []], + ['key' => 'list_card_tag', 'label' => '标签页卡片模式', 'type' => 'switch', 'default' => false], + ['key' => 'list_card_topics', 'label' => '专题页卡片模式', 'type' => 'switch', 'default' => false], + ['key' => 'list_card_author', 'label' => '用户页卡片模式', 'type' => 'switch', 'default' => false], + ]], + ['key' => 'pagination', 'title' => '分页方式', 'fields' => [ + ['key' => 'post_pagination_mode', 'label' => '分页模式', 'type' => 'radio', 'default' => 'numbered', 'options' => [ + ['label' => '数字分页', 'value' => 'numbered'], + ['label' => '加载更多', 'value' => 'load_more'], + ['label' => '无限滚动', 'value' => 'infinite'], + ]], + ['key' => 'ias_max', 'label' => '最大加载页数', 'type' => 'number', 'default' => 3, 'min' => 0, 'max' => 10, 'description' => '0=不限'], + ['key' => 'ajax_trigger_text', 'label' => '加载更多文案', 'type' => 'input', 'default' => ''], + ['key' => 'ajax_nomore_text', 'label' => '没有更多文案', 'type' => 'input', 'default' => ''], + ]], + ['key' => 'sort', 'title' => '默认排序', 'fields' => [ + ['key' => 'list_orderby', 'label' => '列表默认排序', 'type' => 'radio', 'default' => 'modified', 'options' => [ + ['label' => '更新时间', 'value' => 'modified'], + ['label' => '发布时间', 'value' => 'date'], + ['label' => '评论数', 'value' => 'comments'], + ['label' => 'WordPress 默认', 'value' => 'default'], + ], 'help' => '分类/标签/归档页面的默认排序方式'], + ]], + ['key' => 'list_option', 'title' => '列表模式配置', 'fields' => [ + ['key' => 'list_list_style', 'label' => '列表样式变体', 'type' => 'radio', 'default' => '', 'options' => [ + ['label' => '默认', 'value' => ''], + ['label' => '样式2', 'value' => 'style2'], + ]], + ['key' => 'list_list_img_position', 'label' => '缩略图位置', 'type' => 'radio', 'default' => 'left', 'options' => [ + ['label' => '左侧', 'value' => 'left'], + ['label' => '右侧', 'value' => 'right'], + ]], + ['key' => 'list_list_scale', 'label' => '缩略图比例 (%)', 'type' => 'number', 'default' => 70, 'min' => 50, 'max' => 200], + ]], + ['key' => 'card_option', 'title' => '卡片模式配置', 'fields' => [ + ['key' => 'list_card_style', 'label' => '卡片样式变体', 'type' => 'radio', 'default' => '', 'options' => [ + ['label' => '默认', 'value' => ''], + ['label' => '样式3', 'value' => 'style3'], + ]], + ['key' => 'list_card_scale', 'label' => '缩略图比例 (%)', 'type' => 'number', 'default' => 70, 'min' => 20, 'max' => 200], + ]], + ], + ], + 'thumb' => [ + 'key' => 'thumb', + 'title' => '列表缩略图', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'source', 'title' => '图片来源', 'fields' => [ + ['key' => 'thumb_priority', 'label' => '图片优先级', 'type' => 'input', 'default' => 'featured,attachment,resource,custom'], + ['key' => 'thumb_fallback', 'label' => '无图时处理', 'type' => 'radio', 'default' => 'placeholder', 'options' => [ + ['label' => '显示占位图', 'value' => 'placeholder'], + ['label' => '隐藏缩略图', 'value' => 'hide'], + ['label' => '随机封面', 'value' => 'random'], + ]], + ['key' => 'thumb_spare_images', 'label' => '备用缩略图', 'type' => 'gallery', 'default' => [], 'description' => '当 thumb_fallback 为 random 时从此图库随机选取'], + ['key' => 'thumb_aspect_ratio', 'label' => '图片比例', 'type' => 'select', 'default' => '16:9', 'options' => [ + ['label' => '16:9 宽屏', 'value' => '16:9'], + ['label' => '4:3 标准', 'value' => '4:3'], + ['label' => '1:1 正方形', 'value' => '1:1'], + ['label' => '自适应', 'value' => 'auto'], + ]], + ['key' => 'list_thumb_fit', 'label' => '缩略图填充方式', 'type' => 'radio', 'default' => 'cover', 'options' => [ + ['label' => '填充裁剪', 'value' => 'cover'], + ['label' => '完整显示', 'value' => 'contain'], + ]], + ['key' => 'list_thumb_slides_s', 'label' => '幻灯片缩略图', 'type' => 'switch', 'default' => true, 'description' => '允许幻灯片作为列表缩略图'], + ['key' => 'list_thumb_video_s', 'label' => '视频缩略图', 'type' => 'switch', 'default' => true, 'description' => '鼠标悬停时自动播放视频缩略图'], + ['key' => 'list_thumb_video_mute_s', 'label' => '视频缩略图静音', 'type' => 'switch', 'default' => true, 'description' => '视频缩略图播放时静音'], + ['key' => 'thumb_postfirstimg_s', 'label' => '自动获取文章首图', 'type' => 'switch', 'default' => true, 'description' => '自动从文章内容中提取第一张图片作为缩略图'], + ['key' => 'thumb_catimg_s', 'label' => '自动获取分类封面', 'type' => 'switch', 'default' => true, 'description' => '无特色图时使用分类封面图作为缩略图'], + ['key' => 'thumb_postfirstimg_size', 'label' => '首图尺寸', 'type' => 'radio', 'default' => 'medium', 'options' => [ + ['label' => '缩略图', 'value' => 'thumbnail'], + ['label' => '中等', 'value' => 'medium'], + ['label' => '大', 'value' => 'large'], + ['label' => '原始', 'value' => 'full'], + ], 'description' => '自动获取文章首图时使用的 WP 图片尺寸'], + ]], + ['key' => 'multi', 'title' => '多图模式', 'fields' => [ + ['key' => 'thumb_multi_enabled', 'label' => '多图卡片', 'type' => 'switch', 'default' => false], + ['key' => 'thumb_multi_count', 'label' => '最多显示几张', 'type' => 'number', 'default' => 3, 'min' => 2, 'max' => 6], + ['key' => 'mult_thumb_cat', 'label' => '按分类多图显示', 'type' => 'taxonomy_select', 'taxonomy' => 'category', 'multiple' => true, 'default' => []], + ['key' => 'thumb_lazy', 'label' => '懒加载', 'type' => 'switch', 'default' => true], + ]], + ], + ], + 'page' => [ + 'key' => 'page', + 'title' => '文章页面', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'navigation', 'title' => '文章导航', 'fields' => [ + ['key' => 'post_toc_enabled', 'label' => '目录树导航', 'type' => 'switch', 'default' => true], + ['key' => 'post_toc_depth', 'label' => '目录深度', 'type' => 'number', 'default' => 3, 'min' => 1, 'max' => 6], + ['key' => 'article_nav_mobile_nav_s', 'label' => '移动端弹出菜单', 'type' => 'switch', 'default' => true, 'description' => '在移动端弹出菜单中显示目录导航'], + ['key' => 'post_reading_progress', 'label' => '阅读进度条', 'type' => 'switch', 'default' => true], + ['key' => 'breadcrumbs_single_s', 'label' => '面包屑导航', 'type' => 'switch', 'default' => true], + ['key' => 'breadcrumbs_home_text', 'label' => '面包屑首页文字', 'type' => 'input', 'default' => '首页', 'depends_on' => 'breadcrumbs_single_s'], + ['key' => 'post_prevnext_s', 'label' => '上一篇/下一篇', 'type' => 'switch', 'default' => true], + ['key' => 'post_authordesc_s', 'label' => '作者简介', 'type' => 'switch', 'default' => true], + ['key' => 'post_single_hide_time_s', 'label' => '隐藏文章时间', 'type' => 'switch', 'default' => false], + ]], + ['key' => 'cover', 'title' => '封面展示', 'fields' => [ + ['key' => 'post_article_image_cover', 'label' => '图片封面', 'type' => 'switch', 'default' => true, 'description' => '文章有特色图时自动展示封面'], + ['key' => 'post_article_image_cover_scale', 'label' => '封面高度比例(%)', 'type' => 'number', 'default' => 35, 'min' => 10, 'max' => 100], + ['key' => 'post_article_slide_cover', 'label' => '幻灯片封面', 'type' => 'switch', 'default' => true, 'description' => '文章有图集时以轮播形式展示封面'], + ['key' => 'post_article_video_cover', 'label' => '视频封面', 'type' => 'switch', 'default' => true, 'description' => '文章有视频时以视频播放器作为封面'], + ['key' => 'post_article_video_cover_scale', 'label' => '视频封面高度比例(%)', 'type' => 'number', 'default' => 56, 'min' => 20, 'max' => 100], + ]], + ['key' => 'media', 'title' => '媒体功能', 'fields' => [ + ['key' => 'post_lightbox', 'label' => '图片灯箱', 'type' => 'switch', 'default' => true], + ['key' => 'post_lightbox_thumbs', 'label' => '灯箱缩略图', 'type' => 'switch', 'default' => true], + ['key' => 'post_lightbox_zoom', 'label' => '灯箱缩放', 'type' => 'switch', 'default' => true], + ['key' => 'post_lightbox_full', 'label' => '灯箱全屏', 'type' => 'switch', 'default' => true], + ['key' => 'post_lightbox_down', 'label' => '灯箱下载', 'type' => 'switch', 'default' => false], + ['key' => 'post_lightbox_play', 'label' => '灯箱播放', 'type' => 'switch', 'default' => false], + ]], + ['key' => 'related', 'title' => '相关推荐', 'fields' => [ + ['key' => 'post_related_enabled', 'label' => '显示相关推荐', 'type' => 'switch', 'default' => true], + ['key' => 'post_related_count', 'label' => '推荐数量', 'type' => 'number', 'default' => 6, 'min' => 1, 'max' => 20], + ['key' => 'post_related_type', 'label' => '推荐方式', 'type' => 'radio', 'default' => 'category', 'options' => [ + ['label' => '同分类', 'value' => 'category'], + ['label' => '同标签', 'value' => 'tag'], + ['label' => '随机', 'value' => 'random'], + ]], + ['key' => 'post_related_orderby', 'label' => '推荐排序', 'type' => 'select', 'default' => 'rand', 'options' => [ + ['label' => '随机', 'value' => 'rand'], + ['label' => '相关度', 'value' => 'relevance'], + ['label' => '时间', 'value' => 'date'], + ]], + ['key' => 'post_related_title', 'label' => '推荐标题', 'type' => 'input', 'default' => '相关推荐'], + ]], + ['key' => 'interactions', 'title' => '互动功能', 'fields' => [ + ['key' => 'post_like_enabled', 'label' => '点赞', 'type' => 'switch', 'default' => true], + ['key' => 'post_favorite_enabled', 'label' => '收藏', 'type' => 'switch', 'default' => true], + ['key' => 'share_s', 'label' => '分享按钮', 'type' => 'switch', 'default' => true], + ['key' => 'post_copyright_enabled', 'label' => '版权信息', 'type' => 'switch', 'default' => true], + ['key' => 'post_copyright_text', 'label' => '版权默认文案', 'type' => 'textarea', 'default' => '', 'description' => '留空则自动生成"本文由 XX 原创发布"'], + ['key' => 'post_wechat_share', 'label' => '微信分享', 'type' => 'switch', 'default' => false], + ['key' => 'post_rewards_enabled', 'label' => '打赏功能', 'type' => 'switch', 'default' => false], + ['key' => 'post_rewards_text', 'label' => '打赏文案', 'type' => 'input', 'default' => ''], + ['key' => 'post_button_toptext', 'label' => '按钮上方文案', 'type' => 'input', 'default' => '喜欢就支持一下吧'], + ]], + ['key' => 'content', 'title' => '内容处理', 'fields' => [ + ['key' => 'post_p_indent', 'label' => '段落缩进', 'type' => 'switch', 'default' => false], + ['key' => 'post_maxheight_kg', 'label' => '内容高度限制', 'type' => 'switch', 'default' => false], + ['key' => 'post_maxheight', 'label' => '最大高度', 'type' => 'number', 'default' => 1000, 'min' => 600, 'max' => 3000], + ['key' => 'post_front_content', 'label' => '文章头部内容', 'type' => 'textarea', 'default' => ''], + ['key' => 'post_after_content', 'label' => '文章底部内容', 'type' => 'textarea', 'default' => ''], + ]], + ['key' => 'yiyan', 'title' => '一言', 'fields' => [ + ['key' => 'post_yiyan_header', 'label' => '文章头部一言', 'type' => 'switch', 'default' => false], + ['key' => 'post_yiyan_footer', 'label' => '文章底部一言', 'type' => 'switch', 'default' => false], + ['key' => 'post_yiyan_box', 'label' => '一言盒子', 'type' => 'switch', 'default' => false], + ]], + ['key' => 'meta', 'title' => 'Meta设置', 'fields' => [ + ['key' => 'post_default_views_min', 'label' => '默认浏览最小值', 'type' => 'number', 'default' => 20, 'min' => 0, 'max' => 9999], + ['key' => 'post_default_views_max', 'label' => '默认浏览最大值', 'type' => 'number', 'default' => 50, 'min' => 1, 'max' => 9999], + ['key' => 'post_default_likes_min', 'label' => '默认点赞最小值', 'type' => 'number', 'default' => 5, 'min' => 0, 'max' => 9999], + ['key' => 'post_default_likes_max', 'label' => '默认点赞最大值', 'type' => 'number', 'default' => 15, 'min' => 1, 'max' => 9999], + ['key' => 'post_views_s', 'label' => '浏览统计', 'type' => 'switch', 'default' => true], + ['key' => 'post_close_comments', 'label' => '关闭评论', 'type' => 'switch', 'default' => false], + ]], + ], + ], + 'filter' => [ + 'key' => 'filter', + 'title' => '高级筛选', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'basic', 'title' => '筛选基础', 'fields' => [ + ['key' => 'filter_enabled', 'label' => '启用筛选栏', 'type' => 'switch', 'default' => false], + ['key' => 'custom_filter_show', 'label' => '筛选显示页面', 'type' => 'checkbox', 'default' => ['cat', 'tag'], 'options' => [ + ['label' => '分类页', 'value' => 'cat'], + ['label' => '标签页', 'value' => 'tag'], + ['label' => '专题页', 'value' => 'topics'], + ], 'help' => '选择在哪些页面显示筛选栏'], + ['key' => 'filter_categories', 'label' => '分类筛选', 'type' => 'switch', 'default' => true], + ['key' => 'filter_tags', 'label' => '标签筛选', 'type' => 'switch', 'default' => false], + ['key' => 'filter_sort', 'label' => '排序选项', 'type' => 'switch', 'default' => true], + ['key' => 'filter_ajax_enabled', 'label' => 'AJAX 无刷新筛选', 'type' => 'switch', 'default' => true, 'help' => '启用后分类/标签/排序切换将通过 AJAX 加载,无需刷新页面'], + ]], + ['key' => 'ajax_dimensions', 'title' => 'AJAX 筛选维度', 'fields' => [ + ['key' => 'filter_cat_show_cat', 'label' => '分类页:显示分类筛选', 'type' => 'switch', 'default' => true], + ['key' => 'filter_cat_show_tag', 'label' => '分类页:显示标签筛选', 'type' => 'switch', 'default' => false], + ['key' => 'filter_tag_show_cat', 'label' => '标签页:显示分类筛选', 'type' => 'switch', 'default' => true], + ['key' => 'filter_tag_show_tag', 'label' => '标签页:显示标签筛选', 'type' => 'switch', 'default' => false], + ['key' => 'filter_topics_show_cat', 'label' => '专题页:显示分类筛选', 'type' => 'switch', 'default' => true], + ['key' => 'filter_topics_show_tag', 'label' => '专题页:显示标签筛选', 'type' => 'switch', 'default' => false], + ]], + ['key' => 'custom_filter', 'title' => '自定义筛选维度', 'fields' => [ + ['key' => 'custom_filter', 'label' => '筛选维度定义', 'type' => 'repeater', 'default' => [], 'help' => '每项定义一个筛选维度(如"类型""地区""年份"),及其可选值列表', 'fields' => [ + ['key' => 'name', 'label' => '维度名称', 'type' => 'input', 'default' => ''], + ['key' => 'key', 'label' => '维度标识', 'type' => 'input', 'default' => ''], + ['key' => 'vals', 'label' => '可选值', 'type' => 'repeater', 'default' => [], 'fields' => [ + ['key' => 'key', 'label' => '标识', 'type' => 'input', 'default' => ''], + ['key' => 'name', 'label' => '显示名', 'type' => 'input', 'default' => ''], + ]], + ]], + ]], + ['key' => 'cat_custom_filter', 'title' => '按分类绑定筛选', 'fields' => [ + ['key' => 'cat_custom_filter', 'label' => '分类筛选绑定', 'type' => 'repeater', 'default' => [], 'help' => '为指定分类绑定不同的筛选维度组合', 'fields' => [ + ['key' => 'cats', 'label' => '绑定分类', 'type' => 'taxonomy_select', 'taxonomy' => 'category', 'multiple' => true, 'default' => []], + ['key' => 'cat_s', 'label' => '显示分类筛选', 'type' => 'switch', 'default' => true], + ['key' => 'tag_s', 'label' => '显示标签筛选', 'type' => 'switch', 'default' => false], + ['key' => 'topics_s', 'label' => '显示专题筛选', 'type' => 'switch', 'default' => false], + ['key' => 'orderby_s', 'label' => '显示排序按钮', 'type' => 'switch', 'default' => true], + ['key' => 'custom_filters', 'label' => '启用的自定义维度', 'type' => 'input', 'default' => '', 'help' => '逗号分隔维度标识,留空使用全部'], + ]], + ]], + ], + ], + 'feature' => [ + 'key' => 'feature', + 'title' => '文章功能', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'tools', 'title' => '工具功能', 'fields' => [ + ['key' => 'feature_print', 'label' => '打印文章', 'type' => 'switch', 'default' => true], + ['key' => 'feature_word_count', 'label' => '字数统计', 'type' => 'switch', 'default' => true], + ['key' => 'feature_reading_time', 'label' => '预计阅读时间', 'type' => 'switch', 'default' => true], + ['key' => 'feature_pdf_export', 'label' => 'PDF 导出', 'type' => 'switch', 'default' => false], + ]], + ], + ], + 'comment' => [ + 'key' => 'comment', + 'title' => '评论设置', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'display', 'title' => '评论展示', 'fields' => [ + ['key' => 'comment_order', 'label' => '评论排序', 'type' => 'radio', 'default' => 'asc', 'options' => [ + ['label' => '正序', 'value' => 'asc'], + ['label' => '倒序', 'value' => 'desc'], + ]], + ['key' => 'comment_per_page', 'label' => '每页评论数', 'type' => 'number', 'default' => 20, 'min' => 5, 'max' => 100], + ['key' => 'comment_pagination_type', 'label' => '分页方式', 'type' => 'radio', 'default' => 'numbered', 'options' => [ + ['label' => '翻页', 'value' => 'numbered'], + ['label' => '加载更多', 'value' => 'load_more'], + ['label' => '无限滚动', 'value' => 'infinite'], + ]], + ['key' => 'comment_paging_ajax_ias_s', 'label' => '评论自动加载', 'type' => 'switch', 'default' => true, 'description' => '评论无限滚动时自动触发加载'], + ['key' => 'comment_paging_ajax_ias_max', 'label' => '评论最大加载页数', 'type' => 'number', 'default' => 3, 'min' => 0, 'max' => 10, 'description' => '0=不限'], + ['key' => 'comment_nested_depth', 'label' => '回复嵌套深度', 'type' => 'number', 'default' => 3, 'min' => 1, 'max' => 10], + ['key' => 'comment_avatar_size', 'label' => '头像大小', 'type' => 'number', 'default' => 48, 'min' => 24, 'max' => 96], + ]], + ['key' => 'permissions', 'title' => '评论权限', 'fields' => [ + ['key' => 'comment_require_login', 'label' => '评论需要登录', 'type' => 'switch', 'default' => false], + ['key' => 'comment_allow_reply', 'label' => '允许回复', 'type' => 'switch', 'default' => true], + ['key' => 'comment_close_comments', 'label' => '关闭评论', 'type' => 'switch', 'default' => false], + ['key' => 'comment_edit_window', 'label' => '作者自助编辑时限(分钟)', 'type' => 'number', 'default' => 10, 'min' => 0, 'max' => 1440], + ]], + ['key' => 'toolbar', 'title' => '评论工具栏', 'fields' => [ + ['key' => 'comment_smilie', 'label' => 'Emoji表情', 'type' => 'switch', 'default' => true], + ['key' => 'comment_code', 'label' => '代码插入', 'type' => 'switch', 'default' => true], + ['key' => 'comment_img', 'label' => '图片上传', 'type' => 'switch', 'default' => true], + ['key' => 'comment_upload_img', 'label' => '评论图片上传', 'type' => 'switch', 'default' => false], + ]], + ['key' => 'interaction', 'title' => '互动功能', 'fields' => [ + ['key' => 'comment_like_s', 'label' => '评论点赞', 'type' => 'switch', 'default' => true], + ['key' => 'comment_quick_s', 'label' => '快捷回复', 'type' => 'switch', 'default' => true], + ['key' => 'comment_city_s', 'label' => '评论城市', 'type' => 'switch', 'default' => true], + ['key' => 'comment_author_tag', 'label' => '作者标签', 'type' => 'switch', 'default' => true], + ['key' => 'comment_corderby', 'label' => '评论排序切换', 'type' => 'switch', 'default' => true, 'description' => '前台显示最新/最早/最热切换按钮'], + ]], + ['key' => 'text', 'title' => '文案配置', 'fields' => [ + ['key' => 'comment_empty_text', 'label' => '空评论提示', 'type' => 'input', 'default' => '暂无评论,来做第一个评论的人吧~'], + ['key' => 'comment_title', 'label' => '评论区标题', 'type' => 'input', 'default' => '评论'], + ['key' => 'comment_submit_text', 'label' => '提交按钮文案', 'type' => 'input', 'default' => '提交评论'], + ['key' => 'comment_text', 'label' => '评论框提示', 'type' => 'input', 'default' => '说点什么吧...'], + ]], + ], + ], + 'pay' => [ + 'key' => 'pay', + 'title' => '付费内容', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'basic', 'title' => '付费设置', 'fields' => [ + ['key' => 'post_pay_enable', 'label' => '启用付费内容', 'type' => 'switch', 'default' => false], + ['key' => 'post_pay_price_default', 'label' => '默认价格', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 9999], + ['key' => 'post_pay_free_logged_show', 'label' => '登录用户显示免费', 'type' => 'switch', 'default' => false], + ['key' => 'post_pay_box_position', 'label' => '付费框位置', 'type' => 'radio', 'default' => 'content', 'options' => [ + ['label' => '内容前', 'value' => 'content'], + ['label' => '全文遮罩', 'value' => 'mask'], + ]], + ['key' => 'post_pay_mark', 'label' => '付费标记', 'type' => 'input', 'default' => '付费'], + ['key' => 'post_pay_show_user', 'label' => '显示购买用户', 'type' => 'switch', 'default' => false], + ]], + ['key' => 'download', 'title' => '下载设置', 'fields' => [ + ['key' => 'post_download_enable', 'label' => '启用下载功能', 'type' => 'switch', 'default' => false], + ['key' => 'post_download_remote_s', 'label' => '远程文件下载', 'type' => 'switch', 'default' => false], + ['key' => 'post_download_verify_nonce', 'label' => '下载鉴权', 'type' => 'switch', 'default' => true], + ['key' => 'post_download_user_log', 'label' => '用户下载日志', 'type' => 'switch', 'default' => true], + ['key' => 'post_download_post_log', 'label' => '文章下载日志', 'type' => 'switch', 'default' => true], + ]], + ], + ], + 'share' => [ + 'key' => 'share', + 'title' => '分享设置', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'basic', 'title' => '分享设置', 'fields' => [ + ['key' => 'share_wechat_s', 'label' => '微信分享', 'type' => 'switch', 'default' => false], + ['key' => 'share_wechat_appid', 'label' => 'AppID', 'type' => 'input', 'default' => ''], + ['key' => 'share_wechat_app_secret', 'label' => 'AppSecret', 'type' => 'input', 'default' => ''], + ['key' => 'share_only_logo', 'label' => '仅显示Logo', 'type' => 'switch', 'default' => false], + ['key' => 'share_img_compatible_s', 'label' => '海报兼容模式', 'type' => 'switch', 'default' => false, 'description' => '启用后使用兼容模式生成分享海报'], + ['key' => 'share_img_byimg', 'label' => '海报默认图', 'type' => 'media', 'default' => ''], + ['key' => 'share_logo', 'label' => '海报LOGO', 'type' => 'media', 'default' => ''], + ['key' => 'share_img', 'label' => '分享图片', 'type' => 'media', 'default' => ''], + ['key' => 'share_desc', 'label' => '分享描述', 'type' => 'textarea', 'default' => '扫一扫 立即查看'], + ]], + ], + ], + 'submit' => [ + 'key' => 'submit', + 'title' => '前台投稿', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'basic', 'title' => '基础配置', 'fields' => [ + ['key' => 'front_submit_enabled', 'label' => '开启前台投稿', 'type' => 'switch', 'default' => true], + ['key' => 'front_submit_roles', 'label' => '允许投稿的角色', 'type' => 'input', 'default' => 'author,editor,administrator'], + ['key' => 'post_article_btn_txte', 'label' => '投稿按钮文案', 'type' => 'input', 'default' => '发布文章'], + ['key' => 'post_article_title_strlen_limit_min', 'label' => '标题最少字数', 'type' => 'number', 'default' => 5, 'min' => 1, 'max' => 100], + ['key' => 'post_article_title_strlen_limit_max', 'label' => '标题最多字数', 'type' => 'number', 'default' => 30, 'min' => 5, 'max' => 200], + ['key' => 'post_t_placeholder', 'label' => '标题占位符', 'type' => 'input', 'default' => '', 'description' => '留空使用默认'], + ['key' => 'post_c_placeholder', 'label' => '内容占位符', 'type' => 'textarea', 'default' => '', 'description' => '留空使用默认'], + ]], + ['key' => 'fields', 'title' => '表单字段', 'fields' => [ + ['key' => 'front_submit_title', 'label' => '标题', 'type' => 'switch', 'default' => true], + ['key' => 'front_submit_content', 'label' => '正文', 'type' => 'switch', 'default' => true], + ['key' => 'front_submit_excerpt', 'label' => '摘要', 'type' => 'switch', 'default' => true], + ['key' => 'front_submit_cover', 'label' => '封面图', 'type' => 'switch', 'default' => true], + ['key' => 'front_submit_category', 'label' => '分类选择', 'type' => 'switch', 'default' => true], + ['key' => 'post_article_cat', 'label' => '允许投稿分类', 'type' => 'taxonomy_select', 'taxonomy' => 'category', 'multiple' => true, 'default' => [], 'help' => '为空则允许全部分类'], + ['key' => 'post_article_user', 'label' => '免登录归属用户', 'type' => 'user_select', 'default' => 0, 'help' => '免登录投稿时文章归属的用户,搜索选择'], + ['key' => 'front_submit_tags', 'label' => '标签输入', 'type' => 'switch', 'default' => true], + ['key' => 'post_article_topics_s', 'label' => '专题选择', 'type' => 'switch', 'default' => false, 'help' => '投稿时可选择专题分类'], + ['key' => 'post_article_custom_filter_s', 'label' => '高级筛选选择', 'type' => 'switch', 'default' => true, 'help' => '投稿时可选择自定义筛选维度值'], + ['key' => 'front_submit_custom_fields', 'label' => '自定义字段', 'type' => 'textarea', 'default' => ''], + ]], + ['key' => 'review', 'title' => '审核策略', 'fields' => [ + ['key' => 'front_submit_review_mode', 'label' => '审核模式', 'type' => 'radio', 'default' => 'pending', 'options' => [ + ['label' => '待审核', 'value' => 'pending'], + ['label' => '直接发布', 'value' => 'publish'], + ['label' => '自动草稿', 'value' => 'draft'], + ]], + ['key' => 'front_submit_cover_required', 'label' => '封面图必填', 'type' => 'switch', 'default' => false], + ['key' => 'front_submit_max_images', 'label' => '正文最大图片数', 'type' => 'number', 'default' => 5, 'min' => 0, 'max' => 50], + ]], + ['key' => 'editor', 'title' => '编辑器设置', 'fields' => [ + ['key' => 'front_submit_editor_mode', 'label' => '编辑器模式', 'type' => 'radio', 'default' => 'rich', 'options' => [ + ['label' => '富文本', 'value' => 'rich'], + ['label' => 'Markdown', 'value' => 'markdown'], + ['label' => '纯文本', 'value' => 'plain'], + ]], + ['key' => 'front_submit_draft_save', 'label' => '自动保存草稿', 'type' => 'switch', 'default' => true], + ['key' => 'front_submit_leave_warning', 'label' => '离开页面提醒', 'type' => 'switch', 'default' => true], + ]], + ['key' => 'paid', 'title' => '付费投稿权限', 'fields' => [ + ['key' => 'post_article_pay_free_s', 'label' => '允许设置免费', 'type' => 'switch', 'default' => true], + ['key' => 'post_article_pay_price_min', 'label' => '最低价格', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 9999, 'help' => '投稿可设置的最低付费金额'], + ['key' => 'post_article_pay_price_max', 'label' => '最高价格', 'type' => 'number', 'default' => 100, 'min' => 0, 'max' => 9999, 'help' => '投稿可设置的最高付费金额'], + ['key' => 'post_article_pay_vip_price_s', 'label' => '允许设置会员价', 'type' => 'switch', 'default' => true], + ['key' => 'post_article_pay_vip_1_discount', 'label' => 'VIP1 折扣(%)', 'type' => 'number', 'default' => 80, 'min' => 0, 'max' => 100, 'help' => 'VIP1 会员折扣百分比,80 即 8 折'], + ['key' => 'post_article_pay_vip_2_discount', 'label' => 'VIP2 折扣(%)', 'type' => 'number', 'default' => 50, 'min' => 0, 'max' => 100, 'help' => 'VIP2 会员折扣百分比,50 即 5 折'], + ]], + ], + ], + ], + ]; + } +} diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-shop-basic.php b/inc/classes/admin/schema/class-aksrt-admin-schema-shop-basic.php new file mode 100644 index 00000000..472cae19 --- /dev/null +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-shop-basic.php @@ -0,0 +1,37 @@ + 'basic', + 'title' => '商城基础', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'general', 'title' => '商城开关', 'fields' => [ + ['key' => 'shop_s', 'label' => '启用商城', 'type' => 'switch', 'default' => false], + ['key' => 'shop_home_url', 'label' => '商城首页链接', 'type' => 'input', 'default' => ''], + ]], + ['key' => 'breadcrumbs', 'title' => '面包屑', 'fields' => [ + ['key' => 'shop_breadcrumbs_s', 'label' => '显示面包屑', 'type' => 'switch', 'default' => true], + ['key' => 'shop_breadcrumbs_home', 'label' => '显示首页链接', 'type' => 'switch', 'default' => true], + ['key' => 'shop_breadcrumbs_shop_home', 'label' => '显示商城首页', 'type' => 'switch', 'default' => true], + ['key' => 'shop_breadcrumbs_shop_home_name', 'label' => '商城首页名称', 'type' => 'input', 'default' => '商城首页'], + ]], + ['key' => 'rewrite', 'title' => 'URL 别名', 'fields' => [ + ['key' => 'shop_product_rewrite_slug', 'label' => '商品 URL 别名', 'type' => 'input', 'default' => 'product'], + ['key' => 'shop_cart_rewrite_slug', 'label' => '购物车 URL 别名', 'type' => 'input', 'default' => 'cart'], + ['key' => 'shop_rewrite_suffix_html_s', 'label' => '.html 伪静态后缀', 'type' => 'switch', 'default' => false], + ]], + ], + ]; + } +} diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-shop-contact.php b/inc/classes/admin/schema/class-aksrt-admin-schema-shop-contact.php new file mode 100644 index 00000000..23304ba1 --- /dev/null +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-shop-contact.php @@ -0,0 +1,30 @@ + 'contact', + 'title' => '客服配置', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'button', 'title' => '客服按钮', 'fields' => [ + ['key' => 'shop_contact_msg_s', 'label' => '显示客服按钮', 'type' => 'switch', 'default' => true], + ['key' => 'shop_contact_name', 'label' => '按钮文案', 'type' => 'input', 'default' => '联系客服'], + ['key' => 'shop_contact_note', 'label' => '客服备注', 'type' => 'textarea', 'default' => ''], + ]], + ['key' => 'channels', 'title' => '联系方式', 'fields' => [ + ['key' => 'shop_contact_more', 'label' => '多客服联系方式', 'type' => 'textarea', 'default' => '', 'help' => '每行一个:名称|图标|链接|二维码图片 URL'], + ]], + ], + ]; + } +} diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-shop-list.php b/inc/classes/admin/schema/class-aksrt-admin-schema-shop-list.php new file mode 100644 index 00000000..79b72a8b --- /dev/null +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-shop-list.php @@ -0,0 +1,51 @@ + 'list', + 'title' => '商品列表', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'pagination', 'title' => '列表分页', 'fields' => [ + ['key' => 'shop_list_orderby', 'label' => '默认排序', 'type' => 'select', 'default' => 'date', 'options' => [ + ['label' => '最新发布', 'value' => 'date'], + ['label' => '销量最高', 'value' => 'sales'], + ['label' => '价格最低', 'value' => 'price_asc'], + ['label' => '价格最高', 'value' => 'price_desc'], + ['label' => '评价最多', 'value' => 'comments'], + ]], + ['key' => 'shop_list_count', 'label' => '每页数量', 'type' => 'number', 'default' => 12, 'min' => 4, 'max' => 100], + ['key' => 'shop_list_paging', 'label' => '翻页模式', 'type' => 'radio', 'default' => 'number', 'options' => [ + ['label' => '数字翻页', 'value' => 'number'], + ['label' => 'AJAX 加载', 'value' => 'ajax'], + ]], + ['key' => 'shop_list_ias_s', 'label' => '滚动自动加载', 'type' => 'switch', 'default' => false], + ['key' => 'shop_list_ias_max', 'label' => '自动加载页数', 'type' => 'number', 'default' => 5, 'min' => 1, 'max' => 50], + ]], + ['key' => 'style', 'title' => '列表样式', 'fields' => [ + ['key' => 'shop_list_style', 'label' => '卡片样式', 'type' => 'select', 'default' => 'default', 'options' => [ + ['label' => '默认', 'value' => 'default'], + ['label' => '竖向大卡片', 'value' => 'card-lg'], + ['label' => '横向小卡片', 'value' => 'card-sm'], + ]], + ['key' => 'shop_sales_show', 'label' => '销量显示', 'type' => 'select', 'default' => 'show', 'options' => [ + ['label' => '显示', 'value' => 'show'], + ['label' => '不显示', 'value' => 'hide'], + ['label' => '超量显示', 'value' => 'threshold'], + ]], + ['key' => 'shop_sales_show_min', 'label' => '起显阈值', 'type' => 'number', 'default' => 10, 'min' => 0, 'max' => 99999], + ]], + ], + ]; + } +} diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-shop-product-defaults.php b/inc/classes/admin/schema/class-aksrt-admin-schema-shop-product-defaults.php new file mode 100644 index 00000000..9b30f489 --- /dev/null +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-shop-product-defaults.php @@ -0,0 +1,46 @@ + 'product-defaults', + 'title' => '商品默认参数', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'policy', 'title' => '购买策略', 'fields' => [ + ['key' => 'shop_limit_buy', 'label' => '默认限购', 'type' => 'number', 'default' => -1, 'min' => -1, 'max' => 9999, 'help' => '-1 不限,0 禁购,正数为限购数量'], + ['key' => 'shop_guest_buy', 'label' => '免登录购买', 'type' => 'switch', 'default' => false], + ['key' => 'shop_email_fill', 'label' => '邮箱必留', 'type' => 'switch', 'default' => false], + ['key' => 'shop_rebate', 'label' => '允许返佣', 'type' => 'switch', 'default' => true], + ]], + ['key' => 'shipping', 'title' => '运费默认', 'fields' => [ + ['key' => 'shop_shipping_fee', 'label' => '默认运费类型', 'type' => 'select', 'default' => 'free', 'options' => [ + ['label' => '包邮', 'value' => 'free'], + ['label' => '固定运费', 'value' => 'fixed'], + ['label' => '按金额计算', 'value' => 'amount'], + ]], + ]], + ['key' => 'afterSale', 'title' => '售后与服务', 'fields' => [ + ['key' => 'shop_after_sale', 'label' => '默认售后政策', 'type' => 'select', 'default' => 'refund_return', 'options' => [ + ['label' => '仅退款', 'value' => 'refund'], + ['label' => '退货退款', 'value' => 'refund_return'], + ['label' => '不支持售后', 'value' => 'none'], + ]], + ['key' => 'shop_service', 'label' => '服务保障', 'type' => 'textarea', 'default' => ''], + ]], + ['key' => 'params', 'title' => '默认参数模板', 'fields' => [ + ['key' => 'shop_product_params_default', 'label' => '商品参数模板', 'type' => 'textarea', 'default' => ''], + ]], + ], + ]; + } +} diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-shop-shipping-config.php b/inc/classes/admin/schema/class-aksrt-admin-schema-shop-shipping-config.php new file mode 100644 index 00000000..cff4e1d0 --- /dev/null +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-shop-shipping-config.php @@ -0,0 +1,41 @@ + 'shipping-config', + 'title' => '物流配置', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'address', 'title' => '收货地址', 'fields' => [ + ['key' => 'shop_manual_address', 'label' => '手动填写收货地址', 'type' => 'switch', 'default' => false], + ]], + ['key' => 'express', 'title' => '快递查询', 'fields' => [ + ['key' => 'shop_express_api_sdk', 'label' => '快递查询接口', 'type' => 'select', 'default' => 'kuaidi100', 'options' => [ + ['label' => '快递100', 'value' => 'kuaidi100'], + ['label' => '阿里云', 'value' => 'aliyun'], + ['label' => '快递鸟', 'value' => 'kdniao'], + ]], + ['key' => 'shop_express_query_interval', 'label' => '查询刷新间隔(分钟)', 'type' => 'number', 'default' => 60, 'min' => 30, 'max' => 240], + ]], + ['key' => 'credentials', 'title' => '接口凭据', 'fields' => [ + ['key' => 'shop_express_kdniao_opt', 'label' => '快递鸟凭据', 'type' => 'textarea', 'default' => ''], + ['key' => 'shop_express_kuaidi100_opt', 'label' => '快递100凭据', 'type' => 'textarea', 'default' => ''], + ['key' => 'shop_express_aliyun_opt', 'label' => '阿里云凭据', 'type' => 'textarea', 'default' => ''], + ]], + ['key' => 'companies', 'title' => '物流公司', 'fields' => [ + ['key' => 'shop_shipping_company', 'label' => '物流公司列表', 'type' => 'textarea', 'default' => ''], + ]], + ], + ]; + } +} diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-shop-single.php b/inc/classes/admin/schema/class-aksrt-admin-schema-shop-single.php new file mode 100644 index 00000000..500ae941 --- /dev/null +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-shop-single.php @@ -0,0 +1,39 @@ + 'single', + 'title' => '商品详情', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'layout', 'title' => '详情页布局', 'fields' => [ + ['key' => 'shop_content_layout', 'label' => '内容布局', 'type' => 'select', 'default' => 'adapt', 'options' => [ + ['label' => '适应宽度', 'value' => 'adapt'], + ['label' => '全屏', 'value' => 'full'], + ['label' => '适应宽度+侧栏', 'value' => 'sidebar'], + ]], + ['key' => 'shop_content_show_bg', 'label' => '详情背景盒子', 'type' => 'switch', 'default' => false], + ['key' => 'shop_single_tab', 'label' => '自定义 TAB 栏目', 'type' => 'textarea', 'default' => ''], + ['key' => 'shop_content_after', 'label' => '详情页底部内容', 'type' => 'textarea', 'default' => ''], + ]], + ['key' => 'related', 'title' => '相关推荐', 'fields' => [ + ['key' => 'shop_single_related_s', 'label' => '显示相关推荐', 'type' => 'switch', 'default' => true], + ['key' => 'shop_single_related_opt', 'label' => '推荐配置', 'type' => 'textarea', 'default' => ''], + ]], + ['key' => 'image', 'title' => '商品图片', 'fields' => [ + ['key' => 'shop_main_image_default', 'label' => '默认商品主图', 'type' => 'media', 'default' => ''], + ]], + ], + ]; + } +} diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-shop-timing.php b/inc/classes/admin/schema/class-aksrt-admin-schema-shop-timing.php new file mode 100644 index 00000000..aaafe59f --- /dev/null +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-shop-timing.php @@ -0,0 +1,38 @@ + 'timing', + 'title' => '时效与售后原因', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'receipt', 'title' => '确认收货', 'fields' => [ + ['key' => 'shop_order_receipt_max_day', 'label' => '自动确认收货(天)', 'type' => 'number', 'default' => 15, 'min' => 1, 'max' => 60], + ]], + ['key' => 'comment', 'title' => '评价时效', 'fields' => [ + ['key' => 'shop_comment_max_day', 'label' => '评价时效(天)', 'type' => 'number', 'default' => 7, 'min' => 1, 'max' => 60], + ['key' => 'shop_comment_placeholder', 'label' => '评价占位文案', 'type' => 'input', 'default' => ''], + ['key' => 'shop_comment_img_num', 'label' => '评价图片数', 'type' => 'number', 'default' => 3, 'min' => 0, 'max' => 9], + ]], + ['key' => 'afterSale', 'title' => '售后退货', 'fields' => [ + ['key' => 'shop_after_sale_return_express_max_day', 'label' => '退货快递时效(天)', 'type' => 'number', 'default' => 7, 'min' => 1, 'max' => 30], + ['key' => 'shop_after_sale_reason_refund', 'label' => '仅退款原因', 'type' => 'textarea', 'default' => ''], + ['key' => 'shop_after_sale_reason_return', 'label' => '退货退款原因', 'type' => 'textarea', 'default' => ''], + ]], + ['key' => 'author', 'title' => '商家信息', 'fields' => [ + ['key' => 'shop_author_show', 'label' => '显示商家信息', 'type' => 'switch', 'default' => true], + ]], + ], + ]; + } +} diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-shop.php b/inc/classes/admin/schema/class-aksrt-admin-schema-shop.php new file mode 100644 index 00000000..0a2e3ddb --- /dev/null +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-shop.php @@ -0,0 +1,28 @@ + 'shop', + 'title' => '商城&商品', + 'version' => '1.0.0', + 'sections' => [ + 'basic' => AKSRT_Admin_Schema_Shop_Basic::build(), + 'list' => AKSRT_Admin_Schema_Shop_List::build(), + 'contact' => AKSRT_Admin_Schema_Shop_Contact::build(), + 'single' => AKSRT_Admin_Schema_Shop_Single::build(), + 'product-defaults' => AKSRT_Admin_Schema_Shop_Product_Defaults::build(), + 'shipping-config' => AKSRT_Admin_Schema_Shop_Shipping_Config::build(), + 'timing' => AKSRT_Admin_Schema_Shop_Timing::build(), + ], + ]; + } +} diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-tools-backup.php b/inc/classes/admin/schema/class-aksrt-admin-schema-tools-backup.php new file mode 100644 index 00000000..cd07b7af --- /dev/null +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-tools-backup.php @@ -0,0 +1,32 @@ + 'backup', + 'title' => '数据备份', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'backup', 'title' => '备份设置', 'fields' => [ + ['key' => 'backup_auto', 'label' => '自动备份', 'type' => 'switch', 'help' => '启用自动定时备份'], + ['key' => 'backup_frequency', 'label' => '备份频率', 'type' => 'select', 'default' => 'weekly', 'options' => [ + ['label' => '每天', 'value' => 'daily'], + ['label' => '每周', 'value' => 'weekly'], + ['label' => '每月', 'value' => 'monthly'], + ]], + ['key' => 'backup_retain', 'label' => '保留份数', 'type' => 'number', 'default' => 7, 'min' => 3, 'max' => 20], + ['key' => 'backup_now', 'label' => '立即备份', 'type' => 'button', 'help' => '手动触发一次完整备份'], + ]], + ], + ]; + } +} diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-tools-email-rollback.php b/inc/classes/admin/schema/class-aksrt-admin-schema-tools-email-rollback.php new file mode 100644 index 00000000..78eded83 --- /dev/null +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-tools-email-rollback.php @@ -0,0 +1,32 @@ + 'email-rollback', + 'title' => '邮件回滚', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'rollback', 'title' => '邮件回滚', 'fields' => [ + ['key' => 'rollback_type', 'label' => '回滚类型', 'type' => 'select', 'options' => [ + ['label' => '按时间范围', 'value' => 'date_range'], + ['label' => '按邮件类型', 'value' => 'email_type'], + ['label' => '全部回滚', 'value' => 'all'], + ]], + ['key' => 'rollback_date_from', 'label' => '开始日期', 'type' => 'date'], + ['key' => 'rollback_date_to', 'label' => '结束日期', 'type' => 'date'], + ['key' => 'rollback_execute', 'label' => '执行回滚', 'type' => 'button', 'danger' => true, 'help' => '此操作不可逆'], + ]], + ], + ]; + } +} diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-tools-health.php b/inc/classes/admin/schema/class-aksrt-admin-schema-tools-health.php new file mode 100644 index 00000000..2ddc5c6a --- /dev/null +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-tools-health.php @@ -0,0 +1,29 @@ + 'health', + 'title' => '健康检查', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'check', 'title' => '环境检查', 'fields' => [ + ['key' => 'php_version', 'label' => 'PHP 版本', 'type' => 'display', 'help' => '建议 7.4+'], + ['key' => 'wp_version', 'label' => 'WordPress 版本', 'type' => 'display', 'help' => '建议 5.8+'], + ['key' => 'memory_limit', 'label' => '内存限制', 'type' => 'display', 'help' => '建议 256M+'], + ['key' => 'upload_max_size', 'label' => '上传限制', 'type' => 'display'], + ['key' => 'extensions', 'label' => '必要扩展', 'type' => 'display', 'help' => 'curl, mbstring, json, gd'], + ]], + ], + ]; + } +} diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-tools-import-export.php b/inc/classes/admin/schema/class-aksrt-admin-schema-tools-import-export.php new file mode 100644 index 00000000..a4c23437 --- /dev/null +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-tools-import-export.php @@ -0,0 +1,29 @@ + 'import-export', + 'title' => '导入导出', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'export', 'title' => '导出配置', 'fields' => [ + ['key' => 'export_settings', 'label' => '导出主题设置', 'type' => 'button', 'help' => '导出当前所有主题配置为 JSON 文件'], + ['key' => 'export_options', 'label' => '导出 WordPress 选项', 'type' => 'button', 'help' => '导出 aksrt_settings 等选项'], + ]], + ['key' => 'import', 'title' => '导入配置', 'fields' => [ + ['key' => 'import_settings', 'label' => '导入主题设置', 'type' => 'file', 'accept' => '.json', 'help' => '导入主题配置文件(将覆盖当前设置)'], + ]], + ], + ]; + } +} diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-tools-migration.php b/inc/classes/admin/schema/class-aksrt-admin-schema-tools-migration.php new file mode 100644 index 00000000..306c1fc3 --- /dev/null +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-tools-migration.php @@ -0,0 +1,27 @@ + 'migration', + 'title' => '数据迁移', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'status', 'title' => '迁移状态', 'fields' => [ + ['key' => 'current_version', 'label' => '当前版本', 'type' => 'display'], + ['key' => 'pending_migrations', 'label' => '待执行迁移', 'type' => 'display'], + ['key' => 'run_migration', 'label' => '执行迁移', 'type' => 'button', 'help' => '手动触发数据库迁移'], + ]], + ], + ]; + } +} diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-tools.php b/inc/classes/admin/schema/class-aksrt-admin-schema-tools.php new file mode 100644 index 00000000..363ed30f --- /dev/null +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-tools.php @@ -0,0 +1,25 @@ + 'tools', + 'title' => '工具&诊断', + 'sections' => [ + 'import-export' => AKSRT_Admin_Schema_Tools_Import_Export::build(), + 'health' => AKSRT_Admin_Schema_Tools_Health::build(), + 'migration' => AKSRT_Admin_Schema_Tools_Migration::build(), + 'backup' => AKSRT_Admin_Schema_Tools_Backup::build(), + 'email-rollback' => AKSRT_Admin_Schema_Tools_Email_Rollback::build(), + ], + ]; + } +} diff --git a/inc/classes/admin/schema/class-aksrt-admin-schema-users.php b/inc/classes/admin/schema/class-aksrt-admin-schema-users.php new file mode 100644 index 00000000..ad3215b2 --- /dev/null +++ b/inc/classes/admin/schema/class-aksrt-admin-schema-users.php @@ -0,0 +1,697 @@ + 'users', + 'title' => '用户&互动', + 'sections' => [ + 'auth' => [ + 'key' => 'auth', + 'title' => '注册登录', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'register', 'title' => '注册设置', 'fields' => [ + ['key' => 'close_sign', 'label' => '一键禁用登录注册', 'type' => 'switch', 'default' => false, 'desc' => '开启后前台禁用全部注册/登录入口(含社交登录),不影响后台登录'], + ['key' => 'user_close_signup', 'label' => '关闭注册', 'type' => 'switch', 'default' => false], + ['key' => 'close_signin', 'label' => '禁用账密登录', 'type' => 'switch', 'default' => false, 'desc' => '仅允许社交账号登录'], + ['key' => 'user_agreement_s', 'label' => '用户协议', 'type' => 'switch', 'default' => true], + ['key' => 'user_agreement_page', 'label' => '协议页面', 'type' => 'input', 'default' => ''], + ['key' => 'user_privacy_s', 'label' => '隐私政策', 'type' => 'switch', 'default' => true], + ['key' => 'user_privacy_page', 'label' => '隐私页面', 'type' => 'input', 'default' => ''], + ['key' => 'user_agreement_check', 'label' => '强制勾选协议', 'type' => 'switch', 'default' => true], + ['key' => 'no_repetition_name', 'label' => '禁止重复昵称', 'type' => 'switch', 'default' => false], + ['key' => 'user_nickname_out', 'label' => '昵称黑名单关键词', 'type' => 'textarea', 'default' => "赌博,博彩,代发,代理,刷单,博彩,六合彩,admin,test,管理员,客服", 'desc' => '一行一个关键词,注册昵称包含关键词将被拒绝'], + ['key' => 'user_email_limit_type', 'label' => '邮箱限制类型', 'type' => 'radio', 'default' => 'black', 'options' => [ + ['label' => '黑名单(拒绝以下后缀)', 'value' => 'black'], + ['label' => '白名单(仅允许以下后缀)', 'value' => 'white'], + ]], + ['key' => 'user_email_out', 'label' => '邮箱限制后缀', 'type' => 'textarea', 'default' => "@tempmail.com\n@throwaway.email\n@guerrillamail.com", 'desc' => '一行一个邮箱后缀,如 @tempmail.com'], + ]], + ['key' => 'login', 'title' => '登录设置', 'fields' => [ + ['key' => 'user_close_login', 'label' => '关闭登录', 'type' => 'switch', 'default' => false], + ['key' => 'user_sign_type', 'label' => '登录注册样式', 'type' => 'radio', 'default' => 'page', 'options' => [ + ['label' => '弹窗', 'value' => 'modal'], + ['label' => '跳转页面', 'value' => 'page'], + ]], + ['key' => 'sign_modal_auto_s', 'label' => '自动弹出登录窗', 'type' => 'switch', 'default' => false, 'desc' => '仅在 user_sign_type=modal 时生效'], + ['key' => 'sign_modal_auto_waiting_time', 'label' => '弹窗等待秒数', 'type' => 'number', 'default' => 5, 'min' => 0, 'max' => 60, 'desc' => '页面加载多少秒后弹出登录窗'], + ['key' => 'sign_modal_auto_expires', 'label' => '弹窗周期(小时)', 'type' => 'number', 'default' => 1, 'min' => 0, 'max' => 720, 'desc' => '关闭弹窗后多少小时内不再自动弹出,0=每次刷新都弹'], + ['key' => 'replace_wp_login', 'label' => '替换WP登录页', 'type' => 'switch', 'default' => true], + ['key' => 'admin_sign_key', 'label' => '应急登录 Key', 'type' => 'input', 'default' => '', 'desc' => '留空为禁用。管理员可通过 /wp-login.php?aksrt_bypass= 绕过前台替换'], + ['key' => 'user_signin_phone_s', 'label' => '手机号登录', 'type' => 'switch', 'default' => false, 'desc' => '允许用手机号作为用户名登录'], + ['key' => 'user_signin_nopas_s', 'label' => '免密登录', 'type' => 'switch', 'default' => false], + ['key' => 'user_signin_nopas_type', 'label' => '免密登录方式', 'type' => 'radio', 'default' => 'email', 'options' => [ + ['label' => '邮箱验证码', 'value' => 'email'], + ['label' => '手机验证码', 'value' => 'phone'], + ['label' => '邮箱或手机', 'value' => 'email_phone'], + ]], + ['key' => 'user_signin_nopas_active', 'label' => '免密显示模式', 'type' => 'radio', 'default' => 'nopas_first', 'options' => [ + ['label' => '优先免密', 'value' => 'nopas_first'], + ['label' => '优先密码', 'value' => 'password_first'], + ['label' => '仅免密', 'value' => 'nopas_only'], + ]], + ]], + ['key' => 'signup_captcha', 'title' => '注册验证', 'fields' => [ + ['key' => 'user_signup_captch', 'label' => '注册需验证', 'type' => 'switch', 'default' => true, 'desc' => '注册时需完成邮箱或手机验证'], + ['key' => 'captch_type', 'label' => '验证方式', 'type' => 'radio', 'default' => 'email', 'options' => [ + ['label' => '邮箱', 'value' => 'email'], + ['label' => '手机', 'value' => 'phone'], + ['label' => '邮箱或手机', 'value' => 'email_phone'], + ]], + ['key' => 'user_signup_no_repas', 'label' => '无需重复密码', 'type' => 'switch', 'default' => false, 'desc' => '注册时不要求二次确认密码'], + ['key' => 'user_repas_captch_type', 'label' => '找回密码验证方式', 'type' => 'radio', 'default' => 'email', 'options' => [ + ['label' => '邮箱', 'value' => 'email'], + ['label' => '手机', 'value' => 'phone'], + ['label' => '邮箱或手机', 'value' => 'email_phone'], + ]], + ]], + ['key' => 'bind', 'title' => '绑定设置', 'fields' => [ + ['key' => 'bind_phone', 'label' => '绑定手机', 'type' => 'switch', 'default' => false], + ['key' => 'bind_reminder', 'label' => '绑定提醒', 'type' => 'radio', 'default' => 'close', 'options' => [ + ['label' => '提醒绑定邮箱', 'value' => 'email'], + ['label' => '提醒绑定手机', 'value' => 'phone'], + ['label' => '提醒绑定邮箱和手机', 'value' => 'email_phone'], + ['label' => '关闭', 'value' => 'close'], + ]], + ['key' => 'bind_reminder_text', 'label' => '提醒文案', 'type' => 'input', 'default' => '请绑定手机号以正常使用'], + ['key' => 'bind_reminder_expires', 'label' => '提醒周期(小时)', 'type' => 'number', 'default' => 24, 'min' => 1, 'max' => 720, 'desc' => '用户关闭提醒后多少小时内不再提醒'], + ['key' => 'mandatory_bind', 'label' => '强制绑定', 'type' => 'radio', 'default' => 'close', 'options' => [ + ['label' => '强制绑定邮箱', 'value' => 'email'], + ['label' => '强制绑定手机', 'value' => 'phone'], + ['label' => '强制绑定邮箱和手机', 'value' => 'email_phone'], + ['label' => '关闭', 'value' => 'close'], + ]], + ['key' => 'email_set_captch', 'label' => '绑定邮箱需验证', 'type' => 'switch', 'default' => true, 'desc' => '绑定邮箱时需发送验证码确认'], + ]], + ['key' => 'invite_inline', 'title' => '邀请码注册', 'fields' => [ + ['key' => 'invit_code_s', 'label' => '启用邀请码', 'type' => 'switch', 'default' => false, 'desc' => '与「邀请码」子页联动,此处开关控制注册表单是否显示邀请码字段'], + ['key' => 'invit_code_mode', 'label' => '邀请模式', 'type' => 'select', 'default' => 'open', 'options' => [ + ['label' => '开放注册(邀请码选填)', 'value' => 'open'], + ['label' => '必须邀请码', 'value' => 'must'], + ]], + ['key' => 'invit_code_open_desc', 'label' => '开放注册提示', 'type' => 'input', 'default' => '邀请码为选填,没有邀请码也可以注册'], + ['key' => 'invit_code_must_desc', 'label' => '必填提示', 'type' => 'input', 'default' => '请输入邀请码'], + ]], + ['key' => 'sign_page_ui', 'title' => '页面 UI 配置', 'fields' => [ + ['key' => 'sign_page_background', 'label' => '页面背景图', 'type' => 'gallery', 'default' => [], 'desc' => '登录注册页背景图,多图随机'], + ['key' => 'sign_theme_mode', 'label' => '主题模式', 'type' => 'radio', 'default' => 'default', 'options' => [ + ['label' => '默认', 'value' => 'default'], + ['label' => '亮色', 'value' => 'light'], + ['label' => '暗色', 'value' => 'dark'], + ]], + ['key' => 'sign_show_header', 'label' => '显示顶部导航', 'type' => 'switch', 'default' => true], + ['key' => 'sign_card_position', 'label' => '卡片位置', 'type' => 'radio', 'default' => 'center', 'options' => [ + ['label' => '靠左', 'value' => 'left'], + ['label' => '居中', 'value' => 'center'], + ['label' => '靠右', 'value' => 'right'], + ]], + ['key' => 'sign_show_logo', 'label' => '显示 LOGO', 'type' => 'switch', 'default' => true], + ['key' => 'sign_page_footer', 'label' => '页脚 HTML', 'type' => 'textarea', 'default' => '', 'desc' => '登录注册页自定义页脚内容,支持 HTML'], + ]], + ['key' => 'modal_ui', 'title' => '弹窗 UI 配置', 'fields' => [ + ['key' => 'sign_modal_background', 'label' => '弹窗背景图', 'type' => 'gallery', 'default' => [], 'desc' => '弹窗左侧背景图,多图随机'], + ['key' => 'sign_modal_show_logo', 'label' => '弹窗显示 LOGO', 'type' => 'switch', 'default' => true], + ]], + ['key' => 'sign_logo', 'title' => '登录框 LOGO', 'fields' => [ + ['key' => 'user_logo', 'label' => '日间 LOGO', 'type' => 'gallery', 'default' => [], 'desc' => '登录框专用日间 LOGO,留空使用站点全局 LOGO'], + ['key' => 'user_logo_dark', 'label' => '夜间 LOGO', 'type' => 'gallery', 'default' => [], 'desc' => '登录框专用夜间 LOGO,留空使用站点全局夜间 LOGO'], + ]], + ], + ], + 'notifications' => [ + 'key' => 'notifications', + 'title' => '消息通知', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'basic', 'title' => '基础设置', 'fields' => [ + ['key' => 'message_s', 'label' => '站内通知总开关', 'type' => 'switch', 'default' => true], + ['key' => 'msg_center_rewrite_slug', 'label' => '消息中心 URL 别名', 'type' => 'input', 'default' => 'message'], + ['key' => 'message_icon_show', 'label' => '通知图标位置', 'type' => 'checkbox', 'default' => ['nav_menu'], 'options' => [ + ['label' => '导航菜单', 'value' => 'nav_menu'], + ['label' => '用户头像', 'value' => 'avatar'], + ['label' => '移动端底部', 'value' => 'mobile_bar'], + ]], + ['key' => 'message_paginate_type', 'label' => '列表翻页模式', 'type' => 'radio', 'default' => 'ajax_lists', 'options' => [ + ['label' => 'AJAX 翻页', 'value' => 'ajax_lists'], + ['label' => '传统翻页', 'value' => 'default'], + ]], + ['key' => 'message_user_set', 'label' => '允许用户设置推送', 'type' => 'switch', 'default' => true, 'desc' => '允许用户在前台自行设置通知推送偏好'], + ]], + ['key' => 'types', 'title' => '通知类型', 'fields' => [ + ['key' => 'message_close_msg_type', 'label' => '关闭部分推送', 'type' => 'checkbox', 'default' => [], 'options' => [ + ['label' => '文章通知', 'value' => 'posts'], + ['label' => '评论回复', 'value' => 'comment'], + ['label' => '收藏通知', 'value' => 'favorite'], + ['label' => '点赞通知', 'value' => 'like'], + ['label' => '热门通知', 'value' => 'hot'], + ['label' => '关注通知', 'value' => 'followed'], + ['label' => '系统通知', 'value' => 'system'], + ['label' => '支付通知', 'value' => 'pay'], + ['label' => '提现回复', 'value' => 'withdraw_reply'], + ['label' => '勋章通知', 'value' => 'medal'], + ['label' => '认证回复', 'value' => 'auth_apply_reply'], + ], 'desc' => '勾选的类型将不向用户推送通知'], + ]], + ['key' => 'auto_clear', 'title' => '自动清理', 'fields' => [ + ['key' => 'message_auto_clear_s', 'label' => '自动清理已读消息', 'type' => 'switch', 'default' => false], + ['key' => 'message_auto_clear_time', 'label' => '过期天数', 'type' => 'number', 'default' => 365, 'min' => 1, 'max' => 3650, 'desc' => '已读消息超过此天数将被自动清理'], + ['key' => 'message_auto_clear_types', 'label' => '清理类型', 'type' => 'checkbox', 'default' => ['posts', 'comment', 'favorite', 'like', 'hot', 'followed', 'system'], 'options' => [ + ['label' => '文章通知', 'value' => 'posts'], + ['label' => '评论回复', 'value' => 'comment'], + ['label' => '收藏通知', 'value' => 'favorite'], + ['label' => '点赞通知', 'value' => 'like'], + ['label' => '热门通知', 'value' => 'hot'], + ['label' => '关注通知', 'value' => 'followed'], + ['label' => '系统通知', 'value' => 'system'], + ['label' => '支付通知', 'value' => 'pay'], + ['label' => '提现回复', 'value' => 'withdraw_reply'], + ['label' => '勋章通知', 'value' => 'medal'], + ['label' => '认证回复', 'value' => 'auth_apply_reply'], + ['label' => '下载通知', 'value' => 'download'], + ['label' => '积分变动', 'value' => 'credits'], + ['label' => '私信', 'value' => 'private'], + ['label' => '举报反馈', 'value' => 'report'], + ]], + ]], + ['key' => 'private', 'title' => '私信设置', 'fields' => [ + ['key' => 'private_s', 'label' => '启用私信功能', 'type' => 'switch', 'default' => true], + ['key' => 'private_smilie_s', 'label' => '支持表情', 'type' => 'switch', 'default' => true], + ['key' => 'private_code_s', 'label' => '支持代码', 'type' => 'switch', 'default' => false], + ['key' => 'private_image_s', 'label' => '支持图片', 'type' => 'switch', 'default' => true], + ['key' => 'private_upload_img', 'label' => '支持上传图片', 'type' => 'switch', 'default' => false], + ['key' => 'private_quick_s', 'label' => '快捷回复', 'type' => 'switch', 'default' => true], + ['key' => 'private_quick_often', 'label' => '常用快捷回复', 'type' => 'textarea', 'default' => "你好,请问有什么可以帮你的?\n感谢你的反馈\n已处理,请查收", 'desc' => '一行一条快捷回复文案'], + ['key' => 'private_submit_text', 'label' => '发送按钮文案', 'type' => 'input', 'default' => '发送'], + ['key' => 'private_placeholder', 'label' => '输入占位符', 'type' => 'input', 'default' => '请输入消息...'], + ]], + ], + ], + 'oauth' => [ + 'key' => 'oauth', + 'title' => '第三方登录', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'basic', 'title' => '基础设置', 'fields' => [ + ['key' => 'oauth_s', 'label' => '启用第三方登录', 'type' => 'switch', 'default' => false], + ['key' => 'oauth_proxy_mode', 'label' => '代理登录模式', 'type' => 'radio', 'default' => 'close', 'options' => [ + ['label' => '关闭', 'value' => 'close'], + ['label' => '服务端', 'value' => 'server'], + ['label' => '客户端', 'value' => 'client'], + ]], + ['key' => 'oauth_proxy_secret', 'label' => '代理 HMAC 密钥', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'oauth_proxy_mode', 'not' => 'close']], + ['key' => 'oauth_proxy_server_url', 'label' => '服务端地址', 'type' => 'input', 'default' => '', 'dependsOn' => ['key' => 'oauth_proxy_mode', 'value' => 'client']], + ['key' => 'oauth_proxy_server_providers', 'label' => '服务端允许平台', 'type' => 'checkbox', 'default' => ['github', 'gitee', 'qq', 'google'], 'options' => [ + ['label' => 'GitHub', 'value' => 'github'], + ['label' => 'Gitee', 'value' => 'gitee'], + ['label' => 'QQ', 'value' => 'qq'], + ['label' => 'Google', 'value' => 'google'], + ['label' => '微博', 'value' => 'weibo'], + ['label' => '微信', 'value' => 'weixin'], + ['label' => '百度', 'value' => 'baidu'], + ['label' => '支付宝', 'value' => 'alipay'], + ], 'dependsOn' => ['key' => 'oauth_proxy_mode', 'value' => 'server']], + ['key' => 'oauth_proxy_client_providers', 'label' => '客户端启用平台', 'type' => 'checkbox', 'default' => ['github', 'gitee', 'qq', 'google'], 'options' => [ + ['label' => 'GitHub', 'value' => 'github'], + ['label' => 'Gitee', 'value' => 'gitee'], + ['label' => 'QQ', 'value' => 'qq'], + ['label' => 'Google', 'value' => 'google'], + ['label' => '微博', 'value' => 'weibo'], + ['label' => '微信', 'value' => 'weixin'], + ['label' => '百度', 'value' => 'baidu'], + ['label' => '支付宝', 'value' => 'alipay'], + ], 'dependsOn' => ['key' => 'oauth_proxy_mode', 'value' => 'client']], + ['key' => 'oauth_proxy_gzh_type', 'label' => '代理公众号类型', 'type' => 'radio', 'default' => 'service', 'options' => [ + ['label' => '服务号', 'value' => 'service'], + ['label' => '订阅号', 'value' => 'subscribe'], + ], 'dependsOn' => ['key' => 'oauth_proxy_mode', 'not' => 'close']], + ['key' => 'oauth_bind_type', 'label' => '绑定方式', 'type' => 'radio', 'default' => 'auto', 'options' => [ + ['label' => '自动绑定', 'value' => 'auto'], + ['label' => '手动绑定', 'value' => 'manual'], + ]], + ['key' => 'oauth_button_lg', 'label' => '大按钮样式', 'type' => 'switch', 'default' => false], + ['key' => 'weixingzh_priority', 'label' => 'PC端优先微信扫码', 'type' => 'switch', 'default' => false, 'help' => '开启后 PC 端登录框优先显示微信公众号扫码'], + ['key' => 'weixingzh_auto', 'label' => '微信APP内自动登录', 'type' => 'switch', 'default' => false, 'help' => '在微信内置浏览器中自动使用公众号授权登录'], + ['key' => 'weixingzh_auto_expires', 'label' => '自动登录频率限制 (秒)', 'type' => 'number', 'default' => 3600, 'min' => 0, 'max' => 86400, 'dependsOn' => ['key' => 'weixingzh_auto', 'value' => true]], + ]], + ['key' => 'providers', 'title' => '登录提供商', 'fields' => [ + ['key' => 'oauth_github_s', 'label' => 'GitHub登录', 'type' => 'switch', 'default' => false], + ['key' => 'oauth_gitee_s', 'label' => 'Gitee登录', 'type' => 'switch', 'default' => false], + ['key' => 'oauth_qq_s', 'label' => 'QQ登录', 'type' => 'switch', 'default' => false], + ['key' => 'oauth_weixingzh_s', 'label' => '微信公众号登录', 'type' => 'switch', 'default' => false], + ['key' => 'oauth_weixin_s', 'label' => '微信开放平台登录', 'type' => 'switch', 'default' => false], + ['key' => 'oauth_weibo_s', 'label' => '微博登录', 'type' => 'switch', 'default' => false], + ['key' => 'oauth_baidu_s', 'label' => '百度登录', 'type' => 'switch', 'default' => false], + ['key' => 'oauth_alipay_s', 'label' => '支付宝登录', 'type' => 'switch', 'default' => false], + ]], + ['key' => 'github', 'title' => 'GitHub配置', 'fields' => [ + ['key' => 'github_appid', 'label' => 'Client ID', 'type' => 'input', 'default' => ''], + ['key' => 'github_appkey', 'label' => 'Client Secret', 'type' => 'input', 'default' => ''], + ]], + ['key' => 'gitee', 'title' => 'Gitee配置', 'fields' => [ + ['key' => 'gitee_appid', 'label' => 'Client ID', 'type' => 'input', 'default' => ''], + ['key' => 'gitee_appkey', 'label' => 'Client Secret', 'type' => 'input', 'default' => ''], + ]], + ['key' => 'qq', 'title' => 'QQ配置', 'fields' => [ + ['key' => 'qq_appid', 'label' => 'App ID', 'type' => 'input', 'default' => ''], + ['key' => 'qq_appkey', 'label' => 'App Key', 'type' => 'input', 'default' => ''], + ]], + ['key' => 'weixin', 'title' => '微信开放平台配置', 'fields' => [ + ['key' => 'weixin_appid', 'label' => 'AppID', 'type' => 'input', 'default' => ''], + ['key' => 'weixin_appsecret', 'label' => 'AppSecret', 'type' => 'input', 'default' => ''], + ]], + ['key' => 'weixingzh', 'title' => '微信公众号配置', 'fields' => [ + ['key' => 'weixingzh_gzh_type', 'label' => '公众号类型', 'type' => 'radio', 'default' => 'service', 'options' => [ + ['label' => '服务号', 'value' => 'service'], + ['label' => '订阅号', 'value' => 'subscribe'], + ]], + ['key' => 'weixingzh_token', 'label' => 'Token', 'type' => 'input', 'default' => ''], + ['key' => 'weixingzh_token_nocheck', 'label' => '不验证Token', 'type' => 'switch', 'default' => false, 'help' => '开发调试时可跳过 Token 验证'], + ['key' => 'weixingzh_encoding_aes_key', 'label' => 'EncodingAESKey', 'type' => 'input', 'default' => ''], + ['key' => 'weixingzh_appid', 'label' => 'AppID', 'type' => 'input', 'default' => ''], + ['key' => 'weixingzh_appsecret', 'label' => 'AppSecret', 'type' => 'input', 'default' => ''], + ['key' => 'weixingzh_code_qrcode', 'label' => '公众号二维码', 'type' => 'media', 'default' => '', 'help' => '引导用户扫码关注登录的二维码图片'], + ['key' => 'weixingzh_code_keyword', 'label' => '登录回复关键词', 'type' => 'input', 'default' => '登录', 'help' => '用户在公众号内回复此关键词触发登录验证码'], + ['key' => 'weixingzh_code_reply', 'label' => '验证码回复模板', 'type' => 'textarea', 'default' => '您的验证码是:%s,有效期 %d 分钟。', 'help' => '%s=验证码,%d=有效分钟数'], + ['key' => 'weixingzh_code_subscribe_reply', 'label' => '新关注回复模板', 'type' => 'textarea', 'default' => '欢迎关注!回复「%s」获取登录验证码。', 'help' => '%s=登录关键词'], + ['key' => 'weixingzh_subscribe_msg', 'label' => '关注后回复', 'type' => 'textarea', 'default' => '', 'help' => '用户关注公众号后的自动回复文案'], + ['key' => 'weixingzh_new_subscribe_msg', 'label' => '新用户关注回复', 'type' => 'textarea', 'default' => '', 'help' => '首次关注用户的专属回复文案,留空使用通用回复'], + ['key' => 'weixingzh_scan_msg', 'label' => '扫码回复', 'type' => 'textarea', 'default' => '', 'help' => '用户扫码关注公众号后的回复文案'], + ['key' => 'weixingzh_auto_reply_text', 'label' => '文本自动回复', 'type' => 'textarea', 'default' => '', 'help' => '收到文本消息的自动回复'], + ['key' => 'weixingzh_auto_reply_search', 'label' => '搜索回复', 'type' => 'textarea', 'default' => '', 'help' => '未匹配关键词时的搜索提示回复'], + ['key' => 'weixingzh_auto_reply_image', 'label' => '图片回复', 'type' => 'textarea', 'default' => '', 'help' => '收到图片消息的自动回复'], + ['key' => 'weixingzh_auto_reply_voice', 'label' => '语音回复', 'type' => 'textarea', 'default' => '', 'help' => '收到语音消息的自动回复'], + ['key' => 'weixingzh_auto_reply_default', 'label' => '默认回复', 'type' => 'textarea', 'default' => '暂不支持此类型的消息', 'help' => '其他未匹配消息类型的兜底回复'], + ['key' => 'weixingzh_gzh_menu', 'label' => '自定义菜单 JSON', 'type' => 'textarea', 'default' => '', 'help' => '微信公众号自定义菜单 JSON 配置,留空不创建菜单'], + ]], + ['key' => 'weibo', 'title' => '微博配置', 'fields' => [ + ['key' => 'weibo_appid', 'label' => 'App Key', 'type' => 'input', 'default' => ''], + ['key' => 'weibo_appkey', 'label' => 'App Secret', 'type' => 'input', 'default' => ''], + ]], + ['key' => 'baidu', 'title' => '百度配置', 'fields' => [ + ['key' => 'baidu_appid', 'label' => 'API Key', 'type' => 'input', 'default' => ''], + ['key' => 'baidu_appkey', 'label' => 'Secret Key', 'type' => 'input', 'default' => ''], + ]], + ['key' => 'alipay', 'title' => '支付宝配置', 'fields' => [ + ['key' => 'alipay_appid', 'label' => 'AppID', 'type' => 'input', 'default' => ''], + ['key' => 'alipay_privatekey', 'label' => '应用私钥', 'type' => 'input', 'default' => ''], + ['key' => 'alipay_publickey', 'label' => '支付宝公钥', 'type' => 'input', 'default' => ''], + ]], + ], + ], + 'sms' => [ + 'key' => 'sms', + 'title' => '短信接口', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'provider', 'title' => '供应商配置', 'fields' => [ + ['key' => 'sms_enabled', 'label' => '启用短信', 'type' => 'switch', 'default' => false], + ['key' => 'sms_provider', 'label' => '短信服务商', 'type' => 'select', 'default' => 'aliyun', 'options' => [ + ['label' => '阿里云短信', 'value' => 'aliyun'], + ['label' => '腾讯云短信', 'value' => 'tencent'], + ['label' => '华为云短信', 'value' => 'huawei'], + ['label' => '自定义接口', 'value' => 'custom'], + ]], + ['key' => 'sms_number_limit_ch', 'label' => '仅发国内', 'type' => 'switch', 'default' => true, 'desc' => '开启后仅支持发送国内短信,关闭后可发送港澳台及国际短信'], + ['key' => 'sms_access_key', 'label' => 'AccessKey / SecretId / AppKey', 'type' => 'input', 'default' => ''], + ['key' => 'sms_access_secret', 'label' => 'AccessSecret / SecretKey / AppSecret', 'type' => 'input', 'default' => ''], + ]], + ['key' => 'aliyun', 'title' => '阿里云配置', 'fields' => [ + ['key' => 'sms_ali_api_type', 'label' => '接口类型', 'type' => 'radio', 'default' => 'sms', 'options' => [ + ['label' => '短信服务', 'value' => 'sms'], + ['label' => '号码认证', 'value' => 'verify'], + ]], + ['key' => 'sms_region_id', 'label' => 'RegionId', 'type' => 'input', 'default' => 'cn-hangzhou'], + ]], + ['key' => 'tencent', 'title' => '腾讯云配置', 'fields' => [ + ['key' => 'sms_tencent_app_id', 'label' => 'AppID', 'type' => 'input', 'default' => ''], + ['key' => 'sms_tencent_app_key', 'label' => 'AppKey', 'type' => 'input', 'default' => ''], + ['key' => 'sms_tencent_secret_id', 'label' => 'SecretId', 'type' => 'input', 'default' => ''], + ['key' => 'sms_tencent_secret_key', 'label' => 'SecretKey', 'type' => 'input', 'default' => ''], + ['key' => 'sms_tencent_region', 'label' => '地域', 'type' => 'input', 'default' => 'ap-guangzhou'], + ]], + ['key' => 'huawei', 'title' => '华为云配置', 'fields' => [ + ['key' => 'sms_huawei_sender', 'label' => '通道号 (sender)', 'type' => 'input', 'default' => ''], + ['key' => 'sms_huawei_signature', 'label' => '短信签名', 'type' => 'input', 'default' => ''], + ['key' => 'sms_huawei_endpoint', 'label' => 'API Endpoint', 'type' => 'input', 'default' => ''], + ]], + ['key' => 'custom', 'title' => '自定义接口配置', 'fields' => [ + ['key' => 'sms_custom_url', 'label' => '请求 URL', 'type' => 'input', 'default' => ''], + ['key' => 'sms_custom_method', 'label' => '请求方法', 'type' => 'select', 'default' => 'POST', 'options' => [ + ['label' => 'POST', 'value' => 'POST'], + ['label' => 'GET', 'value' => 'GET'], + ['label' => 'PUT', 'value' => 'PUT'], + ]], + ['key' => 'sms_custom_auth_type', 'label' => '鉴权方式', 'type' => 'select', 'default' => 'header', 'options' => [ + ['label' => 'Header', 'value' => 'header'], + ]], + ['key' => 'sms_custom_auth_key', 'label' => '鉴权 Header 名', 'type' => 'input', 'default' => ''], + ['key' => 'sms_custom_auth_value', 'label' => '鉴权 Header 值', 'type' => 'input', 'default' => ''], + ['key' => 'sms_custom_headers', 'label' => '自定义 Headers', 'type' => 'textarea', 'default' => ''], + ['key' => 'sms_custom_body', 'label' => '请求 Body', 'type' => 'textarea', 'default' => ''], + ['key' => 'sms_custom_success_rule', 'label' => '成功判定规则', 'type' => 'input', 'default' => ''], + ]], + ['key' => 'template', 'title' => '短信模板', 'fields' => [ + ['key' => 'sms_sign_name', 'label' => '短信签名', 'type' => 'input', 'default' => ''], + ['key' => 'sms_template_code', 'label' => '验证码模板ID', 'type' => 'input', 'default' => ''], + ['key' => 'sms_code_length', 'label' => '验证码长度', 'type' => 'number', 'default' => 6, 'min' => 4, 'max' => 8], + ['key' => 'sms_code_expire', 'label' => '验证码有效期(秒)', 'type' => 'number', 'default' => 300, 'min' => 60, 'max' => 600], + ]], + ['key' => 'rate_limit', 'title' => '限流策略', 'fields' => [ + ['key' => 'sms_rate_limit', 'label' => '发送间隔(秒)', 'type' => 'number', 'default' => 60, 'min' => 10, 'max' => 300], + ['key' => 'sms_daily_limit', 'label' => '每日发送上限', 'type' => 'number', 'default' => 10, 'min' => 1, 'max' => 100], + ]], + ['key' => 'triggers', 'title' => '通知场景', 'fields' => [ + ['key' => 'sms_notify_bind', 'label' => '绑定手机验证', 'type' => 'switch', 'default' => true], + ['key' => 'sms_notify_login', 'label' => '短信登录', 'type' => 'switch', 'default' => false], + ['key' => 'sms_notify_register', 'label' => '注册验证', 'type' => 'switch', 'default' => true], + ['key' => 'sms_notify_reset_password', 'label' => '找回密码', 'type' => 'switch', 'default' => true], + ]], + ], + ], + 'signin' => [ + 'key' => 'signin', + 'title' => '签到奖励', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'rules', 'title' => '签到规则', 'fields' => [ + ['key' => 'signin_enabled', 'label' => '启用签到', 'type' => 'switch', 'default' => true], + ['key' => 'signin_reward', 'label' => '每日签到奖励', 'type' => 'number', 'default' => 1, 'min' => 0, 'max' => 100], + ['key' => 'signin_consecutive_bonus', 'label' => '连续签到加成', 'type' => 'switch', 'default' => true], + ['key' => 'signin_consecutive_days', 'label' => '加成周期', 'type' => 'number', 'default' => 7, 'min' => 2, 'max' => 30], + ['key' => 'signin_bonus_reward', 'label' => '周期奖励', 'type' => 'number', 'default' => 5, 'min' => 1, 'max' => 100], + ]], + ['key' => 'display', 'title' => '签到展示', 'fields' => [ + ['key' => 'signin_header_user_show', 'label' => '导航栏显示签到', 'type' => 'switch', 'default' => true], + ['key' => 'signin_modal_auto_s', 'label' => '自动弹出签到', 'type' => 'switch', 'default' => false], + ['key' => 'signin_modal_auto_expires', 'label' => '弹出间隔(天)', 'type' => 'number', 'default' => 1, 'min' => 1, 'max' => 30], + ]], + ], + ], + 'credits' => [ + 'key' => 'credits', + 'title' => '积分设置', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'basic', 'title' => '积分基础', 'fields' => [ + ['key' => 'points_s', 'label' => '启用积分系统', 'type' => 'switch', 'default' => true], + ['key' => 'points_pay_s', 'label' => '积分支付', 'type' => 'switch', 'default' => false], + ['key' => 'points_rate', 'label' => '积分兑换比例', 'type' => 'number', 'default' => 100, 'min' => 1, 'max' => 10000], + ['key' => 'points_name', 'label' => '积分名称', 'type' => 'input', 'default' => '积分'], + ['key' => 'points_decimal', 'label' => '积分小数位数', 'type' => 'number', 'default' => 2, 'min' => 0, 'max' => 4], + ]], + ['key' => 'earn', 'title' => '积分获取', 'fields' => [ + ['key' => 'points_earn_comment', 'label' => '评论奖励', 'type' => 'number', 'default' => 1, 'min' => 0, 'max' => 100], + ['key' => 'points_earn_post', 'label' => '发布文章奖励', 'type' => 'number', 'default' => 5, 'min' => 0, 'max' => 100], + ['key' => 'points_earn_login', 'label' => '每日登录奖励', 'type' => 'number', 'default' => 1, 'min' => 0, 'max' => 100], + ['key' => 'points_earn_like_received', 'label' => '收到点赞奖励', 'type' => 'number', 'default' => 1, 'min' => 0, 'max' => 100], + ['key' => 'points_earn_invite', 'label' => '邀请注册奖励', 'type' => 'number', 'default' => 10, 'min' => 0, 'max' => 1000], + ['key' => 'points_earn_daily_limit', 'label' => '每日获取上限', 'type' => 'number', 'default' => 50, 'min' => 0, 'max' => 9999, 'help' => '0为不限制'], + ]], + ['key' => 'spend', 'title' => '积分消费', 'fields' => [ + ['key' => 'points_spend_comment', 'label' => '评论消耗', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 100], + ['key' => 'points_spend_download', 'label' => '下载消耗', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 100], + ['key' => 'points_spend_view_hidden', 'label' => '查看隐藏内容消耗', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 100], + ]], + ['key' => 'transfer', 'title' => '积分转账', 'fields' => [ + ['key' => 'points_transfer_s', 'label' => '启用积分转账', 'type' => 'switch', 'default' => false], + ['key' => 'points_service_charge', 'label' => '转账手续费(%)', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 50], + ['key' => 'points_transfer_min', 'label' => '最低转账金额', 'type' => 'number', 'default' => 1, 'min' => 1, 'max' => 9999], + ]], + ['key' => 'deduction', 'title' => '积分抵扣', 'fields' => [ + ['key' => 'points_deduction_s', 'label' => '启用积分抵扣', 'type' => 'switch', 'default' => false], + ['key' => 'points_deduction_rate', 'label' => '抵扣比例(%)', 'type' => 'number', 'default' => 100, 'min' => 1, 'max' => 100], + ['key' => 'points_deduction_max', 'label' => '最大抵扣比例(%)', 'type' => 'number', 'default' => 50, 'min' => 1, 'max' => 100], + ]], + ['key' => 'display', 'title' => '展示设置', 'fields' => [ + ['key' => 'points_show_balance', 'label' => '导航栏显示余额', 'type' => 'switch', 'default' => true], + ['key' => 'points_show_log', 'label' => '显示积分记录', 'type' => 'switch', 'default' => true], + ['key' => 'points_expire_days', 'label' => '积分有效期(天)', 'type' => 'number', 'default' => 0, 'min' => 0, 'help' => '0为永久有效'], + ]], + ], + ], + 'invite' => [ + 'key' => 'invite', + 'title' => '邀请码', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'basic', 'title' => '邀请设置', 'fields' => [ + ['key' => 'invit_code_s', 'label' => '启用邀请码', 'type' => 'switch', 'default' => false], + ['key' => 'invit_code_open_desc', 'label' => '开放注册提示', 'type' => 'input', 'default' => '邀请码为选填,没有邀请码也可以注册'], + ['key' => 'invit_code_must_desc', 'label' => '必填提示', 'type' => 'input', 'default' => '请输入邀请码'], + ['key' => 'invit_code_mode', 'label' => '邀请模式', 'type' => 'select', 'default' => 'open', 'options' => [ + ['label' => '开放注册(邀请码选填)', 'value' => 'open'], + ['label' => '必须邀请码', 'value' => 'must'], + ]], + ]], + ['key' => 'format', 'title' => '码面设置', 'fields' => [ + ['key' => 'invit_code_length', 'label' => '邀请码长度', 'type' => 'number', 'default' => 8, 'min' => 4, 'max' => 32], + ['key' => 'invit_code_prefix', 'label' => '邀请码前缀', 'type' => 'input', 'default' => ''], + ['key' => 'invit_code_max_use', 'label' => '单码最大使用次数', 'type' => 'number', 'default' => 1, 'min' => 1, 'max' => 9999], + ['key' => 'invit_code_expiry', 'label' => '有效期(天)', 'type' => 'number', 'default' => 0, 'min' => 0, 'help' => '0为永久有效'], + ['key' => 'invit_code_batch', 'label' => '批量生成数量', 'type' => 'number', 'default' => 20, 'min' => 1, 'max' => 500], + ]], + ['key' => 'reward', 'title' => '邀请奖励', 'fields' => [ + ['key' => 'invit_reward_points', 'label' => '邀请奖励积分', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 1000], + ['key' => 'invit_reward_level', 'label' => '邀请奖励等级', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 10], + ['key' => 'invit_reward_vip_days', 'label' => '奖励VIP天数', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 3650], + ['key' => 'invit_invitee_reward', 'label' => '被邀请人奖励', 'type' => 'switch', 'default' => false], + ]], + ['key' => 'display', 'title' => '展示设置', 'fields' => [ + ['key' => 'invit_code_show_count', 'label' => '显示已使用次数', 'type' => 'switch', 'default' => true], + ['key' => 'invit_code_show_remain', 'label' => '显示剩余次数', 'type' => 'switch', 'default' => true], + ]], + ], + ], + 'features' => [ + 'key' => 'features', + 'title' => '用户功能', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'center', 'title' => '用户中心', 'fields' => [ + ['key' => 'user_center_tabs', 'label' => '用户中心 Tab', 'type' => 'input', 'default' => 'profile,posts,comments,credits,favorites'], + ['key' => 'user_avatar_upload', 'label' => '头像上传', 'type' => 'switch', 'default' => true], + ['key' => 'user_cover_upload', 'label' => '封面上传', 'type' => 'switch', 'default' => true], + ['key' => 'user_social_links', 'label' => '社交链接', 'type' => 'switch', 'default' => true], + ['key' => 'user_desc_std', 'label' => '默认签名', 'type' => 'input', 'default' => '这家伙很懒…', 'desc' => '用户未设置个人简介时显示的默认文案'], + ['key' => 'user_city_s', 'label' => '显示城市', 'type' => 'switch', 'default' => false], + ['key' => 'user_join_day_my_name', 'label' => '站名', 'type' => 'input', 'default' => '本站', 'desc' => '用于「已加入 X 天」显示中的站点名称'], + ['key' => 'user_avatar_default', 'label' => '默认头像', 'type' => 'media', 'default' => ''], + ['key' => 'user_cover_img', 'label' => '默认封面', 'type' => 'media', 'default' => ''], + ['key' => 'user_rewrite_slug', 'label' => '用户页slug', 'type' => 'input', 'default' => 'user'], + ['key' => 'user_follow_s', 'label' => '启用关注功能', 'type' => 'switch', 'default' => true], + ['key' => 'user_message_s', 'label' => '启用私信功能', 'type' => 'switch', 'default' => true], + ['key' => 'user_online_s', 'label' => '显示在线状态', 'type' => 'switch', 'default' => true], + ['key' => 'user_last_active_s', 'label' => '显示最后活跃', 'type' => 'switch', 'default' => true], + ]], + ['key' => 'profile', 'title' => '资料页字段', 'fields' => [ + ['key' => 'user_profile_nickname', 'label' => '昵称', 'type' => 'switch', 'default' => true], + ['key' => 'user_profile_email', 'label' => '邮箱', 'type' => 'switch', 'default' => true], + ['key' => 'user_profile_url', 'label' => '个人主页', 'type' => 'switch', 'default' => true], + ['key' => 'user_profile_desc', 'label' => '个人简介', 'type' => 'switch', 'default' => true], + ['key' => 'user_profile_avatar', 'label' => '头像', 'type' => 'switch', 'default' => true], + ['key' => 'user_profile_cover', 'label' => '封面图', 'type' => 'switch', 'default' => true], + ['key' => 'user_profile_qq', 'label' => 'QQ', 'type' => 'switch', 'default' => false], + ['key' => 'user_profile_wechat', 'label' => '微信', 'type' => 'switch', 'default' => false], + ['key' => 'user_profile_weibo', 'label' => '微博', 'type' => 'switch', 'default' => false], + ['key' => 'user_profile_github', 'label' => 'GitHub', 'type' => 'switch', 'default' => false], + ['key' => 'user_profile_bilibili', 'label' => 'B站', 'type' => 'switch', 'default' => false], + ['key' => 'user_profile_twitter', 'label' => 'Twitter', 'type' => 'switch', 'default' => false], + ['key' => 'user_profile_linkedin', 'label' => 'LinkedIn', 'type' => 'switch', 'default' => false], + ['key' => 'user_profile_facebook', 'label' => 'Facebook', 'type' => 'switch', 'default' => false], + ['key' => 'user_profile_telegram', 'label' => 'Telegram', 'type' => 'switch', 'default' => false], + ['key' => 'user_profile_douyin', 'label' => '抖音', 'type' => 'switch', 'default' => false], + ['key' => 'user_profile_zhihu', 'label' => '知乎', 'type' => 'switch', 'default' => false], + ]], + ['key' => 'card', 'title' => '用户卡片', 'fields' => [ + ['key' => 'user_card_avatar', 'label' => '显示头像', 'type' => 'switch', 'default' => true], + ['key' => 'user_card_name', 'label' => '显示用户名', 'type' => 'switch', 'default' => true], + ['key' => 'user_card_desc', 'label' => '显示简介', 'type' => 'switch', 'default' => true], + ['key' => 'user_card_count', 'label' => '显示统计', 'type' => 'switch', 'default' => true], + ['key' => 'user_card_follow', 'label' => '显示关注按钮', 'type' => 'switch', 'default' => true], + ['key' => 'user_card_position', 'label' => '卡片位置', 'type' => 'radio', 'default' => 'right', 'options' => [ + ['label' => '右侧', 'value' => 'right'], + ['label' => '左侧', 'value' => 'left'], + ['label' => '顶部', 'value' => 'top'], + ]], + ['key' => 'user_card_style', 'label' => '卡片样式', 'type' => 'radio', 'default' => 'default', 'options' => [ + ['label' => '默认', 'value' => 'default'], + ['label' => '简洁', 'value' => 'simple'], + ['label' => '详细', 'value' => 'detail'], + ]], + ['key' => 'user_card_bg', 'label' => '卡片背景', 'type' => 'media', 'default' => ''], + ['key' => 'user_card_avatar_size', 'label' => '头像大小', 'type' => 'number', 'default' => 80, 'min' => 40, 'max' => 200], + ]], + ['key' => 'privacy', 'title' => '隐私设置', 'fields' => [ + ['key' => 'user_privacy_posts', 'label' => '隐藏文章', 'type' => 'switch', 'default' => false], + ['key' => 'user_privacy_comments', 'label' => '隐藏评论', 'type' => 'switch', 'default' => false], + ['key' => 'user_privacy_favorites', 'label' => '隐藏收藏', 'type' => 'switch', 'default' => false], + ['key' => 'user_privacy_credits', 'label' => '隐藏积分', 'type' => 'switch', 'default' => false], + ['key' => 'user_privacy_level', 'label' => '隐藏等级', 'type' => 'switch', 'default' => false], + ['key' => 'user_privacy_badge', 'label' => '隐藏徽章', 'type' => 'switch', 'default' => false], + ['key' => 'user_privacy_online', 'label' => '隐藏在线状态', 'type' => 'switch', 'default' => false], + ['key' => 'user_privacy_last_active', 'label' => '隐藏最后活跃', 'type' => 'switch', 'default' => false], + ['key' => 'user_privacy_ip', 'label' => '隐藏IP归属地', 'type' => 'switch', 'default' => false], + ['key' => 'user_privacy_location', 'label' => '隐藏位置', 'type' => 'switch', 'default' => false], + ]], + ['key' => 'rewards', 'title' => '打赏设置', 'fields' => [ + ['key' => 'post_rewards_s', 'label' => '启用文章打赏', 'type' => 'switch', 'default' => false], + ['key' => 'post_rewards_text', 'label' => '打赏文案', 'type' => 'input', 'default' => ''], + ['key' => 'post_rewards_img', 'label' => '打赏二维码', 'type' => 'media', 'default' => ''], + ['key' => 'post_rewards_btn_text', 'label' => '打赏按钮文案', 'type' => 'input', 'default' => '赞赏'], + ['key' => 'post_rewards_amounts', 'label' => '打赏金额', 'type' => 'input', 'default' => '5,10,20,50,100'], + ['key' => 'post_rewards_custom', 'label' => '自定义金额', 'type' => 'switch', 'default' => true], + ]], + ], + ], + 'verify' => [ + 'key' => 'verify', + 'title' => '身份认证', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'display', 'title' => '公开展示', 'fields' => [ + ['key' => 'user_verify_enabled', 'label' => '启用身份认证', 'type' => 'switch', 'default' => true], + ['key' => 'user_verify_public_display', 'label' => '公开页显示认证徽标', 'type' => 'switch', 'default' => true], + ['key' => 'user_verify_badge_text', 'label' => '认证徽标文案', 'type' => 'input', 'default' => '已认证'], + ]], + ['key' => 'workflow', 'title' => '审核流程', 'fields' => [ + ['key' => 'user_verify_accept_email_verified', 'label' => '邮箱验证通过视为已认证', 'type' => 'switch', 'default' => true], + ['key' => 'user_verify_require_manual_review', 'label' => '保留人工审核位', 'type' => 'switch', 'default' => false], + ['key' => 'user_verify_apply_option', 'label' => '申请选项', 'type' => 'textarea', 'default' => "real_name|实名认证\norganization|企业认证"], + ]], + ['key' => 'restrict', 'title' => '限制设置', 'fields' => [ + ['key' => 'user_verify_disable_desc', 'label' => '未认证限制说明', 'type' => 'textarea', 'default' => ''], + ['key' => 'user_verify_limit_level', 'label' => '限制等级', 'type' => 'number', 'default' => 0, 'min' => 0, 'max' => 100], + ['key' => 'user_verify_desc', 'label' => '认证说明', 'type' => 'textarea', 'default' => ''], + ]], + ], + ], + 'level' => [ + 'key' => 'level', + 'title' => '用户等级', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'display', 'title' => '等级显示', 'fields' => [ + ['key' => 'user_level_enabled', 'label' => '启用等级系统', 'type' => 'switch', 'default' => false], + ['key' => 'user_level_public_display', 'label' => '公开页显示等级', 'type' => 'switch', 'default' => true], + ['key' => 'user_level_prefix', 'label' => '等级前缀', 'type' => 'input', 'default' => 'Lv.'], + ['key' => 'user_level_color', 'label' => '等级颜色', 'type' => 'color', 'default' => '#f04494'], + ['key' => 'user_level_icon', 'label' => '等级图标', 'type' => 'icon', 'default' => 'fas fa-star'], + ]], + ['key' => 'rules', 'title' => '等级规则', 'fields' => [ + ['key' => 'user_level_source', 'label' => '等级来源', 'type' => 'radio', 'default' => 'credits_balance'], + ['key' => 'user_level_thresholds', 'label' => '等级阈值', 'type' => 'textarea', 'default' => "1|0\n2|30\n3|100\n4|300"], + ['key' => 'user_level_max', 'label' => '最高等级', 'type' => 'number', 'default' => 10, 'min' => 1, 'max' => 99], + ['key' => 'user_level_decay', 'label' => '等级衰减', 'type' => 'switch', 'default' => false, 'help' => '长期不活跃自动降级'], + ['key' => 'user_level_decay_days', 'label' => '衰减周期(天)', 'type' => 'number', 'default' => 90, 'min' => 7, 'max' => 365], + ]], + ['key' => 'privilege', 'title' => '等级特权', 'fields' => [ + ['key' => 'user_level_download_limit', 'label' => '下载限制加成', 'type' => 'switch', 'default' => false, 'help' => '高等级用户每日下载次数增加'], + ['key' => 'user_level_upload_limit', 'label' => '上传限制加成', 'type' => 'switch', 'default' => false, 'help' => '高等级用户可上传更大文件'], + ['key' => 'user_level_comment_cooldown', 'label' => '评论冷却缩短', 'type' => 'switch', 'default' => false, 'help' => '高等级用户评论间隔更短'], + ['key' => 'user_level_custom_title', 'label' => '自定义头衔', 'type' => 'switch', 'default' => false, 'help' => '允许高等级用户自定义头衔'], + ]], + ['key' => 'notify', 'title' => '升级通知', 'fields' => [ + ['key' => 'user_level_notify', 'label' => '升级通知', 'type' => 'switch', 'default' => true], + ['key' => 'user_level_notify_style', 'label' => '通知方式', 'type' => 'select', 'default' => 'toast', 'options' => [ + ['label' => '弹窗提示', 'value' => 'toast'], + ['label' => '站内信', 'value' => 'message'], + ['label' => '两者都有', 'value' => 'both'], + ]], + ]], + ], + ], + 'badge' => [ + 'key' => 'badge', + 'title' => '用户徽章', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'display', 'title' => '徽章展示', 'fields' => [ + ['key' => 'user_badge_enabled', 'label' => '启用徽章系统', 'type' => 'switch', 'default' => false], + ['key' => 'user_badge_public_display', 'label' => '公开页显示徽章', 'type' => 'switch', 'default' => true], + ['key' => 'user_badge_limit', 'label' => '最多显示数量', 'type' => 'number', 'default' => 3, 'min' => 1, 'max' => 8], + ]], + ['key' => 'dictionary', 'title' => '徽章字典', 'fields' => [ + ['key' => 'user_badge_dictionary', 'label' => '徽章列表', 'type' => 'textarea', 'default' => "founder|创始成员|warning\nstaff|站务团队|info"], + ]], + ], + ], + 'report' => [ + 'key' => 'report', + 'title' => '禁封/举报', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'entry', 'title' => '前台入口', 'fields' => [ + ['key' => 'user_report_enabled', 'label' => '启用举报/禁封结构', 'type' => 'switch', 'default' => false], + ['key' => 'user_report_public_entry', 'label' => '公开主页展示举报入口', 'type' => 'switch', 'default' => true], + ['key' => 'user_report_public_notice', 'label' => '公开显示限制提示', 'type' => 'switch', 'default' => true], + ['key' => 'user_report_ban_notice_text', 'label' => '限制提示文案', 'type' => 'input', 'default' => '当前账号处于限制状态,部分互动能力已关闭。'], + ]], + ['key' => 'taxonomy', 'title' => '举报分类', 'fields' => [ + ['key' => 'user_report_categories', 'label' => '举报分类', 'type' => 'textarea', 'default' => "spam|垃圾信息\nabuse|人身攻击\ncopyright|版权投诉"], + ]], + ['key' => 'ban', 'title' => '禁封设置', 'fields' => [ + ['key' => 'ban_preset_reason', 'label' => '预设封禁原因', 'type' => 'textarea', 'default' => "spam|垃圾信息\nabuse|人身攻击\nviolation|违规操作"], + ['key' => 'ban_appeal_desc', 'label' => '申诉说明', 'type' => 'textarea', 'default' => ''], + ['key' => 'ban_appeal_keys', 'label' => '申诉关键词', 'type' => 'input', 'default' => ''], + ]], + ['key' => 'image', 'title' => '图片举报', 'fields' => [ + ['key' => 'user_report_img_s', 'label' => '启用图片举报', 'type' => 'switch', 'default' => false], + ['key' => 'user_report_desc', 'label' => '举报说明', 'type' => 'textarea', 'default' => ''], + ]], + ], + ], + 'wechat' => [ + 'key' => 'wechat', + 'title' => '微信通知', + 'version' => '1.0.0', + 'option_key' => 'aksrt_settings', + 'groups' => [ + ['key' => 'account', 'title' => '公众号配置', 'fields' => [ + ['key' => 'wechat_notify_enabled', 'label' => '启用微信通知', 'type' => 'switch', 'default' => false], + ['key' => 'wechat_app_id', 'label' => 'AppID', 'type' => 'input', 'default' => ''], + ['key' => 'wechat_app_secret', 'label' => 'AppSecret', 'type' => 'input', 'default' => ''], + ]], + ['key' => 'verify', 'title' => '服务器验证', 'fields' => [ + ['key' => 'wechat_token', 'label' => 'Token', 'type' => 'input', 'default' => ''], + ['key' => 'wechat_encoding_aes_key', 'label' => 'EncodingAESKey', 'type' => 'input', 'default' => ''], + ]], + ['key' => 'templates', 'title' => '模板消息', 'fields' => [ + ['key' => 'wechat_template_comment', 'label' => '评论通知模板', 'type' => 'input', 'default' => ''], + ['key' => 'wechat_template_reply', 'label' => '回复通知模板', 'type' => 'input', 'default' => ''], + ['key' => 'wechat_template_system', 'label' => '系统通知模板', 'type' => 'input', 'default' => ''], + ['key' => 'wechat_template_order', 'label' => '订单通知模板', 'type' => 'input', 'default' => ''], + ]], + ['key' => 'triggers', 'title' => '通知触发器', 'fields' => [ + ['key' => 'wechat_notify_comment', 'label' => '新评论通知', 'type' => 'switch', 'default' => true], + ['key' => 'wechat_notify_reply', 'label' => '评论回复通知', 'type' => 'switch', 'default' => true], + ['key' => 'wechat_notify_system', 'label' => '系统通知', 'type' => 'switch', 'default' => true], + ['key' => 'wechat_notify_order', 'label' => '订单通知', 'type' => 'switch', 'default' => true], + ['key' => 'wechat_bind_entry', 'label' => '显示绑定入口', 'type' => 'switch', 'default' => true], + ]], + ], + ], + ], + ]; + } +} diff --git a/inc/classes/auth-traits/trait-aksrt-auth-service-part-1a.php b/inc/classes/auth-traits/trait-aksrt-auth-service-part-1a.php new file mode 100644 index 00000000..d79bb349 --- /dev/null +++ b/inc/classes/auth-traits/trait-aksrt-auth-service-part-1a.php @@ -0,0 +1,483 @@ + false, 'error' => __('安全验证失败,请刷新页面重试', 'aksrt')]; + } + + if (empty($post['agree_terms'])) { + return ['success' => false, 'error' => __('请先同意服务条款', 'aksrt')]; + } + + if (empty($user_login) || !validate_username($user_login)) { + return ['success' => false, 'error' => __('请输入有效的用户名', 'aksrt')]; + } + + if (mb_strlen($user_login) < 3 || mb_strlen($user_login) > 60) { + return ['success' => false, 'error' => __('用户名长度需在 3-60 个字符之间', 'aksrt')]; + } + + if (username_exists($user_login)) { + return ['success' => false, 'error' => __('该用户名已被使用', 'aksrt')]; + } + + if (empty($user_email) || !is_email($user_email)) { + return ['success' => false, 'error' => __('请输入有效的邮箱地址', 'aksrt')]; + } + + if (email_exists($user_email)) { + return ['success' => false, 'error' => __('该邮箱已被其他账号使用', 'aksrt')]; + } + + $password_check = self::validate_password($password, $confirm); + if ($password_check !== true) { + return ['success' => false, 'error' => $password_check]; + } + + if (function_exists('aksrt_is_captcha_enabled_for') && aksrt_is_captcha_enabled_for('register')) { + if (function_exists('aksrt_verify_captcha')) { + $captcha_result = aksrt_verify_captcha($post); + if ($captcha_result !== true) { + return ['success' => false, 'error' => $captcha_result]; + } + } + } + + // ====== 解析邀请码 ====== + // 优先级:表单 invite_code(用户显式填写) > cookie aksrt_invite + $invite_registration_enabled = class_exists('AKSRT_Settings') && AKSRT_Settings::invite_registration_enabled(); + $invite_mode = $invite_registration_enabled && class_exists('AKSRT_Settings') + ? AKSRT_Settings::invite_register_field_mode() + : 'hidden'; + $invite_code = isset($post['invite_code']) ? sanitize_key((string) wp_unslash($post['invite_code'])) : ''; + $resolved_inviter_id = 0; + $invite_is_valid = false; + + if (!$invite_registration_enabled) { + // 邀请功能关闭,忽略所有邀请码 + $invite_code = ''; + } elseif ($invite_code !== '') { + if (class_exists('AKSRT_Invite')) { + $invite_service = AKSRT_Invite::get_instance(); + $resolved_inviter_id = $invite_service->resolve_code($invite_code); + $invite_is_valid = $invite_service->validate_code($invite_code); + } + // 必填模式下,邀请码无效时根据后台开关决定是否拒绝 + $reject_invalid = class_exists('AKSRT_Settings') && AKSRT_Settings::invite_reject_invalid(); + if ($invite_mode === 'required' && $reject_invalid && !$invite_is_valid) { + return ['success' => false, 'error' => __('邀请码无效,请检查后重试', 'aksrt')]; + } + } elseif ($invite_mode === 'required') { + return ['success' => false, 'error' => __('请填写邀请码', 'aksrt')]; + } + + // 把解析到的 inviter_id 暂存到静态字段,user_register hook 内部读取(优先于 cookie) + self::$pending_invite_inviter_id = $resolved_inviter_id; + self::$pending_invite_code = $invite_code; + + $user_id = wp_create_user($user_login, $password, $user_email); + if (is_wp_error($user_id)) { + self::$pending_invite_inviter_id = 0; + self::$pending_invite_code = ''; + return ['success' => false, 'error' => $user_id->get_error_message()]; + } + + if ($invite_code !== '' && $resolved_inviter_id <= 0 && class_exists('AKSRT_Invite')) { + AKSRT_Invite::get_instance()->handle_registration_invite((int) $user_id, $invite_code); + } + + // wp_create_user 内部已触发 user_register hook,AKSRT_Invite::on_user_register 已读取 pending 字段。 + // 这里清空,避免污染后续请求。 + self::$pending_invite_inviter_id = 0; + self::$pending_invite_code = ''; + + $user = get_userdata($user_id); + if (!$user || is_wp_error($user)) { + return ['success' => false, 'error' => __('注册成功,但读取用户信息失败,请联系管理员', 'aksrt')]; + } + + $oauth_context = isset($context['oauth_bind_data']) && is_array($context['oauth_bind_data']) ? $context['oauth_bind_data'] : []; + $oauth_verified = self::oauth_email_is_verified($oauth_context); + $requires_verify = AKSRT_Settings::registration_requires_email_verification() && !$oauth_verified; + + if (!$requires_verify) { + self::mark_email_verified($user_id); + return [ + 'success' => true, + 'user_id' => (int) $user_id, + 'user' => $user, + 'user_login' => $user_login, + ]; + } + + $method = AKSRT_Settings::registration_verification_method(); + $begin = self::begin_email_verification( + $user, + $method, + [ + 'obk' => sanitize_text_field((string) ($context['obk'] ?? '')), + ] + ); + + if (empty($begin['success'])) { + self::delete_user_account($user_id); + return $begin; + } + + return [ + 'success' => true, + 'user_id' => (int) $user_id, + 'user' => $user, + 'user_login' => $user_login, + 'verification_required' => true, + 'verification_method' => $method, + 'verify_url' => self::get_email_verification_url((int) $user_id, ['mode' => $method]), + 'message' => $begin['message'] ?? '', + ]; + } + + public static function bind_oauth_account(array $post, array $oauth_data): array { + if (!wp_verify_nonce($post['_bind_nonce'] ?? '', 'aksrt_bind')) { + return ['success' => false, 'error' => __('安全验证失败,请刷新页面重试', 'aksrt')]; + } + + if (function_exists('aksrt_is_captcha_enabled_for') && aksrt_is_captcha_enabled_for('bind')) { + if (function_exists('aksrt_verify_captcha')) { + $captcha_result = aksrt_verify_captcha($post); + if ($captcha_result !== true) { + return ['success' => false, 'error' => $captcha_result]; + } + } + } + + $creds = [ + 'user_login' => self::resolve_email_to_username((string) ($post['log'] ?? '')), + 'user_password' => (string) ($post['pwd'] ?? ''), + 'remember' => true, + ]; + + $user = wp_signon($creds); + if (is_wp_error($user)) { + return ['success' => false, 'error' => self::friendly_login_error($user->get_error_message())]; + } + + if (self::user_requires_email_verification($user)) { + wp_logout(); + return [ + 'success' => false, + 'error' => __('该账号尚未完成邮箱验证,请先完成验证后再绑定第三方登录。', 'aksrt'), + 'verify_url' => self::get_email_verification_url((int) $user->ID), + ]; + } + + $provider = sanitize_key((string) ($oauth_data['provider'] ?? '')); + if ($provider === '') { + return ['success' => false, 'error' => __('绑定数据无效,请重新使用第三方登录', 'aksrt')]; + } + + $uid = (int) $user->ID; + update_user_meta($uid, "aksrt_{$provider}_id", $oauth_data['provider_id'] ?? ''); + + $bindings = get_user_meta($uid, 'aksrt_oauth_bindings', true) ?: []; + if (!is_array($bindings)) { + $bindings = []; + } + if (!in_array($provider, $bindings, true)) { + $bindings[] = $provider; + } + update_user_meta($uid, 'aksrt_oauth_bindings', $bindings); + + if (!empty($oauth_data['user_data']['avatar_url'])) { + update_user_meta($uid, 'aksrt_oauth_avatar', esc_url_raw($oauth_data['user_data']['avatar_url'])); + } + + if (!empty($post['obk'])) { + delete_transient(sanitize_text_field((string) $post['obk'])); + } + + wp_set_auth_cookie($uid, true); + + return [ + 'success' => true, + 'user' => $user, + 'provider' => $provider, + ]; + } + + public static function finalize_oauth_registration(int $user_id, array $oauth_data, string $obk = ''): void { + if ($user_id <= 0) { + return; + } + + $provider = sanitize_key((string) ($oauth_data['provider'] ?? '')); + if ($provider === '') { + return; + } + + update_user_meta($user_id, "aksrt_{$provider}_id", $oauth_data['provider_id'] ?? ''); + update_user_meta($user_id, 'aksrt_oauth_bindings', [$provider]); + + if (!empty($oauth_data['user_data']['avatar_url'])) { + update_user_meta($user_id, 'aksrt_oauth_avatar', esc_url_raw($oauth_data['user_data']['avatar_url'])); + } + + if (self::oauth_email_is_verified($oauth_data)) { + AKSRT_Auth_Email_Verification::mark_email_verified($user_id); + } + + if ($obk !== '') { + delete_transient(sanitize_text_field($obk)); + } + + wp_set_auth_cookie($user_id, true); + } + + public static function get_email_verification_context(array $query): array { + $invalid_payload = [ + 'mode' => 'invalid', + 'user' => null, + 'error' => __('验证入口无效或已过期,请重新发起验证流程。', 'aksrt'), + 'method' => '', + 'email' => '', + 'uid' => 0, + 'verified' => false, + 'access_key' => '', + ]; + + $user_id = isset($query['uid']) ? absint($query['uid']) : 0; + if ($user_id <= 0) { + return $invalid_payload; + } + + $user = get_userdata($user_id); + if (!$user || is_wp_error($user)) { + return $invalid_payload; + } + + $method = self::current_registration_verification_method(); + $email = (string) get_user_meta($user_id, self::EMAIL_TARGET_META, true); + $token = sanitize_text_field((string) ($query['token'] ?? '')); + $access = sanitize_text_field((string) ($query['vk'] ?? '')); + + // 容错:meta 缺失时(例如历史账号被强制要求验证),回退到设置默认值与 user_email + if (!in_array($method, ['link', 'code'], true)) { + $method = self::current_registration_verification_method(); + } + if ($email === '' || !is_email($email)) { + $email = (string) $user->user_email; + } + $masked_email = self::masked_email($email); + $has_access = self::has_valid_email_access_key($user_id, $access); + + // token 路径:链接模式直接走 token 校验;不依赖 vk + if ($token !== '') { + if (AKSRT_Auth_Email_Verification::is_email_verified($user_id)) { + return [ + 'mode' => 'invalid', + 'user' => $user, + 'error' => __('该验证链接已失效或已使用,请重新发起验证流程', 'aksrt'), + 'method' => $has_access ? $method : '', + 'email' => $has_access ? $masked_email : '', + 'uid' => $has_access ? $user_id : 0, + 'verified' => true, + 'access_key' => $has_access ? $access : '', + ]; + } + + $result = self::verify_email_token($user_id, $token); + if (!empty($result['success'])) { + return [ + 'mode' => 'completed', + 'user' => $user, + 'error' => '', + 'method' => $method, + 'email' => $masked_email, + 'uid' => $user_id, + 'verified' => true, + 'redirect' => $result['redirect'] ?? home_url('/'), + 'message' => $result['message'] ?? '', + 'access_key' => $has_access ? $access : '', + ]; + } + + return [ + 'mode' => 'invalid', + 'user' => $user, + 'error' => $result['error'] ?? __('验证链接无效或已过期', 'aksrt'), + 'method' => $has_access ? $method : '', + 'email' => $has_access ? $masked_email : '', + 'uid' => $has_access ? $user_id : 0, + 'verified' => false, + 'access_key' => $has_access ? $access : '', + ]; + } + + // 没有 token 也没有 vk:返回完全脱敏的通用错误,避免根据 uid 枚举账号状态 + if (!$has_access) { + return $invalid_payload; + } + + if (!self::user_requires_email_verification($user)) { + return [ + 'mode' => 'verified', + 'user' => $user, + 'error' => '', + 'method' => '', + 'email' => self::masked_email((string) $user->user_email), + 'uid' => $user_id, + 'verified' => true, + 'access_key' => $access, + ]; + } + + return [ + 'mode' => $method === 'code' ? 'code' : 'pending', + 'user' => $user, + 'error' => '', + 'method' => $method, + 'email' => $masked_email ?: self::masked_email((string) $user->user_email), + 'uid' => $user_id, + 'verified' => false, + 'access_key' => $access, + ]; + } + + public static function verify_email_code_submission(array $post): array { + if (!wp_verify_nonce($post['_verify_email_nonce'] ?? '', 'aksrt_verify_email')) { + return ['success' => false, 'error' => __('安全验证失败,请刷新页面重试', 'aksrt')]; + } + + $user_id = isset($post['verify_uid']) ? absint($post['verify_uid']) : 0; + $access = sanitize_text_field((string) ($post['vk'] ?? '')); + $code = sanitize_text_field((string) ($post['email_code'] ?? '')); + if ($user_id <= 0 || $code === '') { + return ['success' => false, 'error' => __('请输入完整的邮箱验证码', 'aksrt')]; + } + + if (!self::has_valid_email_access_key($user_id, $access)) { + return ['success' => false, 'error' => __('验证入口无效或已过期,请重新发起验证流程。', 'aksrt')]; + } + + if (function_exists('aksrt_is_captcha_enabled_for') && aksrt_is_captcha_enabled_for('verify_email')) { + if (function_exists('aksrt_verify_captcha')) { + $captcha_result = aksrt_verify_captcha($post); + if ($captcha_result !== true) { + return ['success' => false, 'error' => $captcha_result]; + } + } + } + + return self::verify_email_code($user_id, $code); + } + + public static function resend_email_verification(array $post): array { + if (!wp_verify_nonce($post['_verify_email_nonce'] ?? '', 'aksrt_verify_email')) { + return ['success' => false, 'error' => __('安全验证失败,请刷新页面重试', 'aksrt')]; + } + + $user_id = isset($post['verify_uid']) ? absint($post['verify_uid']) : 0; + $access = sanitize_text_field((string) ($post['vk'] ?? '')); + + // 必须先持有有效 vk 才允许重发,避免未登录用户用 uid 枚举触发邮件 + if ($user_id <= 0 || !self::has_valid_email_access_key($user_id, $access)) { + return ['success' => false, 'error' => __('验证入口无效或已过期,请重新发起验证流程。', 'aksrt')]; + } + + // IP 节流:每 IP 每 RESEND_IP_WINDOW 秒最多 RESEND_IP_MAX 次(跨账号合并计数) + $ip = sanitize_text_field((string) ($_SERVER['REMOTE_ADDR'] ?? '')); + if ($ip !== '') { + $ip_key = 'aksrt_email_resend_ip_' . md5($ip); + $ip_attempts = (int) get_transient($ip_key); + if ($ip_attempts >= self::RESEND_IP_MAX) { + self::log_event('verify.resend.ip_throttled', ['uid' => $user_id, 'ip' => $ip]); + return ['success' => false, 'error' => __('操作过于频繁,请稍后再试', 'aksrt')]; + } + set_transient($ip_key, $ip_attempts + 1, self::RESEND_IP_WINDOW); + } + + $user = get_userdata($user_id); + if (!$user || is_wp_error($user)) { + return ['success' => false, 'error' => __('验证入口无效或已过期,请重新发起验证流程。', 'aksrt')]; + } + + if (!self::user_requires_email_verification($user)) { + return ['success' => false, 'error' => __('该账号已完成邮箱验证,无需重复发送', 'aksrt')]; + } + + if (function_exists('aksrt_is_captcha_enabled_for') && aksrt_is_captcha_enabled_for('verify_email')) { + if (function_exists('aksrt_verify_captcha')) { + $captcha_result = aksrt_verify_captcha($post); + if ($captcha_result !== true) { + return ['success' => false, 'error' => $captcha_result]; + } + } + } + + $method = self::current_registration_verification_method(); + $email = (string) get_user_meta($user_id, self::EMAIL_TARGET_META, true); + + // 容错:meta 缺失时回退到设置默认值与 user_email(兼容历史账号 / meta 被外部清理的情况) + if (!in_array($method, ['link', 'code'], true)) { + $method = AKSRT_Settings::registration_verification_method(); + } + if ($email === '' || !is_email($email)) { + $email = (string) $user->user_email; + } + if ($email === '' || !is_email($email)) { + return ['success' => false, 'error' => __('当前账号缺少有效邮箱,请联系管理员', 'aksrt')]; + } + + $requested_at = (int) get_user_meta($user_id, self::EMAIL_REQUESTED_AT_META, true); + if ($requested_at > 0 && (time() - $requested_at) < 60) { + return ['success' => false, 'error' => __('操作过于频繁,请稍后再试', 'aksrt')]; + } + + return self::begin_email_verification($user, $method, [ + 'email' => $email, + 'obk' => (string) get_user_meta($user_id, self::EMAIL_OBK_META, true), + 'force' => true, + ]); + } + + private static function user_requires_email_verification($user): bool { + return AKSRT_Auth_Email_Verification::user_requires_email_verification($user); + } + + private static function mark_email_verified(int $user_id): void { + AKSRT_Auth_Email_Verification::mark_email_verified($user_id); + } + + // ============================================================ + // 公开 API:管理员后台 / 内部业务调用(委托 AKSRT_Auth_Email_Verification) + // ============================================================ + + public static function user_is_email_verified(int $user_id): bool { + return AKSRT_Auth_Email_Verification::user_is_email_verified($user_id); + } + + public static function get_user_verification_state(int $user_id): array { + return AKSRT_Auth_Email_Verification::get_user_verification_state($user_id); + } + + public static function admin_mark_verified(int $user_id, int $by_admin_id = 0): array { + return AKSRT_Auth_Email_Verification::admin_mark_verified($user_id, $by_admin_id); + } + + public static function admin_unmark_verified(int $user_id, int $by_admin_id = 0): array { + return AKSRT_Auth_Email_Verification::admin_unmark_verified($user_id, $by_admin_id); + } + +} diff --git a/inc/classes/auth-traits/trait-aksrt-auth-service-part-1b.php b/inc/classes/auth-traits/trait-aksrt-auth-service-part-1b.php new file mode 100644 index 00000000..354b1ea1 --- /dev/null +++ b/inc/classes/auth-traits/trait-aksrt-auth-service-part-1b.php @@ -0,0 +1,447 @@ + 'invalid', + 'user' => null, + 'error' => __('旧的重置密码链接已失效,请重新申请重置密码。', 'aksrt'), + 'token' => '', + 'user_id'=> 0, + ]; + } + + $token = sanitize_text_field($query['token'] ?? ''); + $user_id = isset($query['uid']) ? absint($query['uid']) : 0; + + if ($token === '' || $user_id <= 0) { + return [ + 'mode' => 'request', + 'user' => null, + 'error' => '', + 'token' => '', + 'user_id'=> 0, + ]; + } + + $user = self::validate_reset_token($user_id, $token); + if (is_wp_error($user)) { + return [ + 'mode' => 'invalid', + 'user' => null, + 'error' => $user->get_error_message(), + 'token' => $token, + 'user_id'=> $user_id, + ]; + } + + return [ + 'mode' => 'reset', + 'user' => $user, + 'error' => '', + 'token' => $token, + 'user_id'=> $user_id, + ]; + } + + public static function request_password_reset(array $post): array { + if (!wp_verify_nonce($post['_reset_nonce'] ?? '', 'aksrt_reset_password')) { + return ['success' => false, 'error' => __('安全验证失败,请刷新页面重试', 'aksrt')]; + } + + if (function_exists('aksrt_is_captcha_enabled_for') && aksrt_is_captcha_enabled_for('lostpassword')) { + if (function_exists('aksrt_verify_captcha')) { + $captcha_result = aksrt_verify_captcha($post); + if ($captcha_result !== true) { + return ['success' => false, 'error' => $captcha_result]; + } + } + } + + $raw_login = trim((string) ($post['user_login'] ?? '')); + if ($raw_login === '') { + return ['success' => false, 'error' => __('请输入邮箱地址或用户名', 'aksrt')]; + } + + $user = null; + if (is_email($raw_login)) { + $user = get_user_by('email', $raw_login); + } else { + $user = get_user_by('login', sanitize_user($raw_login, true)); + } + + if (!$user || is_wp_error($user)) { + return ['success' => true, 'message' => __('如果该账号存在,重置邮件已发送到注册邮箱', 'aksrt')]; + } + + $token = self::create_reset_token($user->ID); + $sent = self::send_password_reset_email($user, $token); + + if (!$sent) { + return ['success' => false, 'error' => __('重置邮件发送失败,请稍后再试', 'aksrt')]; + } + + return ['success' => true, 'message' => __('如果该账号存在,重置邮件已发送到注册邮箱', 'aksrt')]; + } + + public static function reset_password_with_token(array $post): array { + if (!wp_verify_nonce($post['_setpw_nonce'] ?? '', 'aksrt_set_password')) { + return ['success' => false, 'error' => __('安全验证失败,请刷新页面重试', 'aksrt')]; + } + + $token = sanitize_text_field($post['reset_token'] ?? ''); + $user_id = isset($post['reset_uid']) ? absint($post['reset_uid']) : 0; + $pass1 = (string) ($post['pass1'] ?? ''); + $pass2 = (string) ($post['pass2'] ?? ''); + + $user = self::validate_reset_token($user_id, $token); + if (is_wp_error($user)) { + return ['success' => false, 'error' => $user->get_error_message(), 'mode' => 'invalid']; + } + + $password_check = self::validate_password($pass1, $pass2); + if ($password_check !== true) { + return ['success' => false, 'error' => $password_check, 'mode' => 'reset']; + } + + wp_set_password($pass1, $user_id); + self::clear_reset_token($user_id); + + return ['success' => true, 'message' => __('密码已设置成功!', 'aksrt')]; + } + + private static function create_reset_token(int $user_id): string { + $token = wp_generate_password(48, false, false); + update_user_meta($user_id, self::RESET_TOKEN_HASH_META, hash('sha256', $token)); + update_user_meta($user_id, self::RESET_TOKEN_EXPIRES_META, time() + self::RESET_TOKEN_TTL); + update_user_meta($user_id, self::RESET_TOKEN_REQUESTED_AT, time()); + return $token; + } + + private static function validate_reset_token(int $user_id, string $token) { + if ($user_id <= 0 || $token === '') { + return new WP_Error('invalid_reset_token', __('重置链接无效或已过期', 'aksrt')); + } + + $user = get_userdata($user_id); + if (!$user || is_wp_error($user)) { + return new WP_Error('invalid_reset_token', __('重置链接无效或已过期', 'aksrt')); + } + + $stored_hash = (string) get_user_meta($user_id, self::RESET_TOKEN_HASH_META, true); + $expires_at = (int) get_user_meta($user_id, self::RESET_TOKEN_EXPIRES_META, true); + $token_hash = hash('sha256', $token); + + if ($stored_hash === '' || $expires_at <= time() || !hash_equals($stored_hash, $token_hash)) { + return new WP_Error('invalid_reset_token', __('重置链接无效或已过期', 'aksrt')); + } + + return $user; + } + + private static function clear_reset_token(int $user_id): void { + delete_user_meta($user_id, self::RESET_TOKEN_HASH_META); + delete_user_meta($user_id, self::RESET_TOKEN_EXPIRES_META); + delete_user_meta($user_id, self::RESET_TOKEN_REQUESTED_AT); + } + + private static function send_password_reset_email(WP_User $user, string $token): bool { + $reset_url = aksrt_get_reset_password_url($token, $user->ID); + $site_name = get_bloginfo('name'); + $default_subject = '[' . $site_name . '] ' . __('重置密码', 'aksrt'); + $default_content = sprintf( + __("您好 %s,\n\n有人请求重置您在 %s 的密码。\n\n请点击以下链接设置新密码:\n\n%s\n\n如果这不是您本人的操作,请忽略此邮件,您的密码不会被更改。", 'aksrt'), + $user->user_login, $site_name, $reset_url + ); + + return AKSRT_Settings::send_event_email( + 'password_reset', $user->user_email, $default_subject, $default_content, + ['{username}' => $user->user_login, '{site_name}' => $site_name, '{url}' => $reset_url] + ); + } + + /* ================================================================= + * 手机号认证(注册 / 登录 / 绑定) + * 对齐 zibll 的 signup_captcha / signin_captcha / bind_phone_captcha + * ================================================================= */ + + /** + * 手机号注册 + * + * @param array $post POST 数据 (phone, code, password, invite_code) + * @return array{success: bool, user_id?: int, error?: string, redirect?: string} + */ + public static function register_by_phone(array $post): array { + $phone = sanitize_text_field((string) ($post['phone'] ?? '')); + $code = sanitize_text_field((string) ($post['code'] ?? '')); + $pass = (string) ($post['password'] ?? ''); + + if (!preg_match('/^1[3-9]\d{9}$/', $phone)) { + return ['success' => false, 'error' => __('手机号格式不正确', 'aksrt')]; + } + if (empty($code)) { + return ['success' => false, 'error' => __('请输入验证码', 'aksrt')]; + } + if (strlen($pass) < 6) { + return ['success' => false, 'error' => __('密码至少 6 位', 'aksrt')]; + } + + // 验证 SMS 验证码 + if (class_exists('AKSRT_SMS')) { + $verify_result = AKSRT_SMS::get_instance()->verify_code($phone, $code); + if (empty($verify_result['success'])) { + return ['success' => false, 'error' => $verify_result['message'] ?? __('验证码不正确', 'aksrt')]; + } + } else { + return ['success' => false, 'error' => __('短信服务不可用', 'aksrt')]; + } + + // 检查手机号是否已注册 + $existing_user = self::get_user_by_phone($phone); + if ($existing_user) { + return ['success' => false, 'error' => __('该手机号已注册', 'aksrt')]; + } + + // 生成用户名(phone_ + 后4位) + $username = 'phone_' . substr($phone, -4); + $suffix = 1; + $base = $username; + while (username_exists($username)) { + $username = $base . '_' . $suffix; + $suffix++; + } + + // 创建用户 + $user_id = wp_create_user($username, $pass, $phone . '@sms.aksrt.local'); + if (is_wp_error($user_id)) { + return ['success' => false, 'error' => $user_id->get_error_message()]; + } + + // 绑定手机号 + update_user_meta($user_id, 'aksrt_phone', $phone); + update_user_meta($user_id, 'aksrt_phone_verified', '1'); + + // 邮箱标记为已验证(手机注册等同于已验证) + update_user_meta($user_id, 'aksrt_email_verified', '1'); + + // 处理邀请码 + $invite_code = sanitize_key((string) ($post['invite_code'] ?? '')); + if ($invite_code !== '' && class_exists('AKSRT_Invite')) { + AKSRT_Invite::get_instance()->handle_registration_invite($user_id, $invite_code); + } + + // 自动登录 + wp_set_current_user($user_id); + wp_set_auth_cookie($user_id, true); + + $redirect = function_exists('aksrt_get_user_center_url') + ? aksrt_get_user_center_url() + : home_url('/'); + + return [ + 'success' => true, + 'user_id' => $user_id, + 'redirect' => $redirect, + 'message' => __('注册成功', 'aksrt'), + ]; + } + + /** + * 手机号登录(密码登录) + * + * @param array $post (phone, password) + * @return array + */ + public static function login_by_phone_password(array $post): array { + $phone = sanitize_text_field((string) ($post['phone'] ?? '')); + $pass = (string) ($post['password'] ?? ''); + + if (!preg_match('/^1[3-9]\d{9}$/', $phone)) { + return ['success' => false, 'error' => __('手机号格式不正确', 'aksrt')]; + } + + $user = self::get_user_by_phone($phone); + if (!$user) { + return ['success' => false, 'error' => __('该手机号未注册', 'aksrt')]; + } + + // 验证密码 + if (!wp_check_password($pass, $user->data->user_pass, $user->ID)) { + return ['success' => false, 'error' => __('密码不正确', 'aksrt')]; + } + + wp_set_current_user($user->ID); + wp_set_auth_cookie($user->ID, true); + + $redirect = function_exists('aksrt_get_user_center_url') + ? aksrt_get_user_center_url() + : home_url('/'); + + return [ + 'success' => true, + 'user_id' => $user->ID, + 'redirect' => $redirect, + ]; + } + + /** + * 手机号免密登录(SMS 验证码登录) + * + * @param array $post (phone, code) + * @return array + */ + public static function login_by_phone_code(array $post): array { + $phone = sanitize_text_field((string) ($post['phone'] ?? '')); + $code = sanitize_text_field((string) ($post['code'] ?? '')); + + if (!preg_match('/^1[3-9]\d{9}$/', $phone)) { + return ['success' => false, 'error' => __('手机号格式不正确', 'aksrt')]; + } + + // 验证 SMS 验证码 + if (class_exists('AKSRT_SMS')) { + $verify_result = AKSRT_SMS::get_instance()->verify_code($phone, $code); + if (empty($verify_result['success'])) { + return ['success' => false, 'error' => $verify_result['message'] ?? __('验证码不正确', 'aksrt')]; + } + } else { + return ['success' => false, 'error' => __('短信服务不可用', 'aksrt')]; + } + + $user = self::get_user_by_phone($phone); + if (!$user) { + return ['success' => false, 'error' => __('该手机号未注册', 'aksrt')]; + } + + wp_set_current_user($user->ID); + wp_set_auth_cookie($user->ID, true); + + $redirect = function_exists('aksrt_get_user_center_url') + ? aksrt_get_user_center_url() + : home_url('/'); + + return [ + 'success' => true, + 'user_id' => $user->ID, + 'redirect' => $redirect, + ]; + } + + /** + * 绑定手机号到当前用户 + * + * @param array $post (phone, code) + * @return array + */ + public static function bind_phone(array $post): array { + if (!is_user_logged_in()) { + return ['success' => false, 'error' => __('请先登录', 'aksrt')]; + } + + $phone = sanitize_text_field((string) ($post['phone'] ?? '')); + $code = sanitize_text_field((string) ($post['code'] ?? '')); + + if (!preg_match('/^1[3-9]\d{9}$/', $phone)) { + return ['success' => false, 'error' => __('手机号格式不正确', 'aksrt')]; + } + + // 验证 SMS 验证码 + if (class_exists('AKSRT_SMS')) { + $verify_result = AKSRT_SMS::get_instance()->verify_code($phone, $code); + if (empty($verify_result['success'])) { + return ['success' => false, 'error' => $verify_result['message'] ?? __('验证码不正确', 'aksrt')]; + } + } else { + return ['success' => false, 'error' => __('短信服务不可用', 'aksrt')]; + } + + // 检查手机号是否已被其他用户绑定 + $existing = self::get_user_by_phone($phone); + if ($existing && (int) $existing->ID !== get_current_user_id()) { + return ['success' => false, 'error' => __('该手机号已被其他账号绑定', 'aksrt')]; + } + + $user_id = get_current_user_id(); + update_user_meta($user_id, 'aksrt_phone', $phone); + update_user_meta($user_id, 'aksrt_phone_verified', '1'); + + return [ + 'success' => true, + 'message' => __('手机号绑定成功', 'aksrt'), + ]; + } + + /** + * 根据手机号查找用户 + */ + private static function get_user_by_phone(string $phone): ?WP_User { + $users = get_users([ + 'meta_key' => 'aksrt_phone', + 'meta_value' => $phone, + 'number' => 1, + 'fields' => 'all', + ]); + return !empty($users) ? $users[0] : null; + } +} diff --git a/inc/classes/auth/class-aksrt-auth-email-verification.php b/inc/classes/auth/class-aksrt-auth-email-verification.php new file mode 100644 index 00000000..da1b6dc5 --- /dev/null +++ b/inc/classes/auth/class-aksrt-auth-email-verification.php @@ -0,0 +1,529 @@ +ID); + } + + public static function is_email_verified(int $user_id): bool { + return (int) get_user_meta($user_id, AKSRT_Auth_Service::EMAIL_VERIFIED_META, true) === 1; + } + + public static function mark_email_verified(int $user_id): void { + $was_verified = (int) get_user_meta($user_id, AKSRT_Auth_Service::EMAIL_VERIFIED_META, true) === 1; + update_user_meta($user_id, AKSRT_Auth_Service::EMAIL_VERIFIED_META, 1); + self::clear_email_verification($user_id); + // 仅在状态由"未验证 → 已验证"跳变时触发,避免历史 backfill / 重复调用造成奖励重发。 + // 监听方负责自己幂等(aksrt_invite 表的 status 检查已经做到这一点)。 + if (!$was_verified) { + do_action('aksrt_email_verified', $user_id); + } + } + + public static function clear_email_verification(int $user_id): void { + delete_user_meta($user_id, AKSRT_Auth_Service::EMAIL_METHOD_META); + delete_user_meta($user_id, AKSRT_Auth_Service::EMAIL_TOKEN_HASH_META); + delete_user_meta($user_id, AKSRT_Auth_Service::EMAIL_CODE_HASH_META); + delete_user_meta($user_id, AKSRT_Auth_Service::EMAIL_EXPIRES_META); + delete_user_meta($user_id, AKSRT_Auth_Service::EMAIL_REQUESTED_AT_META); + delete_user_meta($user_id, AKSRT_Auth_Service::EMAIL_TARGET_META); + delete_user_meta($user_id, AKSRT_Auth_Service::EMAIL_OBK_META); + self::reset_email_code_attempt_trackers($user_id); + } + + // ============================================================ + // 公开 API:管理员后台 / 内部业务调用 + // ============================================================ + + /** + * 是否已验证邮箱(公开版本,给后台 UI / 用户列表渲染用)。 + */ + public static function user_is_email_verified(int $user_id): bool { + return self::is_email_verified($user_id); + } + + /** + * 取一个用户当前的验证状态摘要,用于后台资料页展示。 + * + * @return array{verified:bool, method:string, target_email:string, expires_at:int, requested_at:int, last_admin_log:array} + */ + public static function get_user_verification_state(int $user_id): array { + return [ + 'verified' => self::is_email_verified($user_id), + 'method' => (string) get_user_meta($user_id, AKSRT_Auth_Service::EMAIL_METHOD_META, true), + 'target_email' => (string) get_user_meta($user_id, AKSRT_Auth_Service::EMAIL_TARGET_META, true), + 'expires_at' => (int) get_user_meta($user_id, AKSRT_Auth_Service::EMAIL_EXPIRES_META, true), + 'requested_at' => (int) get_user_meta($user_id, AKSRT_Auth_Service::EMAIL_REQUESTED_AT_META, true), + 'last_admin_log' => (array) (get_user_meta($user_id, 'aksrt_email_verification_admin_log', true) ?: []), + ]; + } + + /** + * 管理员:把指定用户标记为已验证(清除所有进行中的验证 meta)。 + */ + public static function admin_mark_verified(int $user_id, int $by_admin_id = 0): array { + if ($user_id <= 0) { + return ['success' => false, 'error' => __('无效的用户 ID', 'aksrt')]; + } + if (!current_user_can('edit_user', $user_id)) { + return ['success' => false, 'error' => __('权限不足', 'aksrt')]; + } + self::mark_email_verified($user_id); + self::record_admin_action($user_id, 'mark_verified', $by_admin_id); + AKSRT_Auth_Service::log_event('admin.action', ['action' => 'mark_verified', 'uid' => $user_id, 'by' => $by_admin_id]); + return ['success' => true, 'message' => __('已标记为已验证', 'aksrt')]; + } + + /** + * 管理员:撤销已验证状态(用于测试 / 回滚 / 应对邮箱泄漏)。 + */ + public static function admin_unmark_verified(int $user_id, int $by_admin_id = 0): array { + if ($user_id <= 0) { + return ['success' => false, 'error' => __('无效的用户 ID', 'aksrt')]; + } + if (!current_user_can('edit_user', $user_id)) { + return ['success' => false, 'error' => __('权限不足', 'aksrt')]; + } + update_user_meta($user_id, AKSRT_Auth_Service::EMAIL_VERIFIED_META, 0); + self::clear_email_verification($user_id); + self::record_admin_action($user_id, 'unmark_verified', $by_admin_id); + AKSRT_Auth_Service::log_event('admin.action', ['action' => 'unmark_verified', 'uid' => $user_id, 'by' => $by_admin_id]); + return ['success' => true, 'message' => __('已撤销验证状态', 'aksrt')]; + } + /** + * 管理员:立即发送 / 重发验证邮件(绕过 60s 节流)。 + */ + public static function admin_send_verification(int $user_id, int $by_admin_id = 0): array { + if ($user_id <= 0) { + return ['success' => false, 'error' => __('无效的用户 ID', 'aksrt')]; + } + if (!current_user_can('edit_user', $user_id)) { + return ['success' => false, 'error' => __('权限不足', 'aksrt')]; + } + $user = get_userdata($user_id); + if (!$user || is_wp_error($user)) { + return ['success' => false, 'error' => __('用户不存在', 'aksrt')]; + } + + $method = AKSRT_Auth_Service::current_registration_verification_method(); + $email = (string) get_user_meta($user_id, AKSRT_Auth_Service::EMAIL_TARGET_META, true); + if (!in_array($method, ['link', 'code'], true)) { + $method = AKSRT_Settings::registration_verification_method(); + } + if ($email === '' || !is_email($email)) { + $email = (string) $user->user_email; + } + + $result = self::begin_email_verification($user, $method, [ + 'email' => $email, + 'obk' => (string) get_user_meta($user_id, AKSRT_Auth_Service::EMAIL_OBK_META, true), + 'bypass_throttle' => true, + 'force' => true, + ]); + + // 标记为待验证(确保 admin 主动发邮件后用户被视为未验证) + if (!empty($result['success'])) { + update_user_meta($user_id, AKSRT_Auth_Service::EMAIL_VERIFIED_META, 0); + self::record_admin_action($user_id, 'send_verification', $by_admin_id); + AKSRT_Auth_Service::log_event('admin.action', ['action' => 'send_verification', 'uid' => $user_id, 'by' => $by_admin_id]); + } + + return $result; + } + + /** + * 把已存在的用户视为受信任(grandfather):写入 aksrt_email_verified=1。 + * + * 仅写入 meta 不存在的用户;已经显式为 0 的(正在走验证流程的新注册)保持不动。 + * + * 用户量 > 50000 时改为异步分批;否则内联完成。返回处理总数。 + * + * @param bool $force 强制再次执行(即便 backfill_done 标记已存在),调试用 + */ + public static function backfill_legacy_users_as_verified(bool $force = false): int { + if (!$force && get_option('aksrt_email_verification_backfill_done')) { + return 0; + } + + $total = 0; + if (function_exists('count_users')) { + $counts = count_users(); + $total = (int) ($counts['total_users'] ?? 0); + } + + // 大表保护:超过 5w 用户改为后台 cron 分批 + if ($total > 50000) { + if (!wp_next_scheduled('aksrt_email_verification_backfill_batch')) { + wp_schedule_single_event(time() + 30, 'aksrt_email_verification_backfill_batch', [0]); + } + update_option('aksrt_email_verification_backfill_log', [ + 'mode' => 'async', + 'total' => $total, + 'scheduled' => time(), + 'by' => get_current_user_id(), + ]); + return 0; + } + + // 内联分批,每批 500 + $updated = 0; + $paged = 1; + do { + $users = get_users([ + 'number' => 500, + 'paged' => $paged, + 'fields' => ['ID'], + 'meta_query' => [ + [ + 'key' => AKSRT_Auth_Service::EMAIL_VERIFIED_META, + 'compare' => 'NOT EXISTS', + ], + ], + ]); + + if (empty($users)) { + break; + } + + foreach ($users as $u) { + update_user_meta((int) $u->ID, AKSRT_Auth_Service::EMAIL_VERIFIED_META, 1); + $updated++; + } + + $paged++; + // 让 MySQL 喘口气 + if (function_exists('usleep')) { + usleep(50000); + } + } while (count($users) === 500 && $paged < 200); // 上限 10w 兜底防呆 + + update_option('aksrt_email_verification_backfill_done', time()); + update_option('aksrt_email_verification_backfill_log', [ + 'mode' => 'inline', + 'count' => $updated, + 'ran_at' => time(), + 'by' => get_current_user_id(), + ]); + AKSRT_Auth_Service::log_event('admin.backfill', ['count' => $updated, 'by' => get_current_user_id()]); + + return $updated; + } + + /** + * 异步分批回填:cron 钩子。每批 500,自动调度下一批直到完成。 + */ + public static function run_async_backfill_batch(int $offset = 0): void { + $users = get_users([ + 'number' => 500, + 'offset' => max(0, $offset), + 'fields' => ['ID'], + 'orderby' => 'ID', + 'order' => 'ASC', + 'meta_query' => [ + [ + 'key' => AKSRT_Auth_Service::EMAIL_VERIFIED_META, + 'compare' => 'NOT EXISTS', + ], + ], + ]); + + if (empty($users)) { + update_option('aksrt_email_verification_backfill_done', time()); + $log = (array) get_option('aksrt_email_verification_backfill_log', []); + $log['finished_at'] = time(); + update_option('aksrt_email_verification_backfill_log', $log); + AKSRT_Auth_Service::log_event('admin.backfill.finished', []); + return; + } + + foreach ($users as $u) { + update_user_meta((int) $u->ID, AKSRT_Auth_Service::EMAIL_VERIFIED_META, 1); + } + + // 调度下一批 + wp_schedule_single_event(time() + 30, 'aksrt_email_verification_backfill_batch', [$offset + 500]); + } + + /** + * 记录管理员操作日志(每个用户最多 10 条 ring buffer)。 + */ + public static function record_admin_action(int $user_id, string $action, int $by_admin_id): void { + $log = get_user_meta($user_id, 'aksrt_email_verification_admin_log', true); + if (!is_array($log)) { + $log = []; + } + $log[] = [ + 't' => time(), + 'action' => $action, + 'by' => $by_admin_id ?: get_current_user_id(), + ]; + if (count($log) > 10) { + $log = array_slice($log, -10); + } + update_user_meta($user_id, 'aksrt_email_verification_admin_log', $log); + } + // ============================================================ + // 验证码失败计数 / IP 节流 + // ============================================================ + + public static function email_code_ip_throttle_key(int $user_id): string { + $ip = sanitize_text_field((string) ($_SERVER['REMOTE_ADDR'] ?? '')); + if ($user_id <= 0 || $ip === '') { + return ''; + } + + return 'aksrt_email_code_try_' . $user_id . '_' . md5($ip); + } + + public static function reset_email_code_attempt_trackers(int $user_id): void { + delete_user_meta($user_id, AKSRT_Auth_Service::EMAIL_CODE_ATTEMPTS_META); + $ip_key = self::email_code_ip_throttle_key($user_id); + if ($ip_key !== '') { + delete_transient($ip_key); + } + } + + public static function fail_email_code_attempt(int $user_id): array { + $attempts = (int) get_user_meta($user_id, AKSRT_Auth_Service::EMAIL_CODE_ATTEMPTS_META, true); + $attempts++; + update_user_meta($user_id, AKSRT_Auth_Service::EMAIL_CODE_ATTEMPTS_META, $attempts); + + $ip_attempts = 0; + $ip_key = self::email_code_ip_throttle_key($user_id); + if ($ip_key !== '') { + $ip_attempts = (int) get_transient($ip_key); + $ip_attempts++; + set_transient($ip_key, $ip_attempts, AKSRT_Auth_Service::EMAIL_CODE_IP_TTL); + } + + if ($attempts >= AKSRT_Auth_Service::EMAIL_CODE_MAX_ATTEMPTS || $ip_attempts >= AKSRT_Auth_Service::EMAIL_CODE_MAX_ATTEMPTS) { + delete_user_meta($user_id, AKSRT_Auth_Service::EMAIL_CODE_HASH_META); + update_user_meta($user_id, AKSRT_Auth_Service::EMAIL_REQUESTED_AT_META, 0); + AKSRT_Auth_Service::log_event('verify.code.locked', ['uid' => $user_id, 'attempts' => $attempts]); + return ['success' => false, 'error' => __('验证码错误次数过多,当前验证码已失效,请重新发送。', 'aksrt')]; + } + + AKSRT_Auth_Service::log_event('verify.code.fail', ['uid' => $user_id, 'attempts' => $attempts]); + return ['success' => false, 'error' => __('邮箱验证码错误或已过期,请重新输入。', 'aksrt')]; + } + + // ============================================================ + // 验证流程:发起 / 校验 / 完成 + // ============================================================ + + public static function begin_email_verification(WP_User $user, string $method, array $args = []): array { + $method = in_array($method, ['link', 'code'], true) ? $method : 'link'; + $email = sanitize_email((string) ($args['email'] ?? $user->user_email)); + $obk = sanitize_text_field((string) ($args['obk'] ?? '')); + $bypass_throttle = !empty($args['bypass_throttle']); + if ($email === '' || !is_email($email)) { + return ['success' => false, 'error' => __('当前账号缺少有效邮箱,无法发送验证邮件', 'aksrt')]; + } + + // 节流:仅在非管理员路径生效(admin_send_verification 会传 bypass_throttle=true) + if (!$bypass_throttle) { + $last_requested = (int) get_user_meta($user->ID, AKSRT_Auth_Service::EMAIL_REQUESTED_AT_META, true); + if ($last_requested > 0 && (time() - $last_requested) < 60 && empty($args['force'])) { + return ['success' => false, 'error' => __('操作过于频繁,请稍后再试', 'aksrt')]; + } + } + + $expires_at = time() + (AKSRT_Settings::registration_verification_ttl_minutes() * MINUTE_IN_SECONDS); + update_user_meta($user->ID, AKSRT_Auth_Service::EMAIL_VERIFIED_META, 0); + update_user_meta($user->ID, AKSRT_Auth_Service::EMAIL_METHOD_META, $method); + update_user_meta($user->ID, AKSRT_Auth_Service::EMAIL_TARGET_META, $email); + update_user_meta($user->ID, AKSRT_Auth_Service::EMAIL_EXPIRES_META, $expires_at); + update_user_meta($user->ID, AKSRT_Auth_Service::EMAIL_REQUESTED_AT_META, time()); + if ($obk !== '') { + update_user_meta($user->ID, AKSRT_Auth_Service::EMAIL_OBK_META, $obk); + } + + delete_user_meta($user->ID, AKSRT_Auth_Service::EMAIL_TOKEN_HASH_META); + delete_user_meta($user->ID, AKSRT_Auth_Service::EMAIL_CODE_HASH_META); + + if ($method === 'code') { + $code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); + update_user_meta($user->ID, AKSRT_Auth_Service::EMAIL_CODE_HASH_META, wp_hash_password($code)); + // 新验证码 = 重置失败计数 / IP 节流,避免被旧计数立即锁死 + self::reset_email_code_attempt_trackers((int) $user->ID); + $sent = self::send_registration_verification_code_email($user, $email, $code); + } else { + $token = wp_generate_password(48, false, false); + update_user_meta($user->ID, AKSRT_Auth_Service::EMAIL_TOKEN_HASH_META, hash('sha256', $token)); + $sent = self::send_registration_verification_link_email($user, $email, $token); + } + + if (!$sent) { + self::clear_email_verification((int) $user->ID); + AKSRT_Auth_Service::log_event('email.send.fail', ['uid' => (int) $user->ID, 'method' => $method]); + return ['success' => false, 'error' => __('验证邮件发送失败,请稍后重试', 'aksrt')]; + } + + AKSRT_Auth_Service::log_event('email.send.ok', ['uid' => (int) $user->ID, 'method' => $method]); + + $message = $method === 'code' + ? __('注册成功,验证码已发送到你的邮箱,请完成验证后继续。', 'aksrt') + : __('注册成功,验证邮件已发送到你的邮箱,请点击邮件中的链接完成验证。', 'aksrt'); + + return [ + 'success' => true, + 'message' => $message, + ]; + } + + public static function verify_email_token(int $user_id, string $token): array { + $user = get_userdata($user_id); + if (!$user || is_wp_error($user)) { + return ['success' => false, 'error' => __('验证账号不存在或已失效', 'aksrt')]; + } + + $stored_hash = (string) get_user_meta($user_id, AKSRT_Auth_Service::EMAIL_TOKEN_HASH_META, true); + $expires_at = (int) get_user_meta($user_id, AKSRT_Auth_Service::EMAIL_EXPIRES_META, true); + if ($stored_hash === '' || $expires_at <= time() || !hash_equals($stored_hash, hash('sha256', $token))) { + return ['success' => false, 'error' => __('验证链接无效或已过期,请重新发送验证邮件。', 'aksrt')]; + } + + return self::complete_email_verification($user); + } + + public static function verify_email_code(int $user_id, string $code): array { + $user = get_userdata($user_id); + if (!$user || is_wp_error($user)) { + return ['success' => false, 'error' => __('验证账号不存在或已失效', 'aksrt')]; + } + + // 先做 IP 节流:相同 IP 在 EMAIL_CODE_IP_TTL 内累计失败到上限即直接拒绝 + $ip_key = self::email_code_ip_throttle_key($user_id); + if ($ip_key !== '') { + $ip_attempts = (int) get_transient($ip_key); + if ($ip_attempts >= AKSRT_Auth_Service::EMAIL_CODE_MAX_ATTEMPTS) { + AKSRT_Auth_Service::log_event('verify.code.ip_throttled', ['uid' => $user_id, 'attempts' => $ip_attempts]); + return ['success' => false, 'error' => __('操作过于频繁,请稍后再试', 'aksrt')]; + } + } + + // 用户级失败计数:达到上限后该验证码立即作废 + $user_attempts = (int) get_user_meta($user_id, AKSRT_Auth_Service::EMAIL_CODE_ATTEMPTS_META, true); + if ($user_attempts >= AKSRT_Auth_Service::EMAIL_CODE_MAX_ATTEMPTS) { + delete_user_meta($user_id, AKSRT_Auth_Service::EMAIL_CODE_HASH_META); + update_user_meta($user_id, AKSRT_Auth_Service::EMAIL_REQUESTED_AT_META, 0); + AKSRT_Auth_Service::log_event('verify.code.locked.read', ['uid' => $user_id]); + return ['success' => false, 'error' => __('验证码错误次数过多,当前验证码已失效,请重新发送。', 'aksrt')]; + } + + $stored_hash = (string) get_user_meta($user_id, AKSRT_Auth_Service::EMAIL_CODE_HASH_META, true); + $expires_at = (int) get_user_meta($user_id, AKSRT_Auth_Service::EMAIL_EXPIRES_META, true); + if ($stored_hash === '' || $expires_at <= time() || !wp_check_password($code, $stored_hash)) { + return self::fail_email_code_attempt($user_id); + } + + self::reset_email_code_attempt_trackers($user_id); + return self::complete_email_verification($user); + } + + public static function complete_email_verification(WP_User $user): array { + $user_id = (int) $user->ID; + $obk = (string) get_user_meta($user_id, AKSRT_Auth_Service::EMAIL_OBK_META, true); + $oauth = $obk !== '' ? AKSRT_Auth_Service::get_oauth_bind_context($obk) : null; + $email = (string) get_user_meta($user_id, AKSRT_Auth_Service::EMAIL_TARGET_META, true); + $was_verified = (int) get_user_meta($user_id, AKSRT_Auth_Service::EMAIL_VERIFIED_META, true) === 1; + + update_user_meta($user_id, AKSRT_Auth_Service::EMAIL_VERIFIED_META, 1); + self::clear_email_verification($user_id); + AKSRT_Auth_Service::log_event('verify.success', ['uid' => $user_id]); + // 状态跳变时触发 hook,邀请系统等模块据此发奖 + if (!$was_verified) { + do_action('aksrt_email_verified', $user_id); + } + if ($email !== '' && is_email($email)) { + self::send_registration_verified_notice_email($user, $email); + } + + if ($oauth && is_array($oauth)) { + AKSRT_Auth_Service::finalize_oauth_registration($user_id, $oauth, $obk); + } elseif (AKSRT_Settings::registration_auto_login_after_verify()) { + wp_set_current_user($user_id); + wp_set_auth_cookie($user_id, true); + } + + $redirect = function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url() : home_url('/'); + if (!AKSRT_Settings::registration_auto_login_after_verify()) { + $redirect = aksrt_get_login_url(); + } + + return [ + 'success' => true, + 'message' => __('邮箱验证成功,即将进入你的账号。', 'aksrt'), + 'redirect' => $redirect, + ]; + } + + // ============================================================ + // 通知邮件 + // ============================================================ + + public static function send_registration_verification_link_email(WP_User $user, string $email, string $token): bool { + $url = AKSRT_Auth_Service::get_email_verification_url((int) $user->ID, ['token' => $token]); + $site_name = get_bloginfo('name'); + $default_subject = '[' . $site_name . '] ' . __('验证你的邮箱地址', 'aksrt'); + $default_content = sprintf( + __("您好 %s,\n\n欢迎注册 %s。\n\n请点击以下链接完成邮箱验证:\n\n%s\n\n验证成功后系统将自动为你登录。\n\n如果这不是你的操作,请忽略此邮件。", 'aksrt'), + $user->user_login, $site_name, $url + ); + + return AKSRT_Settings::send_event_email( + 'reg_verify_link', $email, $default_subject, $default_content, + ['{username}' => $user->user_login, '{site_name}' => $site_name, '{url}' => $url] + ); + } + + public static function send_registration_verification_code_email(WP_User $user, string $email, string $code): bool { + $site_name = get_bloginfo('name'); + $default_subject = '[' . $site_name . '] ' . __('邮箱验证码', 'aksrt'); + $default_content = sprintf( + __("您好 %s,\n\n欢迎注册 %s。\n\n你的邮箱验证码为:\n\n【%s】\n\n验证码验证成功后系统将自动为你登录。\n\n如果这不是你的操作,请忽略此邮件。", 'aksrt'), + $user->user_login, $site_name, $code + ); + + return AKSRT_Settings::send_event_email( + 'reg_verify_code', $email, $default_subject, $default_content, + ['{username}' => $user->user_login, '{site_name}' => $site_name, '{code}' => $code] + ); + } + + public static function send_registration_verified_notice_email(WP_User $user, string $email): bool { + $site_name = get_bloginfo('name'); + $default_subject = '[' . $site_name . '] ' . __('邮箱验证成功', 'aksrt'); + $default_content = sprintf( + __("您好 %s,\n\n你在 %s 的账号邮箱已经验证成功。\n\n如果这不是你的操作,请立即联系站点管理员。", 'aksrt'), + $user->user_login, $site_name + ); + + return AKSRT_Settings::send_event_email( + 'reg_success', $email, $default_subject, $default_content, + ['{username}' => $user->user_login, '{site_name}' => $site_name] + ); + } +} diff --git a/inc/classes/class-aksrt-admin.php b/inc/classes/class-aksrt-admin.php new file mode 100644 index 00000000..ebfb4909 --- /dev/null +++ b/inc/classes/class-aksrt-admin.php @@ -0,0 +1,254 @@ +'; + echo '
'; + echo '
'; + } + + public function enqueue_admin_assets($hook): void { + $screen = function_exists('get_current_screen') ? get_current_screen() : null; + if (!$screen || $screen->base !== 'toplevel_page_' . self::PAGE_SLUG) { + return; + } + + $build_dir = trailingslashit(AKSRT_DIR) . 'assets/admin/build/'; + $build_uri = trailingslashit(AKSRT_URI) . 'assets/admin/build/'; + + $css_files = [ + 'naive-ui.css', + 'index.css', + 'index2.css', + ]; + foreach ($css_files as $css_file) { + $path = $build_dir . $css_file; + if (file_exists($path)) { + wp_enqueue_style( + 'aksrt-admin-' . sanitize_key(str_replace(['.', '/'], '-', $css_file)), + $build_uri . $css_file, + [], + (string) filemtime($path) + ); + } + } + + $entry = $build_dir . 'index.js'; + if (file_exists($entry)) { + wp_enqueue_script( + 'aksrt-admin-app', + $build_uri . 'index.js', + [], + (string) filemtime($entry), + true + ); + + wp_localize_script('aksrt-admin-app', 'aksrtAdminData', [ + 'apiUrl' => esc_url_raw(rest_url(self::API_NAMESPACE)), + 'nonce' => wp_create_nonce('wp_rest'), + 'siteUrl' => home_url('/'), + 'adminUrl' => admin_url('/'), + 'themeVersion' => wp_get_theme()->get('Version') ?: '3.4.0-dev', + ]); + } + + wp_enqueue_media(); + } + + public function add_module_type_attribute(string $tag, string $handle): string { + if ('aksrt-admin-app' === $handle) { + return str_replace(' +'; ?> + is_enabled()) { + // If disabled, ensure old rules are cleaned up + add_action('init', [$this, 'maybe_restore']); + return; + } + + add_filter('category_link', [$this, 'remove_category_base'], 10, 2); + add_filter('term_link', [$this, 'remove_category_base_term'], 10, 3); + add_action('init', [$this, 'add_rewrite_rules']); + add_action('template_redirect', [$this, 'redirect_old_urls']); + add_action('admin_init', [$this, 'maybe_flush']); + } + + /** + * Check if feature is enabled + */ + private function is_enabled(): bool { + if (!class_exists('AKSRT_Settings')) return false; + // schema key = seo_no_category; runtime alias = seo_remove_category + return (bool) ( + AKSRT_Settings::get_value('seo_remove_category', false) + ?: AKSRT_Settings::get_value('seo_no_category', false) + ); + } + + /** + * Remove /category/ from category link + */ + public function remove_category_base(string $link, $term): string { + $base = get_option('category_base'); + if ($base && $base !== 'category') return $link; + + // Only process category taxonomy + if ($term instanceof WP_Term && $term->taxonomy !== 'category') { + return $link; + } + + $category_base_url = preg_quote(trailingslashit(home_url('/category')), '#'); + return preg_replace('#' . $category_base_url . '#', trailingslashit(home_url('/')), $link, 1); + } + + /** + * Remove base from term_link for categories + */ + public function remove_category_base_term(string $link, $term, $taxonomy): string { + if ($taxonomy !== 'category') return $link; + return $this->remove_category_base($link, $term); + } + + /** + * Add rewrite rules for category URLs without /category/ + */ + public function add_rewrite_rules(): void { + // Get all category slugs + $categories = get_categories(['hide_empty' => false]); + foreach ($categories as $cat) { + if (empty($cat->slug)) continue; + $slug = $cat->slug; + + // /slug/ → index.php?category_name=$slug + add_rewrite_rule( + '^' . preg_quote($slug, '/') . '/?$', + 'index.php?category_name=' . $slug, + 'top' + ); + + // /slug/page/N/ → index.php?category_name=$slug&paged=$N + add_rewrite_rule( + '^' . preg_quote($slug, '/') . '/page/([0-9]+)/?$', + 'index.php?category_name=$slug&paged=$matches[1]', + 'top' + ); + + // /slug/feed/ → index.php?category_name=$slug&feed=$feed + add_rewrite_rule( + '^' . preg_quote($slug, '/') . '/feed/?$', + 'index.php?category_name=$slug&feed=rss2', + 'top' + ); + } + } + + /** + * 301 redirect old /category/ URLs to new ones + */ + public function redirect_old_urls(): void { + $request = $_SERVER['REQUEST_URI'] ?? ''; + $home_path = wp_parse_url(home_url('/'), PHP_URL_PATH); + + // Remove home path prefix + if ($home_path && strpos($request, $home_path) === 0) { + $request = substr($request, strlen($home_path)); + } + + // Check if request starts with /category/ + if (preg_match('#^category/(.+?)/?$#', $request, $matches)) { + $new_url = home_url('/' . $matches[1] . '/'); + wp_redirect($new_url, 301); + exit; + } + } + + /** + * Flush rewrite rules when setting changes + */ + public function maybe_flush(): void { + $rewrite_version = $this->rewrite_version(); + $stored = (string) get_option('aksrt_category_rewrite_flushed', ''); + if ($stored !== $rewrite_version) { + flush_rewrite_rules(); + update_option('aksrt_category_rewrite_flushed', $rewrite_version); + } + } + + /** + * Restore default rewrite rules when feature is disabled + */ + public function maybe_restore(): void { + $stored = (string) get_option('aksrt_category_rewrite_flushed', ''); + if ($stored !== '') { + flush_rewrite_rules(); + delete_option('aksrt_category_rewrite_flushed'); + } + } + + /** + * Force flush (call after enabling/disabling) + */ + public static function force_flush(): void { + flush_rewrite_rules(); + update_option('aksrt_category_rewrite_flushed', AKSRT_VERSION . ':category-rewrite-v1'); + } +} diff --git a/inc/classes/class-aksrt-charge-card.php b/inc/classes/class-aksrt-charge-card.php new file mode 100644 index 00000000..c5babef4 --- /dev/null +++ b/inc/classes/class-aksrt-charge-card.php @@ -0,0 +1,209 @@ +get_var($wpdb->prepare( + "SELECT id FROM {$this->table()} WHERE code = %s", + $code + )); + if ($exists) return 0; + + $result = $wpdb->insert($this->table(), [ + 'code' => $code, + 'amount' => $amount, + 'balance' => $amount, + 'status' => self::STATUS_ACTIVE, + 'expires_at' => $expires_at !== '' ? $expires_at : null, + 'created_at' => current_time('mysql'), + 'note' => $note, + ]); + + return $result ? (int) $wpdb->insert_id : 0; + } + + /** + * 批量生成充值卡 + */ + public function batch_create(float $amount, int $count, int $length = 16, string $prefix = ''): array { + $codes = []; + for ($i = 0; $i < $count; $i++) { + $code = $prefix . strtoupper(wp_generate_password($length, false)); + $id = $this->create([ + 'code' => $code, + 'amount' => $amount, + ]); + if ($id > 0) { + $codes[] = $code; + } + } + return $codes; + } + + /** + * 使用充值卡 + */ + public function redeem(string $code, int $user_id): array { + global $wpdb; + + $code = sanitize_text_field($code); + $card = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM {$this->table()} WHERE code = %s", + $code + ), ARRAY_A); + + if (!$card) { + return ['success' => false, 'message' => '充值卡不存在']; + } + + if ($card['status'] !== self::STATUS_ACTIVE) { + return ['success' => false, 'message' => '充值卡已使用或已失效']; + } + + if ($card['expires_at'] && strtotime($card['expires_at']) < time()) { + // 标记过期 + $wpdb->update($this->table(), ['status' => self::STATUS_EXPIRED], ['id' => (int) $card['id']]); + return ['success' => false, 'message' => '充值卡已过期']; + } + + $amount = (float) $card['balance']; + + // 更新卡状态 + $wpdb->update($this->table(), [ + 'status' => self::STATUS_USED, + 'used_by' => $user_id, + 'used_at' => current_time('mysql'), + 'balance' => 0, + ], ['id' => (int) $card['id']]); + + // 增加用户余额 + if (class_exists('AKSRT_Credits')) { + AKSRT_Credits::add($user_id, (int) $amount, 'charge_card', [ + 'ref_type' => 'charge_card', + 'ref_id' => (int) $card['id'], + ]); + } + + return [ + 'success' => true, + 'message' => sprintf('充值成功,已到账 %s 元', number_format($amount, 2)), + 'amount' => $amount, + ]; + } + + /** + * 获取充值卡列表 + */ + public function get_list(array $args = []): array { + global $wpdb; + + $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); + $offset = max(0, (int) ($args['offset'] ?? 0)); + $status = sanitize_key((string) ($args['status'] ?? '')); + + $where = []; + $params = []; + + if ($status !== '') { + $where[] = 'status = %s'; + $params[] = $status; + } + + $where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; + + $count_sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}"; + $total = $params ? (int) $wpdb->get_var($wpdb->prepare($count_sql, $params)) : (int) $wpdb->get_var($count_sql); + + $sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d"; + $params[] = $limit; + $params[] = $offset; + + $items = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A); + + return [ + 'items' => $items ?: [], + 'total' => $total, + 'page' => floor($offset / $limit) + 1, + 'max_pages' => ceil($total / $limit), + ]; + } + + /** + * 禁用充值卡 + */ + public function disable(int $id): bool { + global $wpdb; + return (bool) $wpdb->update($this->table(), [ + 'status' => self::STATUS_DISABLED, + ], ['id' => $id]); + } + + /** + * 获取统计 + */ + public function get_stats(): array { + global $wpdb; + $table = $this->table(); + + $row = $wpdb->get_row( + "SELECT + COUNT(*) as total, + SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) as active, + SUM(CASE WHEN status = 'used' THEN 1 ELSE 0 END) as used, + SUM(amount) as total_amount, + SUM(CASE WHEN status = 'used' THEN amount ELSE 0 END) as used_amount + FROM {$table}", + ARRAY_A + ); + + return [ + 'total' => (int) ($row['total'] ?? 0), + 'active' => (int) ($row['active'] ?? 0), + 'used' => (int) ($row['used'] ?? 0), + 'total_amount' => (float) ($row['total_amount'] ?? 0), + 'used_amount' => (float) ($row['used_amount'] ?? 0), + ]; + } +} diff --git a/inc/classes/class-aksrt-comment-actions.php b/inc/classes/class-aksrt-comment-actions.php new file mode 100644 index 00000000..10c43f45 --- /dev/null +++ b/inc/classes/class-aksrt-comment-actions.php @@ -0,0 +1,348 @@ + 'POST', + 'callback' => [$this, 'edit_comment'], + 'permission_callback' => [$this, 'check_edit_permission'], + 'args' => [ + 'comment_id' => ['required' => true, 'type' => 'integer', 'sanitize_callback' => 'absint'], + 'content' => ['required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_textarea_field'], + ], + ]); + + register_rest_route('aksrt/v1', '/comment/delete', [ + 'methods' => 'POST', + 'callback' => [$this, 'delete_comment'], + 'permission_callback' => [$this, 'check_delete_permission'], + 'args' => [ + 'comment_id' => ['required' => true, 'type' => 'integer', 'sanitize_callback' => 'absint'], + ], + ]); + + register_rest_route('aksrt/v1', '/comment/moderate', [ + 'methods' => 'POST', + 'callback' => [$this, 'moderate_comment'], + 'permission_callback' => function () { + return current_user_can('moderate_comments'); + }, + 'args' => [ + 'comment_id' => ['required' => true, 'type' => 'integer', 'sanitize_callback' => 'absint'], + 'status' => ['required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field'], + ], + ]); + + register_rest_route('aksrt/v1', '/comment/pin', [ + 'methods' => 'POST', + 'callback' => [$this, 'pin_comment'], + 'permission_callback' => [$this, 'check_pin_permission'], + 'args' => [ + 'comment_id' => ['required' => true, 'type' => 'integer', 'sanitize_callback' => 'absint'], + 'pinned' => ['required' => false, 'type' => 'boolean'], + ], + ]); + + register_rest_route('aksrt/v1', '/comment/like', [ + 'methods' => 'POST', + 'callback' => [$this, 'like_comment'], + // Guests may like; abuse is bounded by per-visitor toggle state. + 'permission_callback' => '__return_true', + 'args' => [ + 'comment_id' => ['required' => true, 'type' => 'integer', 'sanitize_callback' => 'absint'], + 'active' => ['required' => false, 'type' => 'boolean'], + ], + ]); + } + + /** + * Resolve the per-comment action contract (single source of truth). + * + * @param int $comment_id Comment ID. + * @return array{can_edit:bool,can_delete:bool,can_moderate:bool,can_pin:bool} + */ + private function permissions(int $comment_id): array { + if (function_exists('aksrt_comment_action_permissions')) { + return aksrt_comment_action_permissions($comment_id); + } + + // Defensive fallback if the helper is unavailable. + $comment = get_comment($comment_id); + $is_mod = current_user_can('moderate_comments'); + $is_owner = $comment && is_user_logged_in() && (int) $comment->user_id === get_current_user_id(); + return [ + 'can_edit' => $is_mod || $is_owner, + 'can_delete' => $is_mod || $is_owner, + 'can_moderate' => $is_mod, + 'can_pin' => $is_mod, + ]; + } + + /** + * Permission: can edit? (author within configured window, or moderator) + */ + public function check_edit_permission(\WP_REST_Request $request): bool { + if (!is_user_logged_in()) return false; + return $this->permissions((int) $request->get_param('comment_id'))['can_edit']; + } + + /** + * Permission: can delete? (author, or moderator) + */ + public function check_delete_permission(\WP_REST_Request $request): bool { + if (!is_user_logged_in()) return false; + return $this->permissions((int) $request->get_param('comment_id'))['can_delete']; + } + + /** + * Permission: can pin? (moderator, top-level only) + */ + public function check_pin_permission(\WP_REST_Request $request): bool { + if (!is_user_logged_in()) return false; + return $this->permissions((int) $request->get_param('comment_id'))['can_pin']; + } + + /** + * Edit comment content + */ + public function edit_comment(\WP_REST_Request $request): \WP_REST_Response { + $comment_id = $request->get_param('comment_id'); + $content = $request->get_param('content'); + + $comment = get_comment($comment_id); + if (!$comment) { + return new \WP_REST_Response([ + 'success' => false, + 'code' => 'comment_not_found', + 'message' => __('评论不存在。', 'aksrt'), + ], 404); + } + + // Sanitize with wp_kses (allow basic HTML) + $allowed_html = [ + 'a' => ['href' => [], 'title' => [], 'target' => []], + 'code' => [], + 'pre' => [], + 'strong' => [], + 'em' => [], + 'br' => [], + 'p' => [], + ]; + $clean_content = wp_kses($content, $allowed_html); + + $updated = wp_update_comment([ + 'comment_ID' => $comment_id, + 'comment_content' => $clean_content, + ]); + + if (is_wp_error($updated)) { + return new \WP_REST_Response([ + 'success' => false, + 'code' => 'update_failed', + 'message' => __('更新失败。', 'aksrt'), + ], 500); + } + + return new \WP_REST_Response([ + 'success' => true, + 'code' => 'updated', + 'message' => __('评论已更新。', 'aksrt'), + 'data' => [ + 'content' => $clean_content, + ], + ]); + } + + /** + * Delete comment + */ + public function delete_comment(\WP_REST_Request $request): \WP_REST_Response { + $comment_id = $request->get_param('comment_id'); + + $comment = get_comment($comment_id); + if (!$comment) { + return new \WP_REST_Response([ + 'success' => false, + 'code' => 'comment_not_found', + 'message' => __('评论不存在。', 'aksrt'), + ], 404); + } + + $deleted = wp_delete_comment($comment_id, false); // soft delete + if (!$deleted) { + return new \WP_REST_Response([ + 'success' => false, + 'code' => 'delete_failed', + 'message' => __('删除失败。', 'aksrt'), + ], 500); + } + + return new \WP_REST_Response([ + 'success' => true, + 'code' => 'deleted', + 'message' => __('评论已删除。', 'aksrt'), + ]); + } + + /** + * Moderate comment (approve / hold / spam) + */ + public function moderate_comment(\WP_REST_Request $request): \WP_REST_Response { + $comment_id = $request->get_param('comment_id'); + $status = $request->get_param('status'); + + $allowed_statuses = ['approve', 'hold', 'spam']; + if (!in_array($status, $allowed_statuses, true)) { + return new \WP_REST_Response([ + 'success' => false, + 'code' => 'invalid_status', + 'message' => __('无效状态。', 'aksrt'), + ], 400); + } + + $comment = get_comment($comment_id); + if (!$comment) { + return new \WP_REST_Response([ + 'success' => false, + 'code' => 'comment_not_found', + 'message' => __('评论不存在。', 'aksrt'), + ], 404); + } + + $status_map = [ + 'approve' => 'approved', + 'hold' => 'hold', + 'spam' => 'spam', + ]; + + $result = wp_set_comment_status($comment_id, $status_map[$status]); + if (is_wp_error($result)) { + return new \WP_REST_Response([ + 'success' => false, + 'code' => 'moderate_failed', + 'message' => __('操作失败。', 'aksrt'), + ], 500); + } + + return new \WP_REST_Response([ + 'success' => true, + 'code' => 'moderated', + 'message' => __('操作成功。', 'aksrt'), + 'data' => [ + 'status' => $status_map[$status], + ], + ]); + } + + /** + * Toggle a comment's pinned ("置顶") state. Top-level comments only. + */ + public function pin_comment(\WP_REST_Request $request): \WP_REST_Response { + $comment_id = (int) $request->get_param('comment_id'); + $comment = get_comment($comment_id); + if (!$comment) { + return new \WP_REST_Response([ + 'success' => false, + 'code' => 'comment_not_found', + 'message' => __('评论不存在。', 'aksrt'), + ], 404); + } + + if ((int) $comment->comment_parent !== 0) { + return new \WP_REST_Response([ + 'success' => false, + 'code' => 'cannot_pin_reply', + 'message' => __('仅顶层评论可置顶。', 'aksrt'), + ], 400); + } + + $current = '1' === (string) get_comment_meta($comment_id, '_aksrt_comment_pinned', true); + $target = null !== $request->get_param('pinned') + ? (bool) $request->get_param('pinned') + : !$current; + + if ($target) { + update_comment_meta($comment_id, '_aksrt_comment_pinned', '1'); + } else { + delete_comment_meta($comment_id, '_aksrt_comment_pinned'); + } + + return new \WP_REST_Response([ + 'success' => true, + 'code' => 'pinned', + 'message' => $target ? __('评论已置顶。', 'aksrt') : __('已取消置顶。', 'aksrt'), + 'data' => [ + 'pinned' => $target, + ], + ]); + } + + /** + * Toggle the current visitor's like on a comment. + */ + public function like_comment(\WP_REST_Request $request): \WP_REST_Response { + $comment_id = (int) $request->get_param('comment_id'); + $comment = get_comment($comment_id); + if (!$comment || '1' !== (string) $comment->comment_approved) { + return new \WP_REST_Response([ + 'success' => false, + 'code' => 'comment_unavailable', + 'message' => __('评论不存在或未通过审核。', 'aksrt'), + ], 404); + } + + if (!function_exists('aksrt_is_comment_liked') || !function_exists('aksrt_set_comment_like_state') || !function_exists('aksrt_update_comment_counter')) { + return new \WP_REST_Response([ + 'success' => false, + 'code' => 'like_unavailable', + 'message' => __('点赞功能不可用。', 'aksrt'), + ], 500); + } + + $current = aksrt_is_comment_liked($comment_id); + $target = null !== $request->get_param('active') + ? (bool) $request->get_param('active') + : !$current; + + if ($target !== $current) { + aksrt_set_comment_like_state($comment_id, $target); + $count = aksrt_update_comment_counter($comment_id, 'aksrt_comment_likes', $target ? 1 : -1); + } else { + $count = aksrt_get_comment_likes($comment_id); + } + + return new \WP_REST_Response([ + 'success' => true, + 'code' => 'liked', + 'data' => [ + 'active' => $target, + 'count' => $count, + ], + ]); + } +} diff --git a/inc/classes/class-aksrt-content-audit.php b/inc/classes/class-aksrt-content-audit.php new file mode 100644 index 00000000..29e796bf --- /dev/null +++ b/inc/classes/class-aksrt-content-audit.php @@ -0,0 +1,357 @@ +get_config(); + + if (empty($config['content_audit_enabled']) || empty($config['content_audit_text_enabled'])) { + return $this->ok('disabled'); + } + + $provider = sanitize_key((string) ($config['content_audit_provider'] ?? 'baidu')); + $result = $this->call_text_api($provider, $text, $config); + + if (!empty($config['content_audit_log_enabled'])) { + $this->log_audit('text', $text, $result); + } + + if (!$result['passed'] && ($config['content_audit_on_failure'] ?? 'allow') === 'allow') { + $result['passed'] = true; + $result['reason'] .= ' (failure_policy: allow)'; + } + + return $result; + } + + /** + * 审核图片 + * + * @param string $image_path 本地文件路径或 URL + * @return array{passed: bool, reason: string, labels: array, raw: array} + */ + public function audit_image(string $image_path): array { + $config = $this->get_config(); + + if (empty($config['content_audit_enabled']) || empty($config['content_audit_image_enabled'])) { + return $this->ok('disabled'); + } + + $provider = sanitize_key((string) ($config['content_audit_provider'] ?? 'baidu')); + $result = $this->call_image_api($provider, $image_path, $config); + + if (!empty($config['content_audit_log_enabled'])) { + $this->log_audit('image', $image_path, $result); + } + + if (!$result['passed'] && ($config['content_audit_on_failure'] ?? 'allow') === 'allow') { + $result['passed'] = true; + $result['reason'] .= ' (failure_policy: allow)'; + } + + return $result; + } + + /** + * 获取服务运行时状态 + */ + public function get_status(): array { + $config = $this->get_config(); + $enabled = !empty($config['content_audit_enabled']); + $provider = sanitize_key((string) ($config['content_audit_provider'] ?? 'baidu')); + + $missing = []; + if ($enabled && $provider === 'baidu') { + if (empty($config['content_audit_api_key'])) $missing[] = 'content_audit_api_key'; + if (empty($config['content_audit_api_secret'])) $missing[] = 'content_audit_api_secret'; + } + + return [ + 'enabled' => $enabled, + 'provider' => $provider, + 'configured' => $enabled && ($provider === 'null' || empty($missing)), + 'available' => $enabled, + 'verified' => !empty($config['content_audit_verified']), + 'transport' => 'http_api', + 'missing_fields' => $missing, + 'text_enabled' => !empty($config['content_audit_text_enabled']), + 'image_enabled' => !empty($config['content_audit_image_enabled']), + 'on_failure' => (string) ($config['content_audit_on_failure'] ?? 'allow'), + 'log_enabled' => !empty($config['content_audit_log_enabled']), + ]; + } + + /* ============================================================ + * Provider dispatch + * ============================================================ */ + + private function call_text_api(string $provider, string $text, array $config): array { + switch ($provider) { + case 'baidu': + return $this->baidu_text_censor($text, $config); + default: + return $this->local_pattern_check($text, $provider); + } + } + + private function call_image_api(string $provider, string $image_path, array $config): array { + switch ($provider) { + case 'baidu': + return $this->baidu_image_censor($image_path, $config); + default: + return $this->ok('no_image_provider'); + } + } + + /* ============================================================ + * 百度内容审核 Provider + * ============================================================ */ + + /** + * 百度文本审核 + */ + private function baidu_text_censor(string $text, array $config): array { + $api_key = trim((string) ($config['content_audit_api_key'] ?? '')); + $api_secret = trim((string) ($config['content_audit_api_secret'] ?? '')); + if ($api_key === '' || $api_secret === '') { + return ['passed' => false, 'reason' => 'config_missing', 'labels' => [], 'raw' => []]; + } + + $token = $this->baidu_get_access_token($api_key, $api_secret, $config); + if ($token === '') { + return ['passed' => false, 'reason' => 'api_error', 'labels' => [], 'raw' => []]; + } + + $censor_url = trim((string) ($config['content_audit_baidu_text_url'] ?? '')); + if ($censor_url === '') { + $censor_url = 'https://aip.baidubce.com/rest/2.0/solution/v1/text_censor/v2/user_defined'; + } + + $request_url = add_query_arg(['access_token' => $token], $censor_url); + $response = wp_remote_post($request_url, [ + 'timeout' => 15, + 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'], + 'body' => http_build_query(['text' => $text]), + ]); + + if (is_wp_error($response)) { + return ['passed' => false, 'reason' => 'api_error', 'labels' => [], 'raw' => ['error' => $response->get_error_message()]]; + } + + $status_code = (int) wp_remote_retrieve_response_code($response); + if ($status_code !== 200) { + return ['passed' => false, 'reason' => 'api_error', 'labels' => [], 'raw' => ['status' => $status_code]]; + } + + $payload = json_decode((string) wp_remote_retrieve_body($response), true); + if (!is_array($payload) || isset($payload['error_code'])) { + return ['passed' => false, 'reason' => 'api_error', 'labels' => [], 'raw' => $payload ?: []]; + } + + return $this->parse_baidu_result($payload); + } + + /** + * 百度图片审核 + */ + private function baidu_image_censor(string $image_path, array $config): array { + $api_key = trim((string) ($config['content_audit_api_key'] ?? '')); + $api_secret = trim((string) ($config['content_audit_api_secret'] ?? '')); + if ($api_key === '' || $api_secret === '') { + return ['passed' => false, 'reason' => 'config_missing', 'labels' => [], 'raw' => []]; + } + + $token = $this->baidu_get_access_token($api_key, $api_secret, $config); + if ($token === '') { + return ['passed' => false, 'reason' => 'api_error', 'labels' => [], 'raw' => []]; + } + + // 支持本地路径和 URL + if (filter_var($image_path, FILTER_VALIDATE_URL)) { + $image_data = @file_get_contents($image_path); + } else { + $image_data = @file_get_contents($image_path); + } + if ($image_data === false) { + return ['passed' => false, 'reason' => 'file_error', 'labels' => [], 'raw' => []]; + } + + $censor_url = trim((string) ($config['content_audit_baidu_img_url'] ?? '')); + if ($censor_url === '') { + $censor_url = 'https://aip.baidubce.com/rest/2.0/solution/v1/img_censor/v2/user_defined'; + } + + $request_url = add_query_arg(['access_token' => $token], $censor_url); + $response = wp_remote_post($request_url, [ + 'timeout' => 30, + 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'], + 'body' => http_build_query(['image' => base64_encode($image_data)]), + ]); + + if (is_wp_error($response)) { + return ['passed' => false, 'reason' => 'api_error', 'labels' => [], 'raw' => ['error' => $response->get_error_message()]]; + } + + $status_code = (int) wp_remote_retrieve_response_code($response); + if ($status_code !== 200) { + return ['passed' => false, 'reason' => 'api_error', 'labels' => [], 'raw' => ['status' => $status_code]]; + } + + $payload = json_decode((string) wp_remote_retrieve_body($response), true); + if (!is_array($payload) || isset($payload['error_code'])) { + return ['passed' => false, 'reason' => 'api_error', 'labels' => [], 'raw' => $payload ?: []]; + } + + return $this->parse_baidu_result($payload); + } + + /** + * 百度 access_token 获取(带缓存) + */ + private function baidu_get_access_token(string $api_key, string $api_secret, array $config): string { + $cache_key = 'aksrt_baidu_token_' . md5($api_key); + $cached = get_transient($cache_key); + if ($cached) return (string) $cached; + + $token_url = trim((string) ($config['content_audit_baidu_token_url'] ?? '')); + if ($token_url === '') { + $token_url = 'https://aip.baidubce.com/oauth/2.0/token'; + } + + $request_url = add_query_arg([ + 'grant_type' => 'client_credentials', + 'client_id' => $api_key, + 'client_secret' => $api_secret, + ], $token_url); + + $response = wp_remote_get($request_url, ['timeout' => 10]); + if (is_wp_error($response)) return ''; + + $status_code = (int) wp_remote_retrieve_response_code($response); + if ($status_code !== 200) return ''; + + $payload = json_decode((string) wp_remote_retrieve_body($response), true); + if (!is_array($payload) || isset($payload['error'])) return ''; + + $token = (string) ($payload['access_token'] ?? ''); + $expires = max(3600, (int) ($payload['expires_in'] ?? 3600) - 300); + if ($token !== '') { + set_transient($cache_key, $token, $expires); + } + return $token; + } + + /** + * 解析百度审核结果 → 统一格式 + */ + private function parse_baidu_result(array $payload): array { + $conclusion_type = isset($payload['conclusionType']) ? (int) $payload['conclusionType'] : 1; + $conclusion = (string) ($payload['conclusion'] ?? ''); + $top_category = $this->baidu_extract_top_category($payload); + + // conclusionType: 1=合规, 2=不合规, 3=疑似, 4=审核失败 + if ($conclusion_type === 1) { + return [ + 'passed' => true, + 'reason' => '', + 'labels' => ['compliant'], + 'raw' => $payload, + ]; + } + + $score_map = [2 => 95, 3 => 70, 4 => 60]; + $reason_map = [2 => 'non_compliant', 3 => 'suspected', 4 => 'review_failed']; + + return [ + 'passed' => false, + 'reason' => $reason_map[$conclusion_type] ?? 'unknown', + 'labels' => [$top_category ?: ('baidu_type_' . $conclusion_type)], + 'raw' => $payload, + ]; + } + + private function baidu_extract_top_category(array $payload): string { + if (!isset($payload['data']) || !is_array($payload['data']) || empty($payload['data'][0])) { + return ''; + } + $first = $payload['data'][0]; + if (!is_array($first)) return ''; + if (!empty($first['msg'])) return (string) $first['msg']; + if (!empty($first['type'])) return 'type_' . (string) $first['type']; + if (!empty($first['subType'])) return 'sub_' . (string) $first['subType']; + return ''; + } + + /* ============================================================ + * 本地基础过滤(无 API 时的 fallback) + * ============================================================ */ + + private function local_pattern_check(string $text, string $provider): array { + $forbidden_patterns = ['/赌博/i', '/色情/i', '/枪支/i', '/毒品/i']; + foreach ($forbidden_patterns as $pattern) { + if (preg_match($pattern, $text, $m)) { + return [ + 'passed' => false, + 'reason' => sprintf('本地敏感词匹配: %s', $m[0]), + 'labels' => ['forbidden'], + 'raw' => ['provider' => $provider, 'method' => 'local_pattern'], + ]; + } + } + return $this->ok('passthrough'); + } + + /* ============================================================ + * Helpers + * ============================================================ */ + + private function ok(string $reason = ''): array { + return ['passed' => true, 'reason' => $reason, 'labels' => [], 'raw' => []]; + } + + private function get_config(): array { + if (class_exists('AKSRT_Settings')) { + return AKSRT_Settings::get_content_audit_settings(); + } + return ['content_audit_enabled' => false]; + } + + private function log_audit(string $type, string $content, array $result): void { + $log = get_option('aksrt_content_audit_log', []); + $log[] = [ + 'type' => $type, + 'content' => mb_substr($content, 0, 100), + 'passed' => $result['passed'], + 'reason' => $result['reason'] ?? '', + 'time' => current_time('mysql'), + ]; + if (count($log) > 500) { + $log = array_slice($log, -500); + } + update_option('aksrt_content_audit_log', $log); + } +} diff --git a/inc/classes/class-aksrt-content-visibility.php b/inc/classes/class-aksrt-content-visibility.php new file mode 100644 index 00000000..0531b0b8 --- /dev/null +++ b/inc/classes/class-aksrt-content-visibility.php @@ -0,0 +1,282 @@ + + */ + public static function get_post_access_state($post = null, int $user_id = 0): array { + $resource_post = get_post($post); + $uid = $user_id > 0 ? $user_id : get_current_user_id(); + $pay_meta = function_exists('aksrt_get_pay_meta') + ? aksrt_get_pay_meta($resource_post) + : AKSRT_Domain_Model::default_pay_meta(); + $pay_type = AKSRT_Domain_Model::normalize_pay_type((string) ($pay_meta['pay_type'] ?? AKSRT_Domain_Model::PAY_TYPE_NO)); + $price = (float) ($pay_meta['price'] ?? 0); + $paid_settings = class_exists('AKSRT_Settings') + ? AKSRT_Settings::get_paid_content_settings() + : [ + 'download_login' => true, + 'download_credit' => true, + 'download_captcha' => false, + ]; + + $state = [ + 'post_id' => $resource_post instanceof WP_Post ? (int) $resource_post->ID : 0, + 'pay_type' => $pay_type, + 'price' => $price, + 'need_login' => false, + 'need_purchase' => false, + 'need_vip' => false, + 'require_captcha' => false, + 'has_paid_access' => false, + 'can_view' => true, + 'can_download' => true, + 'download_cost' => 0, + ]; + + if (!($resource_post instanceof WP_Post)) { + $state['can_view'] = false; + $state['can_download'] = false; + return $state; + } + + if ($pay_type === AKSRT_Domain_Model::PAY_TYPE_NO) { + return $state; + } + + $has_free_access = $price <= 0 && $pay_type !== AKSRT_Domain_Model::PAY_TYPE_VIP; + $has_paid_order = false; + + if ($uid > 0 && class_exists('AKSRT_Order')) { + $has_paid_order = AKSRT_Order::get_instance()->has_paid_access( + $uid, + AKSRT_Domain_Model::ORDER_PRODUCT_RESOURCE, + (int) $resource_post->ID + ); + } + + $has_vip_access = false; + if ($uid > 0) { + if (function_exists('aksrt_is_vip_exempt') && aksrt_is_vip_exempt($uid)) { + $has_vip_access = true; + } elseif (function_exists('aksrt_is_vip') && $pay_type === AKSRT_Domain_Model::PAY_TYPE_VIP) { + $has_vip_access = aksrt_is_vip($uid); + } + } + + if ($pay_type === AKSRT_Domain_Model::PAY_TYPE_DOWNLOAD) { + $download_cost = function_exists('aksrt_resource_download_cost') + ? (int) aksrt_resource_download_cost($resource_post) + : (int) ceil($price); + + $state['download_cost'] = max(0, $download_cost); + $state['need_login'] = !empty($paid_settings['download_login']) || !empty($pay_meta['require_login']); + $state['require_captcha'] = !empty($paid_settings['download_captcha']) || !empty($pay_meta['require_captcha']); + $state['need_purchase'] = !$has_vip_access && $state['download_cost'] > 0; + $state['has_paid_access'] = $has_vip_access || $has_paid_order || !$state['need_purchase']; + $state['can_view'] = true; + $state['can_download'] = !$state['need_login'] || $uid > 0; + + return $state; + } + + if ($pay_type === AKSRT_Domain_Model::PAY_TYPE_VIP) { + $state['need_vip'] = true; + $state['has_paid_access'] = $has_vip_access; + $state['can_view'] = $has_vip_access; + $state['can_download'] = false; + + return $state; + } + + $state['need_login'] = !empty($pay_meta['require_login']); + $state['need_purchase'] = !$has_free_access; + $state['has_paid_access'] = $has_free_access || $has_paid_order || $has_vip_access; + $state['can_view'] = $state['has_paid_access'] && (!$state['need_login'] || $uid > 0); + $state['can_download'] = false; + + return $state; + } + + public static function can_view_paid_content($post = null, int $user_id = 0): bool { + $state = self::get_post_access_state($post, $user_id); + return !empty($state['can_view']); + } + + /** + * @param int|WP_Post|null $post + * @return true|WP_Error + */ + public static function can_download_resource($post = null, int $user_id = 0) { + $resource_post = get_post($post); + if (!($resource_post instanceof WP_Post) || !function_exists('aksrt_is_resource_post') || !aksrt_is_resource_post($resource_post)) { + return new WP_Error('aksrt_not_resource', __('该内容不是可下载资源。', 'aksrt')); + } + + $state = self::get_post_access_state($resource_post, $user_id); + $uid = $user_id > 0 ? $user_id : get_current_user_id(); + + if (($state['pay_type'] ?? '') !== AKSRT_Domain_Model::PAY_TYPE_DOWNLOAD) { + return new WP_Error('aksrt_not_downloadable', __('该内容不是可下载资源。', 'aksrt')); + } + + if (!empty($state['need_login']) && $uid <= 0) { + return new WP_Error('aksrt_need_login', __('请先登录后再下载。', 'aksrt'), [ + 'status' => 401, + 'login_url' => wp_login_url(get_permalink($resource_post)), + ]); + } + + $cost = (int) ($state['download_cost'] ?? 0); + $paid_settings = class_exists('AKSRT_Settings') + ? AKSRT_Settings::get_paid_content_settings() + : ['download_credit' => true]; + + if (!empty($paid_settings['download_credit']) && $cost > 0) { + $balance = class_exists('AKSRT_Credits') ? AKSRT_Credits::balance($uid) : 0; + if ($balance < $cost) { + return new WP_Error('aksrt_credits_insufficient', __('积分不足,无法下载。', 'aksrt'), [ + 'status' => 402, + 'balance' => $balance, + 'cost' => $cost, + ]); + } + } + + return true; + } + + public function render_hide_shortcode(array $atts, string $content = ''): string { + $atts = shortcode_atts([ + 'mode' => 'comment', + 'message' => '', + 'pid' => get_the_ID(), + ], $atts, 'aksrt_hide'); + + $mode = sanitize_key((string) $atts['mode']); + $pid = (int) $atts['pid']; + $message = (string) $atts['message']; + + if ($pid <= 0) { + return ''; + } + + if ($this->is_unlocked($mode, $pid)) { + return '
' . do_shortcode($content) . '
'; + } + + if ($message === '') { + switch ($mode) { + case 'comment': + $message = __('评论后查看隐藏内容', 'aksrt'); + break; + case 'login': + $message = __('登录后查看隐藏内容', 'aksrt'); + break; + case 'password': + $message = __('输入密码后查看隐藏内容', 'aksrt'); + break; + default: + $message = __('此内容已隐藏', 'aksrt'); + } + } + + $wrapper_class = 'aksrt-hidden-content aksrt-hidden-content--locked aksrt-hidden-content--' . esc_attr($mode); + $html = '
'; + $html .= '
'; + $html .= ' '; + $html .= '' . esc_html($message) . ''; + $html .= '
'; + + if ($mode === 'password') { + $html .= '
'; + $html .= ''; + $html .= ''; + $html .= '
'; + } + + $html .= '
'; + + return $html; + } + + private function is_unlocked(string $mode, int $pid): bool { + switch ($mode) { + case 'comment': + if (!is_user_logged_in()) { + return false; + } + + return get_comments([ + 'post_id' => $pid, + 'user_id' => get_current_user_id(), + 'count' => true, + 'status' => 'approve', + ]) > 0; + + case 'login': + return is_user_logged_in(); + + case 'password': + return !empty($_COOKIE['aksrt_hide_pwd_' . $pid]); + + default: + return false; + } + } + + public function ajax_check_visibility(): void { + check_ajax_referer('aksrt_visibility', '_nonce'); + + $mode = sanitize_key((string) ($_POST['mode'] ?? '')); + $pid = (int) ($_POST['pid'] ?? 0); + + if ($pid <= 0 || !in_array($mode, ['comment', 'login', 'password'], true)) { + wp_send_json_error(['message' => __('参数无效', 'aksrt')]); + } + + if ($mode === 'password') { + $password = sanitize_text_field((string) ($_POST['password'] ?? '')); + $post_password = get_post_password($pid); + if ($post_password === '' || $password !== $post_password) { + wp_send_json_error(['message' => __('密码错误', 'aksrt')]); + } + + setcookie('aksrt_hide_pwd_' . $pid, '1', time() + HOUR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true); + wp_send_json_success(['message' => __('验证成功', 'aksrt')]); + } + + if ($this->is_unlocked($mode, $pid)) { + wp_send_json_success(['message' => __('已解锁', 'aksrt')]); + } + + wp_send_json_error(['message' => __('未满足条件', 'aksrt')]); + } +} diff --git a/inc/classes/class-aksrt-coupon.php b/inc/classes/class-aksrt-coupon.php new file mode 100644 index 00000000..d118b162 --- /dev/null +++ b/inc/classes/class-aksrt-coupon.php @@ -0,0 +1,276 @@ +get_var($wpdb->prepare( + "SELECT id FROM {$this->table()} WHERE code = %s", + $code + )); + if ($exists) return 0; + + $type = sanitize_key((string) ($data['type'] ?? self::TYPE_FIXED)); + if (!in_array($type, [self::TYPE_PERCENT, self::TYPE_FIXED], true)) { + $type = self::TYPE_FIXED; + } + + $wpdb->insert($this->table(), [ + 'code' => $code, + 'type' => $type, + 'value' => (float) ($data['value'] ?? 0), + 'min_amount' => (float) ($data['min_amount'] ?? 0), + 'usage_limit' => (int) ($data['usage_limit'] ?? 0), + 'used_count' => 0, + 'expires_at' => sanitize_text_field((string) ($data['expires_at'] ?? '')), + 'per_user_limit' => (int) ($data['per_user_limit'] ?? 0), + 'product_scope' => wp_json_encode($data['product_scope'] ?? [], JSON_UNESCAPED_UNICODE), + 'max_discount' => (float) ($data['max_discount'] ?? 0), + 'status' => 'active', + 'created_at' => current_time('mysql'), + ], ['%s', '%s', '%f', '%f', '%d', '%d', '%s', '%d', '%s', '%f', '%s', '%s']); + + return (int) $wpdb->insert_id; + } + + /** + * 获取优惠券列表 + */ + public function get_list(array $args = []): array { + global $wpdb; + + $where = []; + $params = []; + + $status = sanitize_key((string) ($args['status'] ?? '')); + if ($status !== '') { + $where[] = 'status = %s'; + $params[] = $status; + } + + $search = sanitize_text_field((string) ($args['search'] ?? '')); + if ($search !== '') { + $where[] = 'code LIKE %s'; + $params[] = '%' . $wpdb->esc_like($search) . '%'; + } + + $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); + $offset = max(0, (int) ($args['offset'] ?? 0)); + + $where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; + + $count_sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}"; + if (!empty($params)) { + $total = (int) $wpdb->get_var($wpdb->prepare($count_sql, $params)); + } else { + $total = (int) $wpdb->get_var($count_sql); + } + + $sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d"; + $params[] = $limit; + $params[] = $offset; + + $rows = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A); + + return [ + 'items' => array_map([$this, 'normalize_row'], (array) $rows), + 'total' => $total, + 'page' => max(1, (int) ceil($offset / $limit) + 1), + 'total_pages' => max(1, (int) ceil($total / $limit)), + ]; + } + + /** + * 验证优惠券是否可用 + * + * @return array{valid: bool, coupon?: array, message?: string} + */ + public function validate(string $code, float $order_amount = 0, array $args = []): array { + global $wpdb; + + $code = sanitize_text_field($code); + if ($code === '') { + return ['valid' => false, 'message' => '请输入优惠码']; + } + + $coupon = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM {$this->table()} WHERE code = %s", + $code + ), ARRAY_A); + + if (!$coupon) { + return ['valid' => false, 'message' => '优惠码不存在']; + } + + $coupon = $this->normalize_row($coupon); + + if ($coupon['status'] !== 'active') { + return ['valid' => false, 'message' => '优惠码已失效']; + } + + if (!empty($coupon['expires_at']) && strtotime($coupon['expires_at']) < time()) { + return ['valid' => false, 'message' => '优惠码已过期']; + } + + if ($coupon['usage_limit'] > 0 && $coupon['used_count'] >= $coupon['usage_limit']) { + return ['valid' => false, 'message' => '优惠码已用完']; + } + + if ($coupon['min_amount'] > 0 && $order_amount < $coupon['min_amount']) { + return ['valid' => false, 'message' => '订单金额不满足最低消费要求']; + } + + // Per-user limit check + $user_id = (int) ($args['user_id'] ?? 0); + if ($coupon['per_user_limit'] > 0 && $user_id > 0) { + $user_used = $this->get_user_usage_count($coupon['id'], $user_id); + if ($user_used >= $coupon['per_user_limit']) { + return ['valid' => false, 'message' => '您已使用该优惠码达到上限']; + } + } + + // Product scope check + if (!empty($coupon['product_scope']) && !empty($args['product_ids'])) { + $scope = is_array($coupon['product_scope']) ? $coupon['product_scope'] : json_decode($coupon['product_scope'], true); + if (is_array($scope) && !empty($scope)) { + $scope_type = (string) ($scope['type'] ?? ''); + $scope_ids = array_map('intval', (array) ($scope['ids'] ?? [])); + $product_ids = array_map('intval', (array) $args['product_ids']); + if ($scope_type === 'include' && empty(array_intersect($product_ids, $scope_ids))) { + return ['valid' => false, 'message' => '该优惠码不适用于当前商品']; + } + if ($scope_type === 'exclude' && !empty(array_intersect($product_ids, $scope_ids))) { + return ['valid' => false, 'message' => '该优惠码不适用于当前商品']; + } + if ($scope_type === 'category') { + $matched = false; + foreach ($product_ids as $pid) { + $cats = wp_get_post_terms($pid, 'aksrt_product_cat', ['fields' => 'ids']); + if (!is_wp_error($cats) && !empty(array_intersect($cats, $scope_ids))) { + $matched = true; + break; + } + } + if (!$matched) { + return ['valid' => false, 'message' => '该优惠码不适用于当前商品分类']; + } + } + } + } + + // Calculate discount + $discount = 0; + if ($coupon['type'] === self::TYPE_PERCENT) { + $discount = $order_amount * ($coupon['value'] / 100); + } else { + $discount = min($coupon['value'], $order_amount); + } + + // Max discount cap + if ($coupon['max_discount'] > 0 && $discount > $coupon['max_discount']) { + $discount = $coupon['max_discount']; + } + + return [ + 'valid' => true, + 'coupon' => $coupon, + 'discount' => round($discount, 2), + ]; + } + + /** + * 核销优惠券 + */ + public function use_coupon(string $code): bool { + global $wpdb; + + $code = sanitize_text_field($code); + $result = $wpdb->query($wpdb->prepare( + "UPDATE {$this->table()} SET used_count = used_count + 1 WHERE code = %s", + $code + )); + + return $result !== false; + } + + /** + * 禁用优惠券 + */ + public function disable(int $id): bool { + global $wpdb; + return $wpdb->update($this->table(), ['status' => 'disabled'], ['id' => $id], ['%s'], ['%d']) !== false; + } + + /** + * Count how many times a user has used a specific coupon. + */ + public function get_user_usage_count(int $coupon_id, int $user_id): int { + global $wpdb; + $table = AKSRT_DB_Migrator::table('coupon_usage'); + return (int) $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM {$table} WHERE coupon_id = %d AND user_id = %d", + $coupon_id, $user_id + )); + } + + /** + * Log a coupon usage record. + */ + public function log_usage(int $coupon_id, int $user_id, string $order_number, float $discount): void { + global $wpdb; + $table = AKSRT_DB_Migrator::table('coupon_usage'); + $wpdb->insert($table, [ + 'coupon_id' => $coupon_id, + 'user_id' => $user_id, + 'order_number' => $order_number, + 'discount' => $discount, + 'created_at' => current_time('mysql'), + ], ['%d', '%d', '%s', '%f', '%s']); + } + + private function normalize_row(array $row): array { + $row['id'] = (int) $row['id']; + $row['value'] = (float) $row['value']; + $row['min_amount'] = (float) $row['min_amount']; + $row['usage_limit'] = (int) $row['usage_limit']; + $row['used_count'] = (int) $row['used_count']; + $row['per_user_limit'] = (int) ($row['per_user_limit'] ?? 0); + $row['max_discount'] = (float) ($row['max_discount'] ?? 0); + if (!empty($row['product_scope']) && is_string($row['product_scope'])) { + $row['product_scope'] = json_decode($row['product_scope'], true) ?: []; + } + return $row; + } +} diff --git a/inc/classes/class-aksrt-credits-earn.php b/inc/classes/class-aksrt-credits-earn.php new file mode 100644 index 00000000..c5c38f1d --- /dev/null +++ b/inc/classes/class-aksrt-credits-earn.php @@ -0,0 +1,229 @@ +today_forum_reward_count($user_id, 'topic'); + if ($today_count >= $daily_limit) return; + + AKSRT_Credits::add($user_id, $amount, 'forum_topic', ['type' => 'post', 'id' => $post_id]); + } + + /** + * 今日论坛奖励次数 + */ + private function today_forum_reward_count(int $user_id, string $type = 'topic'): int { + global $wpdb; + $table = AKSRT_DB_Migrator::table('credit_log'); + if (!$table) return 0; + + $today = current_time('Y-m-d'); + return (int) $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM {$table} WHERE user_id = %d AND reason = %s AND DATE(created_at) = %s", + $user_id, 'forum_' . $type, $today + )); + } + + /** + * 注册赠送。 + */ + public function on_user_register(int $user_id): void { + if (!class_exists('AKSRT_Credits') || !class_exists('AKSRT_Settings')) return; + $cfg = AKSRT_Settings::get_credit_settings(); + if (empty($cfg['enabled'])) return; + $bonus = (int) ($cfg['register_bonus'] ?? 0); + if ($bonus <= 0) return; + + AKSRT_Credits::add($user_id, $bonus, 'register', ['type' => 'user', 'id' => $user_id]); + } + + /** + * 评论插入(直接发表无需审核场景)。 + */ + public function on_comment_inserted(int $comment_id, $comment): void { + if (!class_exists('AKSRT_Settings')) return; + $cfg = AKSRT_Settings::get_credit_settings(); + $reward = $cfg['comment_reward'] ?? []; + if (empty($cfg['enabled']) || empty($reward['enabled'])) return; + + // 仅审核通过后发放 → 不在 insert 时处理 + if (!empty($reward['on_approved'])) return; + + $comment = $comment instanceof WP_Comment ? $comment : get_comment($comment_id); + if (!($comment instanceof WP_Comment)) return; + + // 只奖励已批准的评论 + if ((int) $comment->comment_approved !== 1) return; + + $this->maybe_reward_comment($comment, $reward); + } + + /** + * 评论状态流转。 + */ + public function on_comment_transition($new_status, $old_status, $comment): void { + if (!class_exists('AKSRT_Settings')) return; + $cfg = AKSRT_Settings::get_credit_settings(); + $reward = $cfg['comment_reward'] ?? []; + if (empty($cfg['enabled']) || empty($reward['enabled'])) return; + if (empty($reward['on_approved'])) return; + + if ($new_status !== 'approved') return; + // 跳过已经处理过的:以 commentmeta 标记 + if (!($comment instanceof WP_Comment)) return; + + $this->maybe_reward_comment($comment, $reward); + } + + /** + * 真正的评论奖励逻辑(含字数 / 每日上限 / 去重)。 + */ + private function maybe_reward_comment(WP_Comment $comment, array $reward): void { + $user_id = (int) $comment->user_id; + if ($user_id <= 0) return; + + // 防重复:commentmeta 标记 + if (get_comment_meta($comment->comment_ID, 'aksrt_credit_rewarded', true)) return; + + $min_length = (int) ($reward['min_length'] ?? 0); + if ($min_length > 0 && mb_strlen(strip_tags($comment->comment_content)) < $min_length) return; + + $amount = (int) ($reward['amount'] ?? 0); + if ($amount <= 0) return; + + // 每日上限 + $cap = (int) ($reward['daily_cap'] ?? 0); + if ($cap > 0) { + $today_count = $this->today_comment_reward_count($user_id); + if ($today_count >= $cap) { + update_comment_meta($comment->comment_ID, 'aksrt_credit_rewarded', 'capped'); + return; + } + } + + $result = AKSRT_Credits::add( + $user_id, + $amount, + 'comment', + ['type' => 'comment', 'id' => (int) $comment->comment_ID] + ); + if (!is_wp_error($result)) { + update_comment_meta($comment->comment_ID, 'aksrt_credit_rewarded', 1); + } + } + + /** + * 当日评论奖励次数(查询 credit_log)。 + */ + private function today_comment_reward_count(int $user_id): int { + global $wpdb; + if (!class_exists('AKSRT_DB_Migrator')) return 0; + $table = AKSRT_DB_Migrator::table('credit_log'); + if (!$table) return 0; + + // 站点时区当日 00:00 起 + $tz = wp_timezone(); + $today = (new DateTimeImmutable('today', $tz))->format('Y-m-d H:i:s'); + // 转 UTC(数据库 created_at 用 mysql UTC) + $today_utc = (new DateTimeImmutable('today', $tz))->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s'); + + return (int) $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM {$table} WHERE user_id = %d AND reason = 'comment' AND created_at >= %s", + $user_id, + $today_utc + )); + } + + // ================================================================ + // Forum credits (§7.7) + // ================================================================ + + public function on_forum_reply_posted(int $comment_id, $approved, array $commentdata = []): void { + if (!class_exists('AKSRT_Credits') || !class_exists('AKSRT_Settings')) return; + if (!class_exists('AKSRT_Forum')) return; + + $comment = get_comment($comment_id); + if (!$comment) return; + + $post = get_post((int) $comment->comment_post_ID); + if (!$post || $post->post_type !== 'aksrt_forum_topic') return; + + if ((int) $comment->comment_approved !== 1) return; + + $user_id = (int) $comment->user_id; + if ($user_id <= 0) return; + + $forum_cfg = AKSRT_Settings::get_forum_settings(); + $amount = (int) ($forum_cfg['credits']['comment_new'] ?? 2); + if ($amount <= 0) return; + + $daily_max = (int) ($forum_cfg['credits']['daily_max'] ?? 50); + $today_count = $this->today_forum_reward_count($user_id, 'reply'); + if ($today_count >= $daily_max) return; + + AKSRT_Credits::add($user_id, $amount, 'forum_reply', [ + 'type' => 'comment', + 'id' => $comment_id, + ]); + } + + public function on_forum_topic_featured(int $topic_id, int $operator_id): void { + // Essence reward is handled in AKSRT_Forum::reward_essence(). + // This hook is for additional integrations if needed. + } +} diff --git a/inc/classes/class-aksrt-credits.php b/inc/classes/class-aksrt-credits.php new file mode 100644 index 00000000..c67ba073 --- /dev/null +++ b/inc/classes/class-aksrt-credits.php @@ -0,0 +1,308 @@ + 0 + * @param string $reason 例 register/signin/comment/invite/admin + * @param array $ref ['type' => 'post|comment|user|invite', 'id' => int] + * @return int|WP_Error 新余额或错误 + */ + public static function add(int $user_id, int $delta, string $reason, array $ref = [], string $remark = '') { + if ($user_id <= 0) return new WP_Error('aksrt_credits_user', __('用户无效', 'aksrt')); + if ($delta <= 0) return new WP_Error('aksrt_credits_delta', __('增加积分必须为正数', 'aksrt')); + + $new_balance = self::atomic_update($user_id, $delta, false); + if ($new_balance === null) { + return new WP_Error('aksrt_credits_db', __('积分写入失败', 'aksrt')); + } + self::log($user_id, $delta, $new_balance, $reason, $ref, $remark); + + do_action('aksrt_credits_added', $user_id, $delta, $reason, $new_balance); + return $new_balance; + } + + /** + * 扣减积分。 + * + * @return int|WP_Error 新余额或错误 + */ + public static function deduct(int $user_id, int $cost, string $reason, array $ref = [], string $remark = '') { + if ($user_id <= 0) return new WP_Error('aksrt_credits_user', __('用户无效', 'aksrt')); + if ($cost < 0) return new WP_Error('aksrt_credits_delta', __('扣减积分必须为非负数', 'aksrt')); + if ($cost === 0) { + return self::balance($user_id); + } + + $new_balance = self::atomic_update($user_id, $cost, true); + if ($new_balance === null) { + $current = self::balance($user_id); + return new WP_Error('aksrt_credits_insufficient', __('积分不足', 'aksrt'), [ + 'balance' => $current, + 'cost' => $cost, + ]); + } + + self::log($user_id, -$cost, $new_balance, $reason, $ref, $remark); + + do_action('aksrt_credits_deducted', $user_id, $cost, $reason, $new_balance); + return $new_balance; + } + + /** + * 原子加减:用单条 SQL UPDATE 修改 user_meta,避免 read-modify-write 竞态。 + * + * @param int $user_id + * @param int $abs_delta 绝对值 + * @param bool $is_deduct true=扣减(带余额检查),false=增加 + * @return int|null 新余额;失败(余额不足或 DB 错误)返回 null + */ + private static function atomic_update(int $user_id, int $abs_delta, bool $is_deduct): ?int { + global $wpdb; + + // 确保 meta 行存在(首次扣费可能没有 row) + $existing = get_user_meta($user_id, self::META_KEY, true); + if ($existing === '' || $existing === false || $existing === null) { + update_user_meta($user_id, self::META_KEY, 0); + } + + $meta_key = self::META_KEY; + + if ($is_deduct) { + // 扣减:仅当 meta_value >= cost 时才更新 + $sql = $wpdb->prepare( + "UPDATE {$wpdb->usermeta} + SET meta_value = CAST(meta_value AS SIGNED) - %d + WHERE user_id = %d + AND meta_key = %s + AND CAST(meta_value AS SIGNED) >= %d", + $abs_delta, + $user_id, + $meta_key, + $abs_delta + ); + } else { + $sql = $wpdb->prepare( + "UPDATE {$wpdb->usermeta} + SET meta_value = CAST(meta_value AS SIGNED) + %d + WHERE user_id = %d + AND meta_key = %s", + $abs_delta, + $user_id, + $meta_key + ); + } + + $affected = $wpdb->query($sql); + if ($affected === false || $affected === 0) { + return null; + } + + // 失效 user meta cache,下次 get_user_meta 会重读 + wp_cache_delete($user_id, 'user_meta'); + + $new = (int) get_user_meta($user_id, self::META_KEY, true); + return $new; + } + + /** + * 写一条流水。 + * + * @param int $user_id + * @param int $delta 本次变动(正/负/0) + * @param int $balance_after 操作后余额 + * @param string $reason + * @param array $ref + */ + public static function log(int $user_id, int $delta, int $balance_after, string $reason, array $ref = [], string $remark = ''): void { + global $wpdb; + + $table = AKSRT_DB_Migrator::table('credit_log'); + if (!$table) return; + + $ref_type = isset($ref['type']) ? sanitize_key((string) $ref['type']) : null; + $ref_id = isset($ref['id']) ? (int) $ref['id'] : null; + + $wpdb->insert( + $table, + [ + 'user_id' => $user_id, + 'delta' => $delta, + 'balance_after' => $balance_after, + 'reason' => substr($reason, 0, 64), + 'remark' => $remark !== '' ? substr($remark, 0, 255) : null, + 'ref_type' => $ref_type, + 'ref_id' => $ref_id, + 'ip' => self::client_ip(), + 'ua' => self::client_ua(), + 'created_at' => current_time('mysql', true), + ], + ['%d', '%d', '%d', '%s', '%s', '%s', '%d', '%s', '%s', '%s'] + ); + } + + /** + * 取流水(管理员页面用)。 + * + * @param array $args { user_id, reason, page, per_page, date_from, date_to } + * @return array { items, total, page, per_page } + */ + public static function get_log(array $args = []): array { + global $wpdb; + + $table = AKSRT_DB_Migrator::table('credit_log'); + if (!$table) { + return ['items' => [], 'total' => 0, 'page' => 1, 'per_page' => 20]; + } + + $page = max(1, (int) ($args['page'] ?? 1)); + $per_page = min(100, max(1, (int) ($args['per_page'] ?? 20))); + $offset = ($page - 1) * $per_page; + + $where = ['1=1']; + $params = []; + + if (!empty($args['user_id'])) { + $where[] = 'user_id = %d'; + $params[] = (int) $args['user_id']; + } + if (!empty($args['reason'])) { + $where[] = 'reason = %s'; + $params[] = sanitize_key((string) $args['reason']); + } + if (!empty($args['date_from'])) { + $where[] = 'created_at >= %s'; + $params[] = (string) $args['date_from']; + } + if (!empty($args['date_to'])) { + $where[] = 'created_at <= %s'; + $params[] = (string) $args['date_to']; + } + + $where_sql = implode(' AND ', $where); + + // total + $count_sql = "SELECT COUNT(*) FROM {$table} WHERE {$where_sql}"; + $total = (int) ($params + ? $wpdb->get_var($wpdb->prepare($count_sql, $params)) + : $wpdb->get_var($count_sql)); + + // items + $items_sql = "SELECT * FROM {$table} WHERE {$where_sql} ORDER BY created_at DESC, id DESC LIMIT %d OFFSET %d"; + $items_params = array_merge($params, [$per_page, $offset]); + $items = $wpdb->get_results($wpdb->prepare($items_sql, $items_params), ARRAY_A); + if (!is_array($items)) $items = []; + + // user 名字预填 + $uid_set = array_unique(array_map(fn($r) => (int) $r['user_id'], $items)); + $names = []; + foreach ($uid_set as $uid) { + $u = get_userdata($uid); + if ($u) $names[$uid] = $u->display_name; + } + foreach ($items as &$row) { + $row['user_display_name'] = $names[(int) $row['user_id']] ?? '#' . $row['user_id']; + $row['delta'] = (int) $row['delta']; + $row['balance_after'] = (int) $row['balance_after']; + } + + return [ + 'items' => $items, + 'total' => $total, + 'page' => $page, + 'per_page' => $per_page, + ]; + } + + /** + * 查询用户余额列表(管理员)。 + * + * @param array $args { search, page, per_page } + * @return array + */ + public static function list_users(array $args = []): array { + $page = max(1, (int) ($args['page'] ?? 1)); + $per_page = min(100, max(1, (int) ($args['per_page'] ?? 20))); + + $q = [ + 'number' => $per_page, + 'paged' => $page, + 'orderby' => 'registered', + 'order' => 'DESC', + ]; + if (!empty($args['search'])) { + $q['search'] = '*' . esc_attr((string) $args['search']) . '*'; + $q['search_columns'] = ['user_login', 'user_email', 'display_name', 'user_nicename']; + } + $user_query = new WP_User_Query($q); + $users = $user_query->get_results(); + $items = []; + foreach ($users as $u) { + $items[] = [ + 'id' => (int) $u->ID, + 'login' => $u->user_login, + 'email' => $u->user_email, + 'display_name' => $u->display_name, + 'balance' => self::balance((int) $u->ID), + 'registered' => $u->user_registered, + ]; + } + return [ + 'items' => $items, + 'total' => (int) $user_query->get_total(), + 'page' => $page, + 'per_page' => $per_page, + ]; + } + + // ---------- 工具方法 ---------- + + private static function client_ip(): string { + if (function_exists('aksrt_get_client_ip')) { + return (string) aksrt_get_client_ip(); + } + if (!empty($_SERVER['REMOTE_ADDR'])) { + return (string) $_SERVER['REMOTE_ADDR']; + } + return ''; + } + + private static function client_ua(): string { + $ua = isset($_SERVER['HTTP_USER_AGENT']) ? (string) $_SERVER['HTTP_USER_AGENT'] : ''; + return substr($ua, 0, 255); + } +} diff --git a/inc/classes/class-aksrt-db-migrator.php b/inc/classes/class-aksrt-db-migrator.php new file mode 100644 index 00000000..1cb0ac76 --- /dev/null +++ b/inc/classes/class-aksrt-db-migrator.php @@ -0,0 +1,386 @@ +get_col("SELECT ID FROM {$wpdb->posts} WHERE post_type = 'aksrt_resource' LIMIT 500"); + if (empty($ids)) { + // 无历史数据:直接打标记 + update_option(self::OPT_RESOURCE_MIGRATED, time()); + return; + } + + foreach ($ids as $id) { + $id = (int) $id; + // 转 post_type + $wpdb->update( + $wpdb->posts, + ['post_type' => 'post'], + ['ID' => $id], + ['%s'], + ['%d'] + ); + update_post_meta($id, '_aksrt_is_resource', 1); + clean_post_cache($id); + } + + // 若一批迁移满 500,留待下次触发;否则收尾 + if (count($ids) < 500) { + update_option(self::OPT_RESOURCE_MIGRATED, time()); + // 触发重写规则刷新(因为 /resource/{slug} 旧链接需要 301 重定向) + delete_option('rewrite_rules'); + } + } + + /** + * v4 形态归一迁移: + * - aksrt_settings 中的 site_profile / opening_mode / archive_layout + * / default_archive_layout / post_layout 全部 unset + * - aksrt_basic_settings 中的 opening_mode / post_layout 全部 unset + * - 全部分类的 aksrt_flow term meta 删除 + * + * 幂等。 + */ + private static function maybe_migrate_cms_format(): void { + if (get_option(self::OPT_CMS_FORMAT_MIGRATED)) return; + + // 1. aksrt_settings + $settings = get_option('aksrt_settings', []); + if (is_array($settings)) { + $changed = false; + foreach (['site_profile', 'opening_mode', 'archive_layout', 'default_archive_layout', 'post_layout'] as $k) { + if (array_key_exists($k, $settings)) { + unset($settings[$k]); + $changed = true; + } + } + if ($changed) { + update_option('aksrt_settings', $settings); + } + } + + // 2. aksrt_basic_settings + $basic = get_option('aksrt_basic_settings', []); + if (is_array($basic)) { + $changed = false; + foreach (['opening_mode', 'post_layout'] as $k) { + if (array_key_exists($k, $basic)) { + unset($basic[$k]); + $changed = true; + } + } + if ($changed) { + update_option('aksrt_basic_settings', $basic); + } + } + + // 3. 删除全部分类的 aksrt_flow term meta + global $wpdb; + if ($wpdb instanceof wpdb) { + $wpdb->delete($wpdb->termmeta, ['meta_key' => 'aksrt_flow'], ['%s']); + } + + update_option(self::OPT_CMS_FORMAT_MIGRATED, time()); + } + + private static function maybe_migrate_resource_pay_meta(): void { + if (get_option(AKSRT_Domain_Model::RESOURCE_PAY_META_MIGRATED)) return; + if (!is_admin() && !(defined('DOING_CRON') && DOING_CRON)) return; + + global $wpdb; + + $batch_size = 200; + $cursor = (int) get_option(self::OPT_RESOURCE_PAY_META_CURSOR, 0); + $candidate_keys = [ + AKSRT_Domain_Model::PAY_META_KEY, + AKSRT_Domain_Model::PAY_TYPE_KEY, + AKSRT_Domain_Model::PAY_PRICE_KEY, + AKSRT_Domain_Model::RESOURCE_MARKER_KEY, + '_aksrt_res_pay_type', + '_aksrt_res_price', + '_aksrt_res_original_price', + '_aksrt_res_vip1_price', + '_aksrt_res_vip2_price', + '_aksrt_res_version', + '_aksrt_res_size', + '_aksrt_res_license', + '_aksrt_res_compat', + '_aksrt_res_demo_url', + '_aksrt_res_downloads', + '_aksrt_res_require_login', + '_aksrt_res_require_captcha', + 'download_count', + ]; + + $placeholders = implode(', ', array_fill(0, count($candidate_keys), '%s')); + $sql = "SELECT DISTINCT pm.post_id + FROM {$wpdb->postmeta} pm + INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id + WHERE p.post_type IN ('post', 'aksrt_resource') + AND pm.meta_key IN ({$placeholders}) + AND pm.post_id > %d + ORDER BY pm.post_id ASC + LIMIT %d"; + + $params = array_merge($candidate_keys, [$cursor, $batch_size]); + $ids = $wpdb->get_col($wpdb->prepare($sql, $params)); + + if (empty($ids)) { + delete_option(self::OPT_RESOURCE_PAY_META_CURSOR); + update_option(AKSRT_Domain_Model::RESOURCE_PAY_META_MIGRATED, time()); + return; + } + + foreach ($ids as $post_id) { + self::migrate_resource_pay_meta_post((int) $post_id); + } + + update_option(self::OPT_RESOURCE_PAY_META_CURSOR, max(array_map('intval', $ids))); + + if (count($ids) < $batch_size) { + delete_option(self::OPT_RESOURCE_PAY_META_CURSOR); + update_option(AKSRT_Domain_Model::RESOURCE_PAY_META_MIGRATED, time()); + } + } + + private static function maybe_migrate_orders_to_repository(): void { + if (get_option(AKSRT_Domain_Model::ORDER_MIGRATED_OPTION)) return; + if (!class_exists('AKSRT_Order_Repository')) return; + + AKSRT_Order_Repository::get_instance()->migrate_from_option(); + update_option(AKSRT_Domain_Model::ORDER_MIGRATED_OPTION, time()); + } + + /** + * §7.0 板块 taxonomy → plate CPT 一次性迁移。 + * + * 将 aksrt_forum_board taxonomy terms 转为 aksrt_forum_plate CPT posts, + * 保留 term meta → post meta 映射、层级关系、版主数据。 + * 同时在每个帖子上写入 _aksrt_plate_id 指向新 plate post。 + * 旧 taxonomy 数据保留不删(向后兼容)。 + */ + private static function maybe_migrate_board_taxonomy_to_plate_cpt(): void { + if (get_option(self::OPT_PLATE_CPT_MIGRATED)) return; + if (!is_admin() && !(defined('DOING_CRON') && DOING_CRON)) return; + if (!post_type_exists('aksrt_forum_plate')) return; + + $terms = get_terms([ + 'taxonomy' => 'aksrt_forum_board', + 'hide_empty' => false, + 'orderby' => 'meta_value_num', + 'meta_key' => '_aksrt_sort_order', + 'order' => 'ASC', + ]); + + if (is_wp_error($terms) || empty($terms)) { + update_option(self::OPT_PLATE_CPT_MIGRATED, time()); + return; + } + + $term_to_plate = []; + + foreach ($terms as $term) { + $existing = get_posts([ + 'post_type' => 'aksrt_forum_plate', + 'meta_key' => '_aksrt_migrated_from_term', + 'meta_value' => (string) $term->term_id, + 'numberposts' => 1, + 'post_status' => 'any', + 'fields' => 'ids', + ]); + + if (!empty($existing)) { + $term_to_plate[$term->term_id] = (int) $existing[0]; + continue; + } + + $plate_id = wp_insert_post([ + 'post_type' => 'aksrt_forum_plate', + 'post_title' => $term->name, + 'post_content' => $term->description ?: '', + 'post_excerpt' => wp_trim_words($term->description ?: '', 30, ''), + 'post_status' => 'publish', + 'post_parent' => 0, + 'menu_order' => (int) get_term_meta($term->term_id, '_aksrt_sort_order', true), + ], true); + + if (is_wp_error($plate_id)) { + continue; + } + + $term_to_plate[$term->term_id] = $plate_id; + + update_post_meta($plate_id, '_aksrt_migrated_from_term', $term->term_id); + update_post_meta($plate_id, '_aksrt_sort_order', (int) get_term_meta($term->term_id, '_aksrt_sort_order', true)); + update_post_meta($plate_id, '_aksrt_icon', (string) get_term_meta($term->term_id, '_aksrt_icon', true)); + + $moderators = get_term_meta($term->term_id, '_aksrt_moderators', true); + if (is_array($moderators) && !empty($moderators)) { + update_post_meta($plate_id, '_aksrt_moderators', $moderators); + } + + $roles = get_term_meta($term->term_id, '_aksrt_moderator_roles', true); + if (is_array($roles) && !empty($roles)) { + update_post_meta($plate_id, '_aksrt_moderator_roles', $roles); + } + + $topic_count = (int) get_term_meta($term->term_id, '_aksrt_topic_count', true); + if ($topic_count <= 0) { + $topic_count = (int) $term->count; + } + update_post_meta($plate_id, '_aksrt_posts_count', $topic_count); + } + + // Resolve parent hierarchy + foreach ($terms as $term) { + if ($term->parent > 0 && isset($term_to_plate[$term->term_id], $term_to_plate[$term->parent])) { + wp_update_post([ + 'ID' => $term_to_plate[$term->term_id], + 'post_parent' => $term_to_plate[$term->parent], + ]); + } + } + + // Assign _aksrt_plate_id on existing topics + global $wpdb; + foreach ($term_to_plate as $term_id => $plate_id) { + $topic_ids = $wpdb->get_col($wpdb->prepare( + "SELECT tr.object_id FROM {$wpdb->term_relationships} tr + INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id + WHERE tt.term_id = %d AND tt.taxonomy = 'aksrt_forum_board'", + $term_id + )); + + foreach ($topic_ids as $topic_id) { + update_post_meta((int) $topic_id, '_aksrt_plate_id', $plate_id); + } + } + + delete_option('rewrite_rules'); + update_option(self::OPT_PLATE_CPT_MIGRATED, time()); + } + +} diff --git a/inc/classes/class-aksrt-discount.php b/inc/classes/class-aksrt-discount.php new file mode 100644 index 00000000..4b77f204 --- /dev/null +++ b/inc/classes/class-aksrt-discount.php @@ -0,0 +1,171 @@ + Sorted by priority desc. + */ + public function get_active_discounts(int $product_id): array { + $terms = wp_get_object_terms($product_id, 'shop_discount', ['fields' => 'all']); + if (is_wp_error($terms) || empty($terms)) { + return []; + } + + $now = current_time('timestamp'); + $active = []; + + foreach ($terms as $term) { + $meta = AKSRT_Shop_Term_Meta::get_discount_meta($term->term_id); + + // Time limit check + if (!empty($meta['start']) && strtotime($meta['start']) > $now) { + continue; + } + if (!empty($meta['end']) && strtotime($meta['end']) < $now) { + continue; + } + + $meta['term_id'] = $term->term_id; + $meta['name'] = $term->name; + $active[] = $meta; + } + + // Sort by priority desc + usort($active, function ($a, $b) { + return ((int) $b['priority']) - ((int) $a['priority']); + }); + + return $active; + } + + /** + * Calculate discount for a single line item. + * + * @param float $unit_price Unit price + * @param int $quantity Quantity + * @param int $product_id Product ID + * @param int $user_id User ID (for user_limit checks) + * @return array{ + * original: float, + * discount_amount: float, + * final_price: float, + * gifts: array, + * applied: array + * } + */ + public function calculate(float $unit_price, int $quantity, int $product_id, int $user_id = 0): array { + $original = $unit_price * $quantity; + $discount_amount = 0.0; + $gifts = []; + $applied = []; + + $discounts = $this->get_active_discounts($product_id); + + foreach ($discounts as $d) { + // User limit check + if (!empty($d['user_limit']) && $user_id > 0) { + if (!$this->check_user_limit($d['user_limit'], $user_id)) { + continue; + } + } + + // Price limit check + if ((float) $d['price_limit'] > 0 && $original < (float) $d['price_limit']) { + continue; + } + + $type = $d['type'] ?? 'reduction'; + $value = (float) ($d['value'] ?? 0); + + switch ($type) { + case 'reduction': + // 立减 + $discount_amount += min($value * $quantity, $original - $discount_amount); + break; + + case 'discount': + // 打折 (value = 折扣系数 e.g. 8.5 = 85折) + if ($value > 0 && $value < 10) { + $rate = $value / 10.0; + $save = ($original - $discount_amount) * (1 - $rate); + $discount_amount += $save; + } + break; + + case 'gift': + // 买赠 — collect gift config, no price reduction + if (!empty($d['gift_config'])) { + $gift_data = json_decode($d['gift_config'], true); + if (is_array($gift_data)) { + $gifts[] = $gift_data; + } + } + break; + } + + $applied[] = [ + 'term_id' => $d['term_id'], + 'name' => $d['name'] ?? '', + 'type' => $type, + 'value' => $value, + ]; + } + + $discount_amount = min($discount_amount, $original); + + return [ + 'original' => $original, + 'discount_amount' => round($discount_amount, 2), + 'final_price' => round($original - $discount_amount, 2), + 'gifts' => $gifts, + 'applied' => $applied, + ]; + } + + /** + * Check if a user meets the user_limit requirement. + * + * @param string $limit 'vip' / 'vip_2' / 'auth' + * @param int $user_id + * @return bool + */ + private function check_user_limit(string $limit, int $user_id): bool { + if (empty($limit)) { + return true; + } + + switch ($limit) { + case 'vip': + return function_exists('aksrt_is_vip') ? aksrt_is_vip($user_id) : false; + case 'vip_2': + return function_exists('aksrt_is_vip2') ? aksrt_is_vip2($user_id) : false; + case 'auth': + return function_exists('aksrt_is_verified') ? aksrt_is_verified($user_id) : false; + default: + return true; + } + } +} diff --git a/inc/classes/class-aksrt-docs-presenter.php b/inc/classes/class-aksrt-docs-presenter.php new file mode 100644 index 00000000..d5ce3224 --- /dev/null +++ b/inc/classes/class-aksrt-docs-presenter.php @@ -0,0 +1,232 @@ +collect_children($root_id, $result, 0, $max_depth); + return $result; + } + + /** + * Recursive helper to collect pages in order. + */ + private function collect_children(int $parent_id, array &$result, int $depth, int $max_depth): void { + if ($depth >= $max_depth) { + return; + } + + $children = get_pages([ + 'parent' => $parent_id, + 'sort_column' => 'menu_order, post_title', + 'sort_order' => 'ASC', + 'post_status' => 'publish', + ]); + + foreach ($children as $child) { + $child->_depth = $depth; + $result[] = $child; + $this->collect_children($child->ID, $result, $depth + 1, $max_depth); + } + } + + /** + * Get sibling pages for chapter navigation (prev/next). + * + * @param int $page_id Current page ID. + * @return array{prev: object|null, next: object|null} + */ + public function get_chapter_nav(int $page_id): array { + $parent_id = wp_get_post_parent_id($page_id); + + $siblings = get_pages([ + 'parent' => $parent_id ?: 0, + 'sort_column' => 'menu_order, post_title', + 'sort_order' => 'ASC', + 'post_status' => 'publish', + ]); + + $current_index = -1; + foreach ($siblings as $i => $sibling) { + if ((int) $sibling->ID === $page_id) { + $current_index = $i; + break; + } + } + + $prev = $current_index > 0 ? $siblings[$current_index - 1] : null; + $next = ($current_index >= 0 && $current_index < count($siblings) - 1) + ? $siblings[$current_index + 1] + : null; + + return ['prev' => $prev, 'next' => $next]; + } + + /** + * Get root-level document pages for the document navigation index. + * + * @param string $order Sort order: 'menu_order', 'date', 'title'. + * @return array + */ + public function get_root_pages(string $order = 'menu_order'): array { + $sort_column = 'menu_order, post_title'; + if ($order === 'title') { + $sort_column = 'post_title'; + } elseif ($order === 'date') { + $sort_column = 'post_date'; + } + + return get_pages([ + 'sort_column' => $sort_column, + 'sort_order' => 'ASC', + 'parent' => 0, + 'hierarchical' => 0, + 'post_status' => 'publish', + ]); + } + + /** + * Get recently modified pages for the sidebar. + * + * @param int $count Number of pages to return. + * @return array + */ + public function get_recent_pages(int $count = 8): array { + return get_pages([ + 'sort_column' => 'post_modified', + 'sort_order' => 'DESC', + 'number' => $count, + 'post_status' => 'publish', + ]); + } + + /** + * Build breadcrumb trail for a document page. + * + * @param int $page_id Current page ID. + * @return array Array of {id, title, url} objects. + */ + public function get_breadcrumbs(int $page_id): array { + $trail = []; + $current = get_post($page_id); + + while ($current) { + array_unshift($trail, [ + 'id' => $current->ID, + 'title' => $current->post_title, + 'url' => get_permalink($current->ID), + ]); + $parent_id = wp_get_post_parent_id($current->ID); + $current = $parent_id ? get_post($parent_id) : null; + } + + return $trail; + } + + /** + * Search document pages by keyword. + * + * @param string $keyword Search keyword. + * @param int $root_id Optional root page ID to scope search. + * @param int $limit Max results. + * @return array Array of page objects with 'url' and 'excerpt' added. + */ + public function search_pages(string $keyword, int $root_id = 0, int $limit = 20): array { + $args = [ + 'post_type' => 'page', + 'post_status' => 'publish', + 's' => $keyword, + 'posts_per_page' => $limit, + 'orderby' => 'relevance', + ]; + + if ($root_id > 0) { + $tree_ids = [$root_id]; + $this->collect_children($root_id, $tree_ids, 0, 4); + $ids = array_map(static function ($item) { + return is_object($item) ? $item->ID : (int) $item; + }, $tree_ids); + $ids[] = $root_id; + $args['post__in'] = array_unique($ids); + } + + $query = new WP_Query($args); + $results = []; + + foreach ($query->posts as $post) { + $results[] = [ + 'id' => $post->ID, + 'title' => $post->post_title, + 'url' => get_permalink($post->ID), + 'excerpt' => wp_trim_words(wp_strip_all_tags($post->post_content), 30), + 'modified' => $post->post_modified, + 'parent' => (int) $post->post_parent, + ]; + } + + wp_reset_postdata(); + return $results; + } + + /** + * Check whether a given page belongs to the docs mode. + * + * @param int $page_id Page ID. + * @return bool + */ + public function is_docs_page(int $page_id): bool { + if (!class_exists('AKSRT_Settings')) { + return false; + } + + $settings = AKSRT_Settings::get_docs_mode_settings(); + if (empty($settings['enabled'])) { + return false; + } + + $template = get_page_template_slug($page_id); + return in_array($template, [ + 'templates/pages/page-documentnav.php', + 'templates/pages/page-special.php', + ], true); + } +} diff --git a/inc/classes/class-aksrt-download-state.php b/inc/classes/class-aksrt-download-state.php new file mode 100644 index 00000000..d02e0818 --- /dev/null +++ b/inc/classes/class-aksrt-download-state.php @@ -0,0 +1,208 @@ +build_result( + self::STATE_NO_PERMISSION, + false, + __('该资源暂未提供下载链接。', 'aksrt'), + __('暂无下载', 'aksrt'), + '', + 0, 0, $login_url + ); + } + + // Not logged in + if (!$is_logged_in) { + $requires_login = false; + if (class_exists('AKSRT_Settings')) { + $paid = AKSRT_Settings::get_paid_content_settings(); + $requires_login = !empty($paid['download_requires_login']); + } + if ($requires_login) { + return $this->build_result( + self::STATE_NOT_LOGGED_IN, + false, + __('登录后可下载此资源。', 'aksrt'), + __('登录下载', 'aksrt'), + $login_url, + 0, 0, $login_url + ); + } + } + + // Check gate (credits, permission, etc.) + $gate = function_exists('aksrt_can_download') ? aksrt_can_download($post_id) : true; + if (is_wp_error($gate)) { + $code = $gate->get_error_code(); + $data = (array) $gate->get_error_data(); + $balance = (int) ($data['balance'] ?? 0); + $cost = function_exists('aksrt_resource_download_cost') + ? aksrt_resource_download_cost($post_id) + : (int) ceil($price); + + if ($code === 'aksrt_need_login') { + return $this->build_result( + self::STATE_NOT_LOGGED_IN, + false, + __('登录后可下载此资源。', 'aksrt'), + __('登录下载', 'aksrt'), + (string) ($data['login_url'] ?? $login_url), + $cost, $balance, (string) ($data['login_url'] ?? $login_url) + ); + } + + if ($code === 'aksrt_credits_insufficient') { + return $this->build_result( + self::STATE_INSUFFICIENT_CREDITS, + false, + sprintf( + /* translators: 1: current balance, 2: required cost */ + __('当前余额 %1$d 积分,需要 %2$d 积分。', 'aksrt'), + $balance, + $cost + ), + sprintf(__('付费下载(%d 积分)', 'aksrt'), $cost), + '', + $cost, $balance, $login_url + ); + } + + // Other gate error — treat as no permission + return $this->build_result( + self::STATE_NO_PERMISSION, + false, + $gate->get_error_message() ?: __('无下载权限。', 'aksrt'), + __('无权限', 'aksrt'), + '', + 0, 0, $login_url + ); + } + + // Check if user already owns/downloads + $has_downloaded = false; + if ($is_logged_in) { + $downloaded = get_user_meta($user_id, '_aksrt_downloaded_resources', true); + $downloaded = is_array($downloaded) ? $downloaded : []; + $has_downloaded = in_array($post_id, $downloaded, true); + } + + $cost = function_exists('aksrt_resource_download_cost') + ? aksrt_resource_download_cost($post_id) + : (int) ceil($price); + $balance = 0; + if ($is_logged_in && class_exists('AKSRT_Credits')) { + $balance = AKSRT_Credits::balance($user_id); + } + + if ($has_downloaded) { + return $this->build_result( + self::STATE_OWNED, + true, + __('已下载,可再次下载。', 'aksrt'), + __('再次下载', 'aksrt'), + '', + $cost, $balance, $login_url + ); + } + + // Available + $label = $cost > 0 + ? sprintf(__('下载(%d 积分)', 'aksrt'), $cost) + : __('立即下载', 'aksrt'); + + return $this->build_result( + self::STATE_AVAILABLE, + true, + $cost > 0 + ? sprintf(__('下载此资源将消耗 %d 积分。', 'aksrt'), $cost) + : __('此资源可免费下载。', 'aksrt'), + $label, + '', + $cost, $balance, $login_url + ); + } + + /** + * Build a normalized result array. + */ + private function build_result( + string $state, + bool $can_download, + string $message, + string $action_label, + string $action_url, + int $cost, + int $balance, + string $login_url + ): array { + return [ + 'state' => $state, + 'can_download' => $can_download, + 'message' => $message, + 'action_label' => $action_label, + 'action_url' => $action_url, + 'cost' => $cost, + 'balance' => $balance, + 'login_url' => $login_url, + ]; + } +} diff --git a/inc/classes/class-aksrt-download.php b/inc/classes/class-aksrt-download.php new file mode 100644 index 00000000..163677de --- /dev/null +++ b/inc/classes/class-aksrt-download.php @@ -0,0 +1,329 @@ +getTimestamp(); + wp_schedule_event($next, 'daily', 'aksrt_dl_token_cleanup'); + } + } + + public function cleanup_expired_tokens(): void { + global $wpdb; + if (!class_exists('AKSRT_DB_Migrator')) return; + + $table = AKSRT_DB_Migrator::table('dl_token'); + if (!$table) return; + + $now_utc = (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d H:i:s'); + $audit_cutoff = (new DateTimeImmutable('-7 days', new DateTimeZone('UTC')))->format('Y-m-d H:i:s'); + + $wpdb->query($wpdb->prepare( + "DELETE FROM {$table} WHERE expires_at < %s", + $now_utc + )); + + $wpdb->query($wpdb->prepare( + "DELETE FROM {$table} WHERE used_count >= max_uses AND created_at < %s", + $audit_cutoff + )); + } + + public function register_rewrite(): void { + add_rewrite_rule( + '^' . self::ENDPOINT . '/([A-Za-z0-9]{1,64})/?$', + 'index.php?' . self::QUERY_VAR . '=$matches[1]', + 'top' + ); + } + + public function add_query_var(array $vars): array { + $vars[] = self::QUERY_VAR; + return $vars; + } + + public function register_routes(): void { + register_rest_route('aksrt/v1', '/download/request', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_request'], + 'permission_callback' => [$this, 'check_rest_nonce'], + ]); + + register_rest_route('aksrt/v1', '/download/policy/(?P\\d+)', [ + 'methods' => 'GET', + 'callback' => [$this, 'rest_policy'], + 'permission_callback' => '__return_true', + ]); + } + + public function check_rest_nonce(WP_REST_Request $request) { + $nonce = $request->get_header('x-wp-nonce'); + if (!$nonce) { + return new WP_Error('aksrt_rest_no_nonce', __('缺少安全令牌', 'aksrt'), ['status' => 403]); + } + if (!wp_verify_nonce($nonce, 'wp_rest')) { + return new WP_Error('aksrt_rest_bad_nonce', __('安全令牌已过期,请刷新页面', 'aksrt'), ['status' => 403]); + } + return true; + } + + public function rest_policy(WP_REST_Request $request) { + $post_id = (int) $request['id']; + $post = get_post($post_id); + if (!$post || !aksrt_is_resource_post($post)) { + return new WP_Error('aksrt_invalid', __('资源不存在', 'aksrt'), ['status' => 404]); + } + + $meta = aksrt_get_resource_meta($post); + $paid_settings = AKSRT_Settings::get_paid_content_settings(); + $cost = aksrt_resource_download_cost($post); + $uid = get_current_user_id(); + + $need_captcha = !empty($paid_settings['download_captcha']) || !empty($meta['require_captcha']); + if ($need_captcha && function_exists('aksrt_is_captcha_enabled_for') && !aksrt_is_captcha_enabled_for('download')) { + $need_captcha = false; + } + + return new WP_REST_Response([ + 'post_id' => $post_id, + 'price' => (float) $meta['price'], + 'cost' => $cost, + 'require_login' => !empty($paid_settings['download_login']) || !empty($meta['require_login']), + 'require_captcha' => $need_captcha, + 'logged_in' => $uid > 0, + 'balance' => $uid > 0 && class_exists('AKSRT_Credits') ? AKSRT_Credits::balance($uid) : 0, + 'sources' => array_map(function ($row, $idx) { + $extract_code = trim((string) ($row['extract_code'] ?? '')); + return [ + 'index' => $idx, + 'label' => $row['label'] ?? '', + 'type' => $row['type'] ?? 'direct', + 'has_extract' => $extract_code !== '', + ]; + }, $meta['downloads'], array_keys($meta['downloads'])), + ], 200); + } + + public function rest_request(WP_REST_Request $request) { + $params = $request->get_json_params(); + if (!is_array($params)) $params = $request->get_params(); + + $post_id = isset($params['post_id']) ? (int) $params['post_id'] : 0; + $source_index = isset($params['source_index']) ? (int) $params['source_index'] : 0; + + $post = get_post($post_id); + if (!$post || !aksrt_is_resource_post($post)) { + return new WP_Error('aksrt_invalid', __('资源不存在', 'aksrt'), ['status' => 404]); + } + + $meta = aksrt_get_resource_meta($post); + if (empty($meta['downloads'][$source_index])) { + return new WP_Error('aksrt_no_source', __('下载源不存在', 'aksrt'), ['status' => 404]); + } + $source = $meta['downloads'][$source_index]; + + $gate = aksrt_can_download($post); + if (is_wp_error($gate)) return $gate; + + $paid_settings = AKSRT_Settings::get_paid_content_settings(); + $need_captcha = !empty($paid_settings['download_captcha']) || !empty($meta['require_captcha']); + if ($need_captcha && function_exists('aksrt_is_captcha_enabled_for') && !aksrt_is_captcha_enabled_for('download')) { + $need_captcha = false; + } + if ($need_captcha && function_exists('aksrt_verify_captcha')) { + $cap_payload = isset($params['captcha']) && is_array($params['captcha']) ? $params['captcha'] : []; + $verify = aksrt_verify_captcha($cap_payload); + if ($verify !== true) { + $msg = is_string($verify) ? $verify : __('人机验证失败', 'aksrt'); + return new WP_Error('aksrt_captcha_failed', $msg, ['status' => 400]); + } + } + + $uid = get_current_user_id(); + $cost = aksrt_resource_download_cost($post); + if ($cost > 0 && $uid > 0 && class_exists('AKSRT_Credits')) { + $deducted = AKSRT_Credits::deduct($uid, $cost, 'download', ['type' => 'post', 'id' => $post_id]); + if (is_wp_error($deducted)) return $deducted; + } + + $token = $this->issue_token($uid, $post_id, $source_index, $cost); + if (is_wp_error($token)) { + if ($cost > 0 && $uid > 0 && class_exists('AKSRT_Credits')) { + AKSRT_Credits::add($uid, $cost, 'refund', ['type' => 'post', 'id' => $post_id]); + } + return $token; + } + + $dl_url = home_url('/' . self::ENDPOINT . '/' . $token); + if ($uid > 0 && function_exists('aksrt_add_user_notification')) { + aksrt_add_user_notification($uid, [ + 'type' => 'download_ready', + 'title' => __('资源下载已就绪', 'aksrt'), + 'desc' => sprintf(__('《%s》下载链接已生成,可立即开始下载。', 'aksrt'), get_the_title($post)), + 'url' => $dl_url, + ]); + } + + $extract_code = trim((string) ($source['extract_code'] ?? '')); + + return new WP_REST_Response([ + 'success' => true, + 'token' => $token, + 'dl_url' => $dl_url, + 'expires_in' => self::TOKEN_TTL, + 'source_label' => $source['label'] ?? '', + 'source_type' => $source['type'] ?? 'direct', + 'has_extract' => $extract_code !== '', + 'extract_code' => $extract_code, + 'balance' => $uid > 0 && class_exists('AKSRT_Credits') ? AKSRT_Credits::balance($uid) : 0, + ], 200); + } + + public function maybe_serve_download(): void { + $token = get_query_var(self::QUERY_VAR); + if (!$token) return; + + $token = (string) $token; + if (strlen($token) > 64 || !preg_match('/^[A-Za-z0-9]+$/', $token)) { + $this->fail_410(); + } + + global $wpdb; + $table = AKSRT_DB_Migrator::table('dl_token'); + if (!$table) $this->fail_410(); + + $row = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM {$table} WHERE token = %s LIMIT 1", + $token + ), ARRAY_A); + if (!$row) $this->fail_410(); + + $expires_at = strtotime($row['expires_at'] . ' UTC'); + if ($expires_at !== false && time() > $expires_at) $this->fail_410(); + + if ((int) $row['used_count'] >= (int) $row['max_uses']) $this->fail_410(); + + $ip = function_exists('aksrt_get_client_ip') ? (string) aksrt_get_client_ip() : (string) ($_SERVER['REMOTE_ADDR'] ?? ''); + $ua_hash = sha1((string) ($_SERVER['HTTP_USER_AGENT'] ?? '')); + if (!empty($row['ip_bind']) && $row['ip_bind'] !== $ip) $this->fail_410(); + if (!empty($row['ua_hash']) && $row['ua_hash'] !== $ua_hash) $this->fail_410(); + + $post = get_post((int) $row['post_id']); + if (!$post || !aksrt_is_resource_post($post)) $this->fail_410(); + $meta = aksrt_get_resource_meta($post); + $idx = (int) $row['source_index']; + if (empty($meta['downloads'][$idx]['url'])) $this->fail_410(); + + $wpdb->update( + $table, + ['used_count' => (int) $row['used_count'] + 1], + ['token' => $token], + ['%d'], + ['%s'] + ); + + // Count on actual token consumption. + if (function_exists('aksrt_update_post_counter')) { + aksrt_update_post_counter((int) $row['post_id'], '_aksrt_res_download_count', 1); + } else { + $count = (int) get_post_meta((int) $row['post_id'], '_aksrt_res_download_count', true); + update_post_meta((int) $row['post_id'], '_aksrt_res_download_count', $count + 1); + } + + nocache_headers(); + wp_redirect(esc_url_raw((string) $meta['downloads'][$idx]['url']), 302); + exit; + } + + private function fail_410(): void { + status_header(410); + nocache_headers(); + wp_die(esc_html__('下载链接已过期或无效,请回到资源页重新获取。', 'aksrt'), 410); + } + + private function issue_token(int $user_id, int $post_id, int $source_index, int $cost) { + global $wpdb; + $table = AKSRT_DB_Migrator::table('dl_token'); + if (!$table) { + return new WP_Error('aksrt_no_table', __('下载令牌表未初始化', 'aksrt'), ['status' => 500]); + } + + $token = $this->random_token(self::TOKEN_LEN); + + $ip = function_exists('aksrt_get_client_ip') ? (string) aksrt_get_client_ip() : (string) ($_SERVER['REMOTE_ADDR'] ?? ''); + $ua_hash = sha1((string) ($_SERVER['HTTP_USER_AGENT'] ?? '')); + + $now_utc = (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Y-m-d H:i:s'); + $expires_utc = (new DateTimeImmutable('now', new DateTimeZone('UTC')))->modify('+' . self::TOKEN_TTL . ' seconds')->format('Y-m-d H:i:s'); + + $ok = $wpdb->insert( + $table, + [ + 'token' => $token, + 'user_id' => $user_id, + 'post_id' => $post_id, + 'source_index' => $source_index, + 'cost' => $cost, + 'ip_bind' => $ip, + 'ua_hash' => $ua_hash, + 'used_count' => 0, + 'max_uses' => 1, + 'expires_at' => $expires_utc, + 'created_at' => $now_utc, + ], + ['%s', '%d', '%d', '%d', '%d', '%s', '%s', '%d', '%d', '%s', '%s'] + ); + + if ($ok === false) { + return new WP_Error('aksrt_token_insert', __('生成下载令牌失败', 'aksrt'), ['status' => 500]); + } + + return $token; + } + + private function random_token(int $len): string { + $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + $max = strlen($alphabet) - 1; + $out = ''; + for ($i = 0; $i < $len; $i++) { + $out .= $alphabet[random_int(0, $max)]; + } + return $out; + } +} diff --git a/inc/classes/class-aksrt-email-rollback.php b/inc/classes/class-aksrt-email-rollback.php new file mode 100644 index 00000000..71aba2cd --- /dev/null +++ b/inc/classes/class-aksrt-email-rollback.php @@ -0,0 +1,228 @@ +get_col($wpdb->prepare( + "SELECT user_id FROM {$wpdb->usermeta} WHERE meta_key = %s AND CAST(meta_value AS UNSIGNED) < %d", + self::META_CHANGED_AT, + $cutoff + )); + if (empty($rows)) return; + foreach ($rows as $uid) { + delete_user_meta((int) $uid, self::META_PREVIOUS); + delete_user_meta((int) $uid, self::META_CHANGED_AT); + } + } + + /** + * 在 14 天窗口内? + */ + public static function is_within_window(int $user_id): bool { + $changed_at = (int) get_user_meta($user_id, self::META_CHANGED_AT, true); + if ($changed_at <= 0) return false; + return (time() - $changed_at) <= self::WINDOW_SECONDS; + } + + /** + * 列出窗口内可回滚的用户。 + */ + public static function list_pending(int $page = 1, int $per_page = 20): array { + global $wpdb; + $page = max(1, $page); + $per_page = min(100, max(1, $per_page)); + $offset = ($page - 1) * $per_page; + $cutoff = time() - self::WINDOW_SECONDS; + + $sql_count = $wpdb->prepare( + "SELECT COUNT(DISTINCT um1.user_id) + FROM {$wpdb->usermeta} um1 + INNER JOIN {$wpdb->usermeta} um2 ON um2.user_id = um1.user_id AND um2.meta_key = %s + WHERE um1.meta_key = %s AND CAST(um1.meta_value AS UNSIGNED) >= %d", + self::META_PREVIOUS, + self::META_CHANGED_AT, + $cutoff + ); + $total = (int) $wpdb->get_var($sql_count); + + $sql = $wpdb->prepare( + "SELECT um1.user_id, um1.meta_value AS changed_at, um2.meta_value AS previous_email + FROM {$wpdb->usermeta} um1 + INNER JOIN {$wpdb->usermeta} um2 ON um2.user_id = um1.user_id AND um2.meta_key = %s + WHERE um1.meta_key = %s AND CAST(um1.meta_value AS UNSIGNED) >= %d + ORDER BY CAST(um1.meta_value AS UNSIGNED) DESC + LIMIT %d OFFSET %d", + self::META_PREVIOUS, + self::META_CHANGED_AT, + $cutoff, + $per_page, + $offset + ); + $rows = $wpdb->get_results($sql, ARRAY_A); + + $items = []; + foreach ((array) $rows as $r) { + $uid = (int) $r['user_id']; + $u = get_userdata($uid); + if (!$u) continue; + $items[] = [ + 'user_id' => $uid, + 'login' => $u->user_login, + 'display_name' => $u->display_name, + 'current_email' => $u->user_email, + 'previous_email' => (string) $r['previous_email'], + 'changed_at' => (int) $r['changed_at'], + 'expires_in' => max(0, (int) $r['changed_at'] + self::WINDOW_SECONDS - time()), + ]; + } + + return [ + 'items' => $items, + 'total' => $total, + 'page' => $page, + 'per_page' => $per_page, + ]; + } + + /** + * 执行回滚。 + * + * @return array{success:bool, message?:string, error?:string} + */ + public static function rollback(int $user_id, int $by_admin_id = 0): array { + if ($user_id <= 0 || !current_user_can('edit_user', $user_id)) { + return ['success' => false, 'error' => __('权限不足', 'aksrt')]; + } + if (!self::is_within_window($user_id)) { + return ['success' => false, 'error' => __('回滚窗口已过期或当前账号未发起过邮箱变更', 'aksrt')]; + } + + $previous = (string) get_user_meta($user_id, self::META_PREVIOUS, true); + if ($previous === '' || !is_email($previous)) { + return ['success' => false, 'error' => __('旧邮箱无效', 'aksrt')]; + } + + // 检查旧邮箱是否被其他用户占用(理论上极少见) + $occupier = email_exists($previous); + if ($occupier && (int) $occupier !== $user_id) { + return ['success' => false, 'error' => __('旧邮箱已被其他账号占用,无法回滚', 'aksrt')]; + } + + $update = wp_update_user([ + 'ID' => $user_id, + 'user_email' => $previous, + ]); + if (is_wp_error($update)) { + return ['success' => false, 'error' => $update->get_error_message()]; + } + + // 清空回滚 meta(一次性) + delete_user_meta($user_id, self::META_PREVIOUS); + delete_user_meta($user_id, self::META_CHANGED_AT); + + // 记录管理员操作日志 + $log = get_user_meta($user_id, 'aksrt_email_verification_admin_log', true); + if (!is_array($log)) $log = []; + $log[] = [ + 't' => time(), + 'action' => 'email_rollback', + 'by' => $by_admin_id ?: get_current_user_id(), + ]; + if (count($log) > 10) $log = array_slice($log, -10); + update_user_meta($user_id, 'aksrt_email_verification_admin_log', $log); + + if (class_exists('AKSRT_Auth_Service')) { + AKSRT_Auth_Service::log_event('admin.email_rollback', [ + 'uid' => $user_id, + 'by' => $by_admin_id ?: get_current_user_id(), + ]); + } + + return [ + 'success' => true, + 'message' => sprintf(__('已将用户邮箱回滚为 %s', 'aksrt'), $previous), + ]; + } + + /** + * 在用户列表行操作中加入"回滚邮箱"。 + */ + public function add_row_action(array $actions, WP_User $user): array { + if (!current_user_can('edit_user', $user->ID)) return $actions; + if (!self::is_within_window((int) $user->ID)) return $actions; + + $url = wp_nonce_url( + admin_url('admin-post.php?action=aksrt_email_rollback&user_id=' . (int) $user->ID), + 'aksrt_email_rollback_' . (int) $user->ID + ); + $actions['aksrt_email_rollback'] = sprintf( + '%s', + esc_url($url), + esc_html__('回滚邮箱', 'aksrt') + ); + return $actions; + } + + public function handle_admin_rollback(): void { + $user_id = isset($_GET['user_id']) ? absint($_GET['user_id']) : 0; + if ($user_id <= 0 || !current_user_can('edit_user', $user_id)) { + wp_die(esc_html__('权限不足', 'aksrt')); + } + check_admin_referer('aksrt_email_rollback_' . $user_id); + + $r = self::rollback($user_id, get_current_user_id()); + $type = !empty($r['success']) ? 'success' : 'error'; + $msg = $r['message'] ?? $r['error'] ?? ''; + set_transient('aksrt_admin_notice_' . get_current_user_id(), [['type' => $type, 'msg' => $msg]], 30); + + wp_safe_redirect(wp_get_referer() ?: admin_url('users.php')); + exit; + } +} diff --git a/inc/classes/class-aksrt-follow.php b/inc/classes/class-aksrt-follow.php new file mode 100644 index 00000000..28cbeab0 --- /dev/null +++ b/inc/classes/class-aksrt-follow.php @@ -0,0 +1,179 @@ +is_following($follower_id, $following_id)) { + return true; + } + + global $wpdb; + $result = $wpdb->insert($this->table(), [ + 'follower_id' => $follower_id, + 'following_id' => $following_id, + 'created_at' => current_time('mysql'), + ], ['%d', '%d', '%s']); + + return $result !== false; + } + + /** + * 取消关注 + */ + public function unfollow(int $follower_id, int $following_id): bool { + if ($follower_id <= 0 || $following_id <= 0) return false; + + global $wpdb; + $result = $wpdb->delete($this->table(), [ + 'follower_id' => $follower_id, + 'following_id' => $following_id, + ], ['%d', '%d']); + + return $result !== false; + } + + /** + * 切换关注状态 + * + * @return bool true=已关注, false=已取消 + */ + public function toggle(int $follower_id, int $following_id): bool { + if ($this->is_following($follower_id, $following_id)) { + $this->unfollow($follower_id, $following_id); + return false; + } + $this->follow($follower_id, $following_id); + return true; + } + + /** + * 是否已关注 + */ + public function is_following(int $follower_id, int $following_id): bool { + if ($follower_id <= 0 || $following_id <= 0) return false; + + global $wpdb; + $exists = $wpdb->get_var($wpdb->prepare( + "SELECT id FROM {$this->table()} WHERE follower_id = %d AND following_id = %d LIMIT 1", + $follower_id, $following_id + )); + + return (int) $exists > 0; + } + + /** + * 获取粉丝数(谁关注了 target_id) + */ + public function get_follower_count(int $user_id): int { + if ($user_id <= 0) return 0; + + global $wpdb; + return (int) $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM {$this->table()} WHERE following_id = %d", + $user_id + )); + } + + /** + * 获取关注数(target_id 关注了谁) + */ + public function get_following_count(int $user_id): int { + if ($user_id <= 0) return 0; + + global $wpdb; + return (int) $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM {$this->table()} WHERE follower_id = %d", + $user_id + )); + } + + /** + * 获取粉丝列表 + */ + public function get_followers(int $user_id, int $page = 1, int $per_page = 20): array { + return $this->get_user_list($user_id, 'following_id', $page, $per_page); + } + + /** + * 获取关注列表 + */ + public function get_following(int $user_id, int $page = 1, int $per_page = 20): array { + return $this->get_user_list($user_id, 'follower_id', $page, $per_page); + } + + /** + * 通用用户列表查询 + */ + private function get_user_list(int $user_id, string $where_field, int $page, int $per_page): array { + global $wpdb; + + $other_field = $where_field === 'follower_id' ? 'following_id' : 'follower_id'; + $offset = max(0, ($page - 1) * $per_page); + + $total = (int) $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM {$this->table()} WHERE {$where_field} = %d", + $user_id + )); + + $rows = $wpdb->get_results($wpdb->prepare( + "SELECT {$other_field} as user_id, created_at + FROM {$this->table()} + WHERE {$where_field} = %d + ORDER BY created_at DESC + LIMIT %d OFFSET %d", + $user_id, $per_page, $offset + ), ARRAY_A); + + $items = []; + foreach ((array) $rows as $row) { + $uid = (int) $row['user_id']; + $user = get_userdata($uid); + $items[] = [ + 'user_id' => $uid, + 'display_name' => $user ? $user->display_name : '', + 'avatar_url' => $user ? get_avatar_url($uid, ['size' => 48]) : '', + 'created_at' => $row['created_at'] ?? '', + ]; + } + + return [ + 'items' => $items, + 'total' => $total, + 'page' => $page, + 'total_pages' => max(1, (int) ceil($total / $per_page)), + ]; + } +} diff --git a/inc/classes/class-aksrt-forum.php b/inc/classes/class-aksrt-forum.php new file mode 100644 index 00000000..62284311 --- /dev/null +++ b/inc/classes/class-aksrt-forum.php @@ -0,0 +1,801 @@ +perm_service) { + $this->perm_service = new AKSRT_Forum_Permission_Service($this); + } + return $this->perm_service; + } + + private function score_service(): AKSRT_Forum_Score_Service { + if (!$this->score_service) { + $this->score_service = new AKSRT_Forum_Score_Service($this); + } + return $this->score_service; + } + + private function comment_service(): AKSRT_Forum_Comment_Service { + if (!$this->comment_service) { + $this->comment_service = new AKSRT_Forum_Comment_Service($this); + } + return $this->comment_service; + } + + private function draft_service(): AKSRT_Forum_Draft_Service { + if (!$this->draft_svc) { + require_once __DIR__ . '/forum/class-aksrt-forum-draft-service.php'; + $this->draft_svc = new AKSRT_Forum_Draft_Service($this); + } + return $this->draft_svc; + } + + private ?AKSRT_Forum_Topic_Service $topic_svc = null; + + private ?AKSRT_Forum_Moderation_Service $mod_svc = null; + private function moderation_service(): AKSRT_Forum_Moderation_Service { + if ($this->mod_svc === null) { + $this->mod_svc = new AKSRT_Forum_Moderation_Service($this); + } + return $this->mod_svc; + } + + private ?AKSRT_Forum_View_Builder $view_builder = null; + private function view_builder(): AKSRT_Forum_View_Builder { + if ($this->view_builder === null) { + $this->view_builder = new AKSRT_Forum_View_Builder($this); + } + return $this->view_builder; + } + + private function topic_service(): AKSRT_Forum_Topic_Service { + if ($this->topic_svc === null) { + $this->topic_svc = new AKSRT_Forum_Topic_Service($this); + } + return $this->topic_svc; + } + private function board_service(): AKSRT_Forum_Board_Service { + if (!$this->board_svc) { + $this->board_svc = new AKSRT_Forum_Board_Service($this); + } + return $this->board_svc; + } + + public function is_plate_cpt_active(): bool { + return post_type_exists('aksrt_forum_plate') + && (bool) get_option(AKSRT_DB_Migrator::OPT_PLATE_CPT_MIGRATED); + } + + public function get_config(): array { + if (class_exists('AKSRT_Settings')) { + return AKSRT_Settings::get_forum_settings(); + } + + return ['enabled' => false]; + } + + public function get_boards(array $args = []): array { + return $this->board_service()->get_boards($args); + } + + public function create_board(array $data): int { + return $this->board_service()->create_board($data); + } + + public function update_board(int $board_id, array $data): bool { + return $this->board_service()->update_board($board_id, $data); + } + + public function delete_board(int $board_id): bool { + return $this->board_service()->delete_board($board_id); + } + + public function reorder_boards(array $order_map): bool { + return $this->board_service()->reorder_boards($order_map); + } + + // §7.0 Plate CPT methods —板块 CPT 化 CRUD + + public function get_plates(array $args = []): array { + return $this->board_service()->get_plates($args); + } + + public function get_plate(int $plate_id): ?array { + return $this->board_service()->get_plate($plate_id); + } + + public function build_plate_payload(\WP_Post $post): array { + return $this->board_service()->build_plate_payload($post); + } + + public function create_plate(array $data): int { + return $this->board_service()->create_plate($data); + } + + public function update_plate(int $plate_id, array $data): bool { + return $this->board_service()->update_plate($plate_id, $data); + } + + public function delete_plate(int $plate_id, string $cascade = 'trash', int $move_to = 0): bool { + return $this->board_service()->delete_plate($plate_id, $cascade, $move_to); + } + + public function reorder_plates(array $order_map): bool { + return $this->board_service()->reorder_plates($order_map); + } + + public function get_topic_plate_id(int $topic_id): int { + return $this->board_service()->get_topic_plate_id($topic_id); + } + + public function is_plate_moderator(int $user_id, int $plate_id): bool { + return $this->board_service()->is_plate_moderator($user_id, $plate_id); + } + + public function get_plate_view(int $plate_id, array $args = []): array { + return $this->board_service()->get_plate_view($plate_id, $args); + } + + public function get_topics(array $args = []): array { + $view = $this->get_topic_list($args); + return $view['items']; + } + + public function get_topic_list(array $args = []): array { + return $this->view_builder()->get_topic_list($args); + } + + public function get_home_view(int $user_id = 0): array { + return $this->view_builder()->get_home_view($user_id); + } + + public function get_follow_stream(int $user_id, int $page = 1, int $limit = 15): array { + return $this->view_builder()->get_follow_stream($user_id, $page, $limit); + } + + public function get_synthesis_stream(int $page = 1, int $limit = 15, int $viewer = 0): array { + return $this->view_builder()->get_synthesis_stream($page, $limit, $viewer); + } + + public function get_pending_stream(int $user_id, int $page = 1, int $limit = 15): array { + return $this->view_builder()->get_pending_stream($user_id, $page, $limit); + } + + public function ajax_home_tab(): void { + $this->view_builder()->ajax_home_tab(); + } + + public function render_topic_row(array $topic): void { + $this->view_builder()->render_topic_row($topic); + } + + public function get_board_view(int $board_id, array $args = []): array { + return $this->board_service()->get_board_view($board_id, $args); + } + + public function can_view_plate(int $user_id, int $plate_id, string $allow_view = ''): bool { + return $this->board_service()->can_view_plate($user_id, $plate_id, $allow_view); + } + + public function can_compose_in_plate(int $user_id, int $plate_id): bool { + return $this->board_service()->can_compose_in_plate($user_id, $plate_id); + } + + public function is_user_following_plate(int $user_id, int $plate_id): bool { + return $this->board_service()->is_user_following_plate($user_id, $plate_id); + } + + public function toggle_follow_plate(int $user_id, int $plate_id): array { + return $this->board_service()->toggle_follow_plate($user_id, $plate_id); + } + + public function get_user_followed_plates(int $user_id): array { + return $this->board_service()->get_user_followed_plates($user_id); + } + + public function get_user_managed_plates(int $user_id): array { + return $this->board_service()->get_user_managed_plates($user_id); + } + + public function get_user_forum_topics(int $user_id, array $args = []): array { + return $this->view_builder()->get_user_forum_topics($user_id, $args); + } + + public function get_user_favorite_topics(int $user_id, int $page = 1, int $limit = 10): array { + return $this->view_builder()->get_user_favorite_topics($user_id, $page, $limit); + } + + public function get_user_forum_stats(int $user_id): array { + return $this->view_builder()->get_user_forum_stats($user_id); + } + + public function increment_plate_views(int $plate_id): void { + $this->board_service()->increment_plate_views($plate_id); + } + + public function is_hot_plate(int $plate_id): bool { + return $this->board_service()->is_hot_plate($plate_id); + } + + public function get_plate_categories(): array { + return $this->board_service()->get_plate_categories(); + } + + public function is_cat_moderator(int $user_id, int $cat_id): bool { + return $this->board_service()->is_cat_moderator($user_id, $cat_id); + } + + public function get_sorted_plates(string $orderby = 'menu_order', string $order = 'ASC', int $limit = 50): array { + return $this->board_service()->get_sorted_plates($orderby, $order, $limit); + } + + public function get_topic_view(int $topic_id, int $user_id = 0): array { + return $this->view_builder()->get_topic_view($topic_id, $user_id); + } + + public function get_compose_view(int $user_id, array $args = []): array { + return $this->view_builder()->get_compose_view($user_id, $args); + } + + public function get_stats(): array { + return $this->view_builder()->get_stats(); + } + + public function can_edit_topic(int $user_id, int $topic_id): bool { + return $this->topic_service()->can_edit_topic($user_id, $topic_id); + } + + public function can_moderate_topic(int $user_id, int $topic_id): bool { + return $this->forum_user_can($user_id, 'topic_moderate', $topic_id); + } + + public function update_topic(int $topic_id, array $data) { + return $this->topic_service()->update_topic($topic_id, $data); + } + + public function get_tags(): array { + return $this->view_builder()->get_tags(); + } + + public function get_tag_archive_view(array $args = []): array { + return $this->view_builder()->get_tag_archive_view($args); + } + + public function init(): void { + add_action('wp_ajax_aksrt_forum_update_topic', [$this, 'ajax_update_topic']); + add_action('wp_ajax_aksrt_forum_topic_list', [$this, 'ajax_topic_list']); + add_action('wp_ajax_aksrt_forum_create_topic', [$this, 'ajax_create_topic']); + add_action('wp_ajax_aksrt_forum_follow_plate', [$this, 'ajax_follow_plate']); + add_action('wp_ajax_aksrt_forum_unlock_topic', [$this, 'ajax_unlock_topic']); + + // §7.4 draft persistence + add_action('wp_ajax_aksrt_forum_save_draft', [$this, 'ajax_save_draft']); + add_action('wp_ajax_aksrt_forum_load_draft', [$this, 'ajax_load_draft']); + add_action('wp_ajax_aksrt_forum_delete_draft', [$this, 'ajax_delete_draft']); + + // §7.4 remote search selectors + add_action('wp_ajax_aksrt_forum_search_boards', [$this, 'ajax_search_boards']); + add_action('wp_ajax_nopriv_aksrt_forum_search_boards', [$this, 'ajax_search_boards']); + add_action('wp_ajax_aksrt_forum_search_tags', [$this, 'ajax_search_tags']); + add_action('wp_ajax_nopriv_aksrt_forum_search_tags', [$this, 'ajax_search_tags']); + add_action('wp_ajax_aksrt_forum_search_topics', [$this, 'ajax_search_topics']); + add_action('wp_ajax_nopriv_aksrt_forum_search_topics', [$this, 'ajax_search_topics']); + + // §7.4 AJAX-based topic editing + add_action('wp_ajax_aksrt_forum_edit_topic', [$this, 'ajax_edit_topic']); + + // §7.6 forum comment engine + add_action('wp_ajax_aksrt_forum_comments', [$this, 'ajax_forum_comments']); + add_action('wp_ajax_nopriv_aksrt_forum_comments', [$this, 'ajax_forum_comments']); + add_action('wp_ajax_aksrt_forum_comment_top', [$this, 'ajax_comment_topping']); + + self::register_hide_shortcode(); + + // §7.10 forum home tab AJAX + add_action('wp_ajax_aksrt_forum_home_tab', [$this, 'ajax_home_tab']); + add_action('wp_ajax_nopriv_aksrt_forum_home_tab', [$this, 'ajax_home_tab']); + + add_action('admin_init', [$this, 'maybe_migrate_boards'], 25); + add_filter('template_include', [$this, 'template_routing']); + add_action('wp', [$this, 'maybe_refresh_recommend_on_view']); + add_action('comment_post', [$this, 'on_forum_comment_posted'], 10, 3); + + // §7.2 板块多维统计 hooks + add_action('save_post_aksrt_forum_topic', [$this, 'on_topic_saved_sync_plate'], 20, 2); + add_action('comment_post', [$this, 'on_comment_sync_plate_reply'], 25, 3); + + add_action('wp_login', [$this, 'cap_cache_on_login'], 10, 2); + add_action('updated_post_meta', [$this, 'cap_cache_on_plate_meta'], 10, 3); + add_action('added_post_meta', [$this, 'cap_cache_on_plate_meta'], 10, 3); + add_action('deleted_post_meta', [$this, 'cap_cache_on_plate_meta'], 10, 3); + add_action('updated_term_meta', [$this, 'cap_cache_on_cat_meta'], 10, 3); + add_action('save_post_aksrt_forum_plate', [$this, 'cap_cache_on_plate_save'], 10, 1); + } + + public function on_topic_saved_sync_plate(int $post_id, \WP_Post $post): void { + $this->moderation_service()->on_topic_saved_sync_plate($post_id, $post); + } + + public function on_comment_sync_plate_reply(int $comment_id, $approved, array $commentdata): void { + $this->moderation_service()->on_comment_sync_plate_reply($comment_id, $approved, $commentdata); + } + + public function template_routing(string $template): string { + return $this->view_builder()->template_routing($template); + } + + public function update_topic_meta(int $topic_id, array $meta): bool { + return $this->topic_service()->update_topic_meta($topic_id, $meta); + } + + public function is_moderator(int $user_id, int $topic_id): bool { + return $this->moderation_service()->is_moderator($user_id, $topic_id); + } + + public function ajax_update_topic(): void { + $this->topic_service()->ajax_update_topic(); + } + + public function ajax_topic_list(): void { + $this->view_builder()->ajax_topic_list(); + } + + public function ajax_create_topic(): void { + $this->topic_service()->ajax_create_topic(); + } + + public function create_topic(int $user_id, array $data) { + return $this->topic_service()->create_topic($user_id, $data); + } + + public function moderate_topic(int $topic_id, string $action, array $payload, int $operator_id = 0) { + return $this->topic_service()->moderate_topic($topic_id, $action, $payload, $operator_id); + } + + public function submit_vote(int $topic_id, int $user_id, array $option_ids) { + return $this->topic_service()->submit_vote($topic_id, $user_id, $option_ids); + } + + public function ajax_follow_plate(): void { + $this->board_service()->ajax_follow_plate(); + } + + public function ajax_unlock_topic(): void { + $this->topic_service()->ajax_unlock_topic(); + } + + public function apply_moderator(int $user_id, int $board_id, string $reason) { + return $this->moderation_service()->apply_moderator($user_id, $board_id, $reason); + } + + public function review_moderator_application(string $application_id, string $status, string $note, int $reviewer_id) { + return $this->moderation_service()->review_moderator_application($application_id, $status, $note, $reviewer_id); + } + + public function get_moderator_application_list(array $args = []): array { + return $this->moderation_service()->get_moderator_application_list($args); + } + + public function sync_board_count(int $board_id): void { + $this->moderation_service()->sync_board_count($board_id); + } + + public function sync_plate_counts(int $plate_id): void { + $this->moderation_service()->sync_plate_counts($plate_id); + } + + public function get_board_moderators(int $board_id): array { + return $this->moderation_service()->get_board_moderators($board_id); + } + + public function get_plate_moderators(int $plate_id): array { + return $this->moderation_service()->get_plate_moderators($plate_id); + } + + public function is_forum_admin(int $user_id): bool { + return $this->moderation_service()->is_forum_admin($user_id); + } + + public function is_board_moderator(int $user_id, int $board_id): bool { + return $this->moderation_service()->is_board_moderator($user_id, $board_id); + } + + public function get_board_moderator_profiles(int $board_id): array { + return $this->moderation_service()->get_board_moderator_profiles($board_id); + } + + public function get_board_moderator_profiles_with_roles(int $board_id): array { + return $this->moderation_service()->get_board_moderator_profiles_with_roles($board_id); + } + + public function get_moderator_roles(int $board_id): array { + return $this->moderation_service()->get_moderator_roles($board_id); + } + + public function set_moderator_role(int $user_id, int $board_id, string $role): bool { + return $this->moderation_service()->set_moderator_role($user_id, $board_id, $role); + } + + public function add_moderator(int $user_id, int $board_id, string $role = 'moderator'): bool { + return $this->moderation_service()->add_moderator($user_id, $board_id, $role); + } + + public function remove_moderator(int $user_id, int $board_id): bool { + return $this->moderation_service()->remove_moderator($user_id, $board_id); + } + + public function get_user_moderator_boards(int $user_id): array { + return $this->moderation_service()->get_user_moderator_boards($user_id); + } + + public function has_moderator_role(int $user_id, int $board_id, string $min_role = 'moderator'): bool { + return $this->moderation_service()->has_moderator_role($user_id, $board_id, $min_role); + } + + public function get_board_moderator_applications(int $board_id): array { + return $this->moderation_service()->get_board_moderator_applications($board_id); + } + + public function get_plate_moderator_applications(int $plate_id): array { + return $this->moderation_service()->get_plate_moderator_applications($plate_id); + } + + public function get_board_application_for_user(int $user_id, int $board_id): ?array { + return $this->moderation_service()->get_board_application_for_user($user_id, $board_id); + } + + public function get_visibility_options(): array { + return $this->topic_service()->get_visibility_options(); + } + + public function get_review_options(): array { + return $this->topic_service()->get_review_options(); + } + + public function can_user_publish_directly(int $user_id, int $board_id = 0): bool { + return $this->topic_service()->can_user_publish_directly($user_id, $board_id); + } + + public function get_sticky_topics_for_board(int $board_id): array { + return $this->view_builder()->get_sticky_topics_for_board($board_id); + } + + public function get_topic_detail(int $topic_id, int $viewer_user_id = 0): array { + return $this->view_builder()->get_topic_detail($topic_id, $viewer_user_id); + } + + public function increment_view_count(int $topic_id): void { + $this->view_builder()->increment_view_count($topic_id); + } + + public function maybe_migrate_boards(): void { + $this->board_service()->maybe_migrate_boards(); + } + + public function build_topic_payload(WP_Post $post, int $viewer_user_id = 0): array { + return $this->topic_service()->build_topic_payload($post, $viewer_user_id); + } + + public function unlock_topic_with_credits(int $user_id, int $topic_id): array { + return $this->topic_service()->unlock_topic_with_credits($user_id, $topic_id); + } + + public function prepare_vote_meta(array $data): array { + return $this->topic_service()->prepare_vote_meta($data); + } + + public function save_vote_meta(int $topic_id, array $vote_meta): void { + $this->topic_service()->save_vote_meta($topic_id, $vote_meta); + } + + public function normalize_post_status_query(string $status) { + return $this->topic_service()->normalize_post_status_query($status); + } + + private function build_user_summary(int $user_id): array { + return $this->topic_service()->build_user_summary($user_id); + } + + private function get_user_avatar_url(int $user_id): string { + return $this->topic_service()->get_user_avatar_url($user_id); + } + + public function save_cover_meta(int $post_id, array $data): void { + $this->topic_service()->save_cover_meta($post_id, $data); + } + + public function split_tags(string $tags): array { + return $this->topic_service()->split_tags($tags); + } + + private function get_visibility_map(): array { + return $this->topic_service()->get_visibility_map(); + } + + private function get_review_map(): array { + return $this->topic_service()->get_review_map(); + } + + private function get_visibility_label(string $visibility): string { + return $this->topic_service()->get_visibility_label($visibility); + } + + private function get_review_label(string $review_status): string { + return $this->topic_service()->get_review_label($review_status); + } + + public function is_favorited(int $user_id, int $topic_id): bool { + return $this->score_service()->is_favorited($user_id, $topic_id); + } + + public function toggle_favorite(int $user_id, int $topic_id) { + return $this->score_service()->toggle_favorite($user_id, $topic_id); + } + + public function score_topic(int $user_id, int $topic_id, int $amount) { + return $this->score_service()->score_topic($user_id, $topic_id, $amount); + } + + public function get_score_detail(int $topic_id): array { + return $this->score_service()->get_score_detail($topic_id); + } + + public function calculate_recommend_score(int $topic_id): float { + return $this->score_service()->calculate_recommend_score($topic_id); + } + + public function refresh_recommend_score(int $topic_id): float { + return $this->score_service()->refresh_recommend_score($topic_id); + } + + public function batch_refresh_recommend_scores(int $limit = 100): int { + return $this->score_service()->batch_refresh_recommend_scores($limit); + } + + public function maybe_refresh_recommend_on_view(): void { + $this->score_service()->maybe_refresh_recommend_on_view(); + } + + public function notify(int $user_id, string $type, string $content, array $data = []): void { + $this->score_service()->notify($user_id, $type, $content, $data); + } + + public function notify_topic_pending(int $topic_id, int $author_id): void { + $this->score_service()->notify_topic_pending($topic_id, $author_id); + } + + public function notify_moderation_result(int $topic_id, string $action, int $operator_id): void { + $this->score_service()->notify_moderation_result($topic_id, $action, $operator_id); + } + + public function notify_topic_reply(int $topic_id, int $commenter_id): void { + $this->score_service()->notify_topic_reply($topic_id, $commenter_id); + } + + public function notify_topic_scored(int $topic_id, int $scorer_id, int $amount): void { + $this->score_service()->notify_topic_scored($topic_id, $scorer_id, $amount); + } + + const COMMENT_TOP_NONE = 0; + const COMMENT_TOP_NORMAL = 1; + const COMMENT_TOP_HOT = 2; + const COMMENT_TOP_BEST = 3; + + public function on_forum_comment_posted(int $comment_id, $approved, array $commentdata = []): void { + $this->score_service()->on_forum_comment_posted($comment_id, $approved, $commentdata); + } + + public function ajax_forum_comments(): void { + $this->comment_service()->ajax_forum_comments(); + } + + public function ajax_comment_topping(): void { + $this->comment_service()->ajax_comment_topping(); + } + + public function render_forum_comments(array $comments, int $topic_id, int $adopted_id = 0): void { + $this->comment_service()->render_forum_comments($comments, $topic_id, $adopted_id); + } + + public function adopt_answer(int $topic_id, int $comment_id, int $operator_id) { + return $this->comment_service()->adopt_answer($topic_id, $comment_id, $operator_id); + } + + public function get_adopted_comment_id(int $topic_id): int { + return $this->comment_service()->get_adopted_comment_id($topic_id); + } + + public function get_forum_comments(int $topic_id, string $sort = 'newest', bool $author_only = false, int $page = 1, int $per_page = 30): array { + return $this->comment_service()->get_forum_comments($topic_id, $sort, $author_only, $page, $per_page); + } + + public function set_comment_topping(int $comment_id, int $level): bool { + return $this->comment_service()->set_comment_topping($comment_id, $level); + } + + public function maybe_mark_hot_comment(int $comment_id): bool { + return $this->comment_service()->maybe_mark_hot_comment($comment_id); + } + + public function is_hot_comment(int $comment_id): bool { + return $this->comment_service()->is_hot_comment($comment_id); + } + + public function get_commenter_badge(int $user_id, int $topic_id): ?array { + return $this->comment_service()->get_commenter_badge($user_id, $topic_id); + } + + public function save_draft(int $user_id, array $data) { + return $this->draft_service()->save_draft($user_id, $data); + } + + public function get_user_draft_count(int $user_id): int { + return $this->draft_service()->get_user_draft_count($user_id); + } + + public function get_user_drafts(int $user_id, int $limit = 20): array { + return $this->draft_service()->get_user_drafts($user_id, $limit); + } + + public function delete_draft(int $user_id, int $draft_id): bool { + return $this->draft_service()->delete_draft($user_id, $draft_id); + } + + public function load_draft_data(int $user_id, int $draft_id): ?array { + return $this->draft_service()->load_draft_data($user_id, $draft_id); + } + + public function ajax_save_draft(): void { + $this->draft_service()->ajax_save_draft(); + } + + public function ajax_load_draft(): void { + $this->draft_service()->ajax_load_draft(); + } + + public function ajax_delete_draft(): void { + $this->draft_service()->ajax_delete_draft(); + } + + public function ajax_search_boards(): void { + $this->view_builder()->ajax_search_boards(); + } + + public function ajax_search_tags(): void { + $this->view_builder()->ajax_search_tags(); + } + + public function ajax_search_topics(): void { + $this->view_builder()->ajax_search_topics(); + } + + public function ajax_edit_topic(): void { + $this->topic_service()->ajax_edit_topic(); + } + + public function get_compose_capabilities(int $user_id): array { + return $this->permission_service()->get_compose_capabilities($user_id); + } + + public function sanitize_topic_content(int $user_id, string $content): string { + return $this->topic_service()->sanitize_topic_content($user_id, $content); + } + + public static function register_hide_shortcode(): void { + add_shortcode('aksrt_hide', [AKSRT_Forum_Topic_Service::class, 'render_hide_shortcode']); + } + + public function publish_draft(int $user_id, int $draft_id, array $data) { + return $this->draft_service()->publish_draft($user_id, $draft_id, $data); + } + + // + // 四级层级(对标 zibll): + // TIER_FORUM_ADMIN (4) — bbs_admin_users + manage_options + // TIER_CAT_MOD (3) — aksrt_plate_cat term meta 'moderator' + // TIER_PLATE_AUTHOR(2) — plate CPT post_author(超级版主) + // TIER_MODERATOR (1) — plate CPT post meta '_aksrt_moderators' list + // + // 公开 API: + // forum_user_can( $uid, $cap, $context_id = 0 ): bool + // get_user_plate_tier( $uid, $plate_id ): int + // flush_cap_cache( $uid = 0 ): void + + public function get_user_plate_tier(int $user_id, int $plate_id): int { + return $this->permission_service()->get_user_plate_tier($user_id, $plate_id); + } + + public function forum_user_can(int $user_id, string $cap, int $context_id = 0): bool { + return $this->permission_service()->forum_user_can($user_id, $cap, $context_id); + } + + public function flush_cap_cache(int $user_id = 0): void { + $this->permission_service()->flush_cap_cache($user_id); + } + + public function cap_cache_on_login(string $user_login, \WP_User $user): void { + $this->permission_service()->cap_cache_on_login($user_login, $user); + } + + public function cap_cache_on_plate_meta(int $meta_id, int $object_id, string $meta_key): void { + $this->permission_service()->cap_cache_on_plate_meta($meta_id, $object_id, $meta_key); + } + + public function cap_cache_on_cat_meta(int $meta_id, int $object_id, string $meta_key): void { + $this->permission_service()->cap_cache_on_cat_meta($meta_id, $object_id, $meta_key); + } + + public function cap_cache_on_plate_save(int $post_id): void { + $this->permission_service()->cap_cache_on_plate_save($post_id); + } +} diff --git a/inc/classes/class-aksrt-front-submit.php b/inc/classes/class-aksrt-front-submit.php new file mode 100644 index 00000000..8be00b85 --- /dev/null +++ b/inc/classes/class-aksrt-front-submit.php @@ -0,0 +1,391 @@ +roles; + if (empty(array_intersect($user_roles, (array) $allowed_roles))) { + // Also allow administrators + if (!in_array('administrator', $user_roles, true)) { + return false; + } + } + } + + // Credits threshold: if configured, user must have enough credits + $min_credits = (int) ($submit_settings['submit_min_credits'] ?? 0); + if ($min_credits > 0 && class_exists('AKSRT_Credits')) { + $balance = AKSRT_Credits::balance($user_id); + if ($balance < $min_credits) { + return false; + } + } + + // Default: allow users with edit_posts capability (or any logged-in user if submission is enabled) + return user_can($user_id, 'edit_posts') || !empty($submit_settings['submit_enabled']); + } + + /** + * 提交文章 + * + * @param array $data 投稿数据 + * @return array{success: bool, post_id?: int, message: string} + */ + public function submit(array $data): array { + $user_id = get_current_user_id(); + if (!$this->can_submit($user_id)) { + return ['success' => false, 'message' => __('没有投稿权限', 'aksrt')]; + } + + $title = sanitize_text_field((string) ($data['title'] ?? '')); + $content = wp_kses_post((string) ($data['content'] ?? '')); + $category = (int) ($data['category'] ?? 0); + $tags = sanitize_text_field((string) ($data['tags'] ?? '')); + $status = sanitize_key((string) ($data['status'] ?? 'pending')); + + if ($title === '') { + return ['success' => false, 'message' => __('标题不能为空', 'aksrt')]; + } + + $valid_statuses = ['pending', 'draft', 'publish']; + if (!in_array($status, $valid_statuses, true)) { + $status = 'pending'; + } + + // Determine review policy: if review is not required and user can publish, auto-publish + if ($status === 'pending') { + $submit_settings = []; + if (class_exists('AKSRT_Settings')) { + $submit_settings = AKSRT_Settings::get_value('post_list.submit', []); + } + $review_required = $submit_settings['review_required'] ?? true; + $skip_moderation = false; + if (class_exists('AKSRT_Settings')) { + $all = AKSRT_Settings::get_all(); + $skip_moderation = !empty($all['skip_submit_moderation']); + } + + if (!$review_required || $skip_moderation) { + // Auto-publish if user has publish_posts capability + if (user_can($user_id, 'publish_posts')) { + $status = 'publish'; + } + } + } + + $post_data = [ + 'post_title' => $title, + 'post_content' => $content, + 'post_status' => $status, + 'post_author' => $user_id, + 'post_type' => 'post', + ]; + + $post_id = wp_insert_post($post_data, true); + + if (is_wp_error($post_id)) { + return ['success' => false, 'message' => $post_id->get_error_message()]; + } + + // 设置分类 + if ($category > 0) { + wp_set_post_categories($post_id, [$category]); + } + + // 设置标签 + if ($tags !== '') { + $tag_names = array_map('trim', explode(',', $tags)); + wp_set_post_tags($post_id, $tag_names); + } + + // 设置封面图 + $thumbnail_url = sanitize_url((string) ($data['thumbnail'] ?? '')); + if ($thumbnail_url !== '') { + $this->set_thumbnail_from_url($post_id, $thumbnail_url); + } + + // 设置专题 (topics taxonomy) + $this->apply_topics_and_filters($post_id, $data); + + return [ + 'success' => true, + 'post_id' => $post_id, + 'message' => $status === 'draft' ? __('草稿已保存', 'aksrt') : __('投稿已提交,等待审核', 'aksrt'), + ]; + } + + /** + * 更新文章 + */ + public function update(int $post_id, array $data): array { + $user_id = get_current_user_id(); + if ($user_id <= 0) { + return ['success' => false, 'message' => __('请先登录', 'aksrt')]; + } + + $post = get_post($post_id); + if (!$post || (int) $post->post_author !== $user_id) { + return ['success' => false, 'message' => __('没有编辑权限', 'aksrt')]; + } + + $update_data = ['ID' => $post_id]; + + if (isset($data['title'])) { + $update_data['post_title'] = sanitize_text_field($data['title']); + } + if (isset($data['content'])) { + $update_data['post_content'] = wp_kses_post($data['content']); + } + if (isset($data['status'])) { + $update_data['post_status'] = sanitize_key($data['status']); + } + + $result = wp_update_post($update_data, true); + + if (is_wp_error($result)) { + return ['success' => false, 'message' => $result->get_error_message()]; + } + + // Update category + $category = (int) ($data['category'] ?? 0); + if ($category > 0) { + wp_set_post_categories($post_id, [$category]); + } + + // Update tags + if (isset($data['tags'])) { + $tags = sanitize_text_field((string) $data['tags']); + if ($tags !== '') { + $tag_names = array_map('trim', explode(',', $tags)); + wp_set_post_tags($post_id, $tag_names); + } else { + wp_set_post_tags($post_id, []); + } + } + + // Update thumbnail + if (isset($data['thumbnail'])) { + $thumbnail_url = sanitize_url((string) $data['thumbnail']); + if ($thumbnail_url !== '') { + $this->set_thumbnail_from_url($post_id, $thumbnail_url); + } + } + + // Update topics + custom filter meta + $this->apply_topics_and_filters($post_id, $data); + + return ['success' => true, 'post_id' => $post_id, 'message' => __('已更新', 'aksrt')]; + } + + /** + * 从 URL 设置封面图 + * + * Downloads the image from the given URL, sideloads it into the media library, + * and sets it as the post's featured image. + * + * @param int $post_id Post ID. + * @param string $url Image URL. + */ + private function set_thumbnail_from_url(int $post_id, string $url): void { + if ($url === '' || !filter_var($url, FILTER_VALIDATE_URL)) { + return; + } + + // Require media handling functions + if (!function_exists('media_sideload_image')) { + require_once ABSPATH . 'wp-admin/includes/media.php'; + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/image.php'; + } + + // Check if URL is an image + $ext = strtolower(pathinfo(parse_url($url, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION)); + $allowed_exts = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg']; + if ($ext !== '' && !in_array($ext, $allowed_exts, true)) { + return; + } + + // Sideload the image + $attachment_id = media_sideload_image($url, $post_id, '', 'id'); + + if (!is_wp_error($attachment_id)) { + set_post_thumbnail($post_id, $attachment_id); + } elseif (defined('WP_DEBUG') && WP_DEBUG) { + error_log(sprintf('[AKSRT Front Submit] Failed to set thumbnail for post %d: %s — %s', $post_id, $url, $attachment_id->get_error_message())); + } + } + + /** + * AJAX 提交投稿 + */ + public function ajax_submit_post(): void { + check_ajax_referer('aksrt_submit', '_nonce'); + + $data = $_POST; + $result = $this->submit($data); + + if ($result['success']) { + wp_send_json_success($result); + } + + wp_send_json_error($result); + } + + /** + * AJAX 保存草稿 + */ + public function ajax_save_draft(): void { + check_ajax_referer('aksrt_submit', '_nonce'); + + $_POST['status'] = 'draft'; + $data = $_POST; + $result = $this->submit($data); + + if ($result['success']) { + wp_send_json_success($result); + } + + wp_send_json_error($result); + } + + /** + * AJAX 更新文章 + */ + public function ajax_update_post(): void { + check_ajax_referer('aksrt_submit', '_nonce'); + + $post_id = (int) ($_POST['post_id'] ?? 0); + if ($post_id <= 0) { + wp_send_json_error(['message' => __('文章 ID 无效', 'aksrt')]); + } + + $result = $this->update($post_id, $_POST); + + if ($result['success']) { + wp_send_json_success($result); + } + + wp_send_json_error($result); + } + + /** + * AJAX handler: delete draft + */ + public function ajax_delete_draft(): void { + check_ajax_referer('aksrt_submit', '_nonce'); + + $user_id = get_current_user_id(); + if ($user_id <= 0) { + wp_send_json_error(['message' => __('请先登录', 'aksrt')]); + } + + // Fix: JS sends 'draft_id', not 'post_id' + $post_id = (int) (($_POST['draft_id'] ?? $_POST['post_id']) ?? 0); + if ($post_id <= 0) { + wp_send_json_error(['message' => __('草稿 ID 无效', 'aksrt')]); + } + + $post = get_post($post_id); + if (!$post || (int) $post->post_author !== $user_id) { + wp_send_json_error(['message' => __('没有删除权限', 'aksrt')]); + } + + if ($post->post_status !== 'draft') { + wp_send_json_error(['message' => __('只能删除草稿', 'aksrt')]); + } + + $result = wp_delete_post($post_id, true); + if ($result) { + wp_send_json_success(['message' => __('草稿已删除', 'aksrt')]); + } + + wp_send_json_error(['message' => __('删除失败', 'aksrt')]); + } + + /** + * Apply topics taxonomy terms and custom filter meta values to a post. + * + * Reads 'topics' array and 'cf_{dim_key}' scalar values from $data. + * Custom filter keys are validated against the configured dimension definitions. + * + * @param int $post_id Post ID. + * @param array $data Submission data ($_POST). + */ + private function apply_topics_and_filters(int $post_id, array $data): void { + // Topics taxonomy + if (isset($data['topics']) && is_array($data['topics']) && taxonomy_exists('topics')) { + $topic_ids = array_map('absint', $data['topics']); + $topic_ids = array_filter($topic_ids); + wp_set_object_terms($post_id, array_values($topic_ids), 'topics'); + } + + // Custom filter meta values (_aksrt_cf_{dim_key}) + foreach ($data as $key => $val) { + if (strpos($key, 'cf_') !== 0) { + continue; + } + $dim_key = sanitize_key(substr($key, 3)); + if ($dim_key === '') { + continue; + } + $cf_val = sanitize_key((string) $val); + $meta_key = '_aksrt_cf_' . $dim_key; + if ($cf_val !== '') { + update_post_meta($post_id, $meta_key, $cf_val); + } else { + delete_post_meta($post_id, $meta_key); + } + } + } +} diff --git a/inc/classes/class-aksrt-go-redirect.php b/inc/classes/class-aksrt-go-redirect.php new file mode 100644 index 00000000..a0e56b7d --- /dev/null +++ b/inc/classes/class-aksrt-go-redirect.php @@ -0,0 +1,249 @@ +get_excluded_domains(); + foreach ($excluded as $domain) { + $domain = strtolower(trim($domain)); + if ($domain && (strtolower($url_host) === $domain || substr(strtolower($url_host), -strlen($domain) - 1) === '.' . $domain)) { + return false; + } + } + + return true; + } + + /** + * Generate signed go link with time-limited nonce + */ + public function create_go_link(string $url): string { + $token = wp_create_nonce('aksrt_go_' . $url); + return add_query_arg([ + 'url' => rawurlencode($url), + 'token' => $token, + ], home_url('/go/')); + } + + /** + * Handle the /go/ confirmation page + */ + public function handle_go_page(): void { + if (!get_query_var('aksrt_go_redirect')) return; + + $url = isset($_GET['url']) ? wp_unslash(rawurldecode($_GET['url'])) : ''; + $token = isset($_GET['token']) ? sanitize_text_field(wp_unslash($_GET['token'])) : ''; + + if (empty($url) || empty($token)) { + wp_die( + esc_html__('无效的跳转链接。', 'aksrt'), + esc_html__('跳转错误', 'aksrt'), + ['response' => 400] + ); + } + + // Validate nonce (gated by seo_go_link_nonce schema setting) + $require_nonce = class_exists('AKSRT_Settings') + ? !empty(AKSRT_Settings::get_value('seo_go_link_nonce', true)) + : true; + if ($require_nonce && !wp_verify_nonce($token, 'aksrt_go_' . $url)) { + wp_die( + esc_html__('链接已过期或无效,请返回原页面重新点击。', 'aksrt'), + esc_html__('链接过期', 'aksrt'), + ['response' => 403] + ); + } + + // Validate URL + $url = esc_url_raw($url); + if (empty($url) || !wp_http_validate_url($url)) { + wp_die( + esc_html__('无效的目标地址。', 'aksrt'), + esc_html__('跳转错误', 'aksrt'), + ['response' => 400] + ); + } + + // Security: must be external + if (!$this->is_external($url)) { + wp_redirect($url); + exit; + } + + // Render confirmation page + $this->render_go_page($url); + } + + /** + * Render the go confirmation page + */ + private function render_go_page(string $url): void { + $domain = wp_parse_url($url, PHP_URL_HOST) ?: $url; + $protocol = wp_parse_url($url, PHP_URL_SCHEME) ?: 'https'; + + set_query_var('aksrt_go_target_url', esc_url($url)); + set_query_var('aksrt_go_target_domain', (string) $domain); + set_query_var('aksrt_go_target_protocol', (string) $protocol); + + $template = locate_template('go.php', false, false); + if ($template) { + status_header(200); + nocache_headers(); + include $template; + exit; + } + + $safe_url = esc_url($url); + $safe_domain = esc_html($domain); + $safe_protocol = esc_html($protocol); + + wp_head(); + ?> + + > + + + + <?php esc_html_e('外部链接跳转', 'aksrt'); ?> + + + +
+

+
://
+
+
+
+ + +
+
+ + + is_enabled()) return $content; + if (is_admin()) return $content; + if (!is_singular()) return $content; + // seo_go_link_post: whether to replace links in post content (default true) + if (class_exists('AKSRT_Settings') && empty(AKSRT_Settings::get_value('seo_go_link_post', true))) { + return $content; + } + + return preg_replace_callback( + '/]*?)href=["\']([^"\']+)["\']([^>]*?)>/i', + function ($matches) { + $before = $matches[1]; + $url = $matches[2]; + $after = $matches[3]; + + // Skip if already a go link, anchor, or relative + if (strpos($url, '/go/') !== false || strpos($url, '#') === 0 || strpos($url, '/') === 0) { + return $matches[0]; + } + + // Skip if already has nofollow or is javascript + if (stripos($before . $after, 'nofollow') !== false || strpos($url, 'javascript:') === 0) { + return $matches[0]; + } + + if (!$this->is_external($url)) { + return $matches[0]; + } + + $go_url = $this->create_go_link($url); + return ''; + }, + $content + ); + } +} diff --git a/inc/classes/class-aksrt-home-modules.php b/inc/classes/class-aksrt-home-modules.php new file mode 100644 index 00000000..043894d5 --- /dev/null +++ b/inc/classes/class-aksrt-home-modules.php @@ -0,0 +1,233 @@ + 'POST', + 'callback' => [$this, 'rest_create_preview'], + 'permission_callback' => function () { + return current_user_can('manage_options'); + }, + ]); + } + + /** + * 获取并校验模块列表(含预览/默认回退逻辑)。 + */ + private static function resolve_modules(): array { + $modules = []; + + // 预览模式 + $preview_id = get_query_var(self::PREVIEW_QUERY_VAR); + if ($preview_id && current_user_can('manage_options')) { + $cached = get_transient(self::PREVIEW_TRANSIENT_PREFIX . sanitize_key((string) $preview_id)); + if (is_array($cached)) { + $modules = $cached; + } + } + + if (empty($modules) && class_exists('AKSRT_Settings')) { + $modules = AKSRT_Settings::get_homepage_modules(); + } + + // 校验:过滤无效条目,若全部无效则清空以触发默认模板回退 + if (!empty($modules)) { + $allowed = self::allowed_types(); + $valid = array_filter($modules, function ($row) use ($allowed) { + return is_array($row) && !empty($row['type']) && in_array($row['type'], $allowed, true); + }); + $modules = empty($valid) ? [] : array_values($valid); + } + + if (empty($modules)) { + $modules = self::get_default_template('resource_standard'); + } + + return $modules; + } + + /** + * 渲染入口(首页 CMS 形态调用)。 + * + * 优先级: + * 1. ?aksrt_preview={id} 且管理员 → 读 transient 渲染 + * 2. option homepage_modules + * 3. 若空 → 渲染默认模板(resource_standard) + */ + public static function render(): void { + $modules = self::resolve_modules(); + + echo '
'; + foreach ($modules as $idx => $row) { + if (!is_array($row) || empty($row['type'])) continue; + $type = sanitize_key((string) $row['type']); + $config = isset($row['config']) && is_array($row['config']) ? $row['config'] : []; + + set_query_var('aksrt_module_config', $config); + set_query_var('aksrt_module_index', $idx); + + get_template_part('templates/parts/home-modules/module', $type); + } + // 清理 + set_query_var('aksrt_module_config', null); + set_query_var('aksrt_module_index', null); + echo '
'; + } + + /** + * 全屏模块类型(不受侧栏约束)。 + */ + public static function fullbleed_types(): array { + return ['hero']; + } + + /** + * 分区渲染:仅渲染指定区域的模块。 + * + * @param string $zone 'fullbleed' | 'body' + */ + public static function render_zone(string $zone = 'body'): void { + $modules = self::resolve_modules(); + + $fullbleed = self::fullbleed_types(); + $filtered = array_filter($modules, function ($row) use ($fullbleed, $zone) { + $type = $row['type'] ?? ''; + $is_fullbleed = in_array($type, $fullbleed, true); + return $zone === 'fullbleed' ? $is_fullbleed : !$is_fullbleed; + }); + + if (empty($filtered)) return; + + $wrapper_class = $zone === 'fullbleed' ? 'home-modules home-modules--fullbleed' : 'home-modules'; + echo '
'; + foreach ($filtered as $idx => $row) { + if (!is_array($row) || empty($row['type'])) continue; + $type = sanitize_key((string) $row['type']); + $config = isset($row['config']) && is_array($row['config']) ? $row['config'] : []; + + set_query_var('aksrt_module_config', $config); + set_query_var('aksrt_module_index', $idx); + + get_template_part('templates/parts/home-modules/module', $type); + } + set_query_var('aksrt_module_config', null); + set_query_var('aksrt_module_index', null); + echo '
'; + } + + /** + * 创建预览会话。 + */ + public function rest_create_preview(WP_REST_Request $request) { + $params = $request->get_json_params(); + if (!is_array($params)) $params = $request->get_params(); + $modules = isset($params['modules']) && is_array($params['modules']) ? $params['modules'] : []; + + $clean = []; + $allowed_types = self::allowed_types(); + foreach ($modules as $row) { + if (!is_array($row)) continue; + $type = isset($row['type']) ? sanitize_key((string) $row['type']) : ''; + if (!in_array($type, $allowed_types, true)) continue; + $clean[] = [ + 'type' => $type, + 'config' => isset($row['config']) && is_array($row['config']) ? $row['config'] : [], + ]; + } + + $id = wp_generate_uuid4(); + set_transient(self::PREVIEW_TRANSIENT_PREFIX . $id, $clean, 30 * MINUTE_IN_SECONDS); + + return new WP_REST_Response([ + 'preview_id' => $id, + 'preview_url' => add_query_arg(self::PREVIEW_QUERY_VAR, $id, home_url('/')), + 'expires_in' => 30 * MINUTE_IN_SECONDS, + ], 200); + } + + public static function allowed_types(): array { + return [ + 'hero', 'banner', 'category', 'tabs', 'ranking', + 'recent', 'tag_cloud', 'friend_links', 'html', 'hotspot', + 'category_recommend_cards', + // Phase 5: 运营型模块 + 'text_title', 'button_group', 'spacing', 'faq', + 'data_grid', 'highlight_cards', 'partner_marquee', 'product_showcase', + // Widget-区等价模块(补齐 zibll widget-user / widget-more 能力) + 'user', 'more', + ]; + } + + /** + * 默认模板(3 套预设)。 + */ + public static function get_default_template(string $name): array { + switch ($name) { + case 'news': + return [ + ['type' => 'banner', 'config' => []], + ['type' => 'category', 'config' => ['count' => 8, 'columns' => 4]], + ['type' => 'category', 'config' => ['count' => 8, 'columns' => 4]], + ['type' => 'tag_cloud', 'config' => ['limit' => 30]], + ]; + + case 'mixed': + return [ + ['type' => 'hero', 'config' => []], + ['type' => 'recent', 'config' => ['post_type' => 'post', 'count' => 6, 'layout' => 'card']], + ['type' => 'category', 'config' => ['count' => 6, 'columns' => 3]], + ['type' => 'category', 'config' => ['count' => 6, 'columns' => 3]], + ]; + + case 'resource_standard': + default: + return [ + ['type' => 'hero', 'config' => []], + ['type' => 'tabs', 'config' => ['count' => 8]], + ['type' => 'recent', 'config' => ['post_type' => 'aksrt_resource', 'count' => 8, 'layout' => 'grid-cms']], + ['type' => 'ranking', 'config' => ['metric' => 'downloads', 'count' => 6, 'post_type' => 'aksrt_resource']], + ]; + } + } +} diff --git a/inc/classes/class-aksrt-invite.php b/inc/classes/class-aksrt-invite.php new file mode 100644 index 00000000..8bdc98fa --- /dev/null +++ b/inc/classes/class-aksrt-invite.php @@ -0,0 +1,617 @@ + user_id + * + * 注意:option 大小受限(默认非 autoload),10 万级用户没问题, + * 百万级仍可走,性能瓶颈在 update_option 写入;此处采用懒写策略: + * 仅在 get_or_create_code 生成新码时同步写入。 + */ + const OPT_REVERSE_MAP = 'aksrt_invite_code_map'; + + private static $instance = null; + + private function __construct() {} + + public static function get_instance(): self { + if (null === self::$instance) self::$instance = new self(); + return self::$instance; + } + + public function init(): void { + // 在请求最早期捕获邀请码 + add_action('init', [$this, 'capture_invite_cookie'], 1); + // 注册时记录邀请关系 + add_action('user_register', [$this, 'on_user_register'], 20, 1); + // 邮箱验证后兑现 + add_action('aksrt_email_verified', [$this, 'on_email_verified'], 10, 1); + // 用户删除时清理邀请码映射 + add_action('delete_user', [$this, 'on_delete_user'], 10, 1); + + add_action('rest_api_init', [$this, 'register_routes']); + } + + public function register_routes(): void { + register_rest_route('aksrt/v1', '/invite/me', [ + 'methods' => 'GET', + 'callback' => [$this, 'rest_me'], + 'permission_callback' => function () { + return is_user_logged_in(); + }, + ]); + register_rest_route('aksrt/v1', '/invite/list', [ + 'methods' => 'GET', + 'callback' => [$this, 'rest_list'], + 'permission_callback' => function () { + return is_user_logged_in(); + }, + ]); + } + + /** 捕获 ?invite=XYZ */ + public function capture_invite_cookie(): void { + if (is_admin()) return; + if (empty($_GET['invite'])) return; + + // 邀请注册功能关闭时不处理 + if (class_exists('AKSRT_Settings') && !AKSRT_Settings::invite_registration_enabled()) return; + + $code = sanitize_key((string) $_GET['invite']); + if ($code === '') return; + + // 校验邀请码是否对应真实用户 + $inviter_id = $this->resolve_code($code); + if ($inviter_id <= 0) return; + + // 已登录用户不能用自己的邀请码 + if (is_user_logged_in() && $inviter_id === get_current_user_id()) return; + + // 不覆盖已存在的(防多次访问被覆盖) + if (!empty($_COOKIE[self::COOKIE_NAME])) return; + + $expires = time() + DAY_IN_SECONDS * self::COOKIE_TTL_DAYS; + // 不覆盖 secure 标记的检测 + $secure = is_ssl(); + // headers_sent 检查 + if (!headers_sent()) { + setcookie(self::COOKIE_NAME, $code, [ + 'expires' => $expires, + 'path' => COOKIEPATH ?: '/', + 'domain' => COOKIE_DOMAIN, + 'secure' => $secure, + 'httponly' => true, + 'samesite' => 'Lax', + ]); + $_COOKIE[self::COOKIE_NAME] = $code; + } + } + + /** 注册:写入 aksrt_invite 表 */ + public function on_user_register(int $invitee_id): void { + // 邀请注册功能关闭时不记录邀请关系 + if (class_exists('AKSRT_Settings') && !AKSRT_Settings::invite_registration_enabled()) return; + + // 优先级 1:表单显式填写的邀请码(由 AKSRT_Auth_Service 注入) + $code = ''; + $inviter_id = 0; + if (class_exists('AKSRT_Auth_Service')) { + $ctx = AKSRT_Auth_Service::take_pending_invite_context(); + if (!empty($ctx['inviter_id']) && $ctx['inviter_id'] !== $invitee_id) { + $inviter_id = (int) $ctx['inviter_id']; + $code = (string) $ctx['code']; + } + } + + // 优先级 2:fallback 到 cookie + if ($inviter_id <= 0) { + if (empty($_COOKIE[self::COOKIE_NAME])) return; + $code = sanitize_key((string) $_COOKIE[self::COOKIE_NAME]); + if ($code === '') return; + $inviter_id = $this->get_user_by_code($code); + } + + if ($inviter_id <= 0 || $inviter_id === $invitee_id) return; + + $this->record_invite_registration($inviter_id, $invitee_id, $code, 'registered'); + + // 清 cookie,避免再次触发 + if (!headers_sent()) { + setcookie(self::COOKIE_NAME, '', time() - 3600, COOKIEPATH ?: '/', COOKIE_DOMAIN); + } + + // 若配置不要求邮箱验证就立即兑现 + $invite_cfg = class_exists('AKSRT_Settings') + ? AKSRT_Settings::get_invite_reward_settings() + : [ + 'require_verify' => true, + ]; + if (empty($invite_cfg['require_verify'])) { + $this->reward_pair($inviter_id, $invitee_id); + } + } + + /** 邮箱验证后兑现奖励(钩子由 AKSRT_Auth_Service 触发,可后续接入) */ + public function on_email_verified(int $user_id): void { + // 邀请注册功能关闭时不兑现奖励 + if (class_exists('AKSRT_Settings') && !AKSRT_Settings::invite_registration_enabled()) return; + + global $wpdb; + $table = AKSRT_DB_Migrator::table('invite'); + if (!$table) return; + + $row = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM {$table} WHERE invitee_id = %d AND status IN ('registered', 'verified') LIMIT 1", + $user_id + ), ARRAY_A); + if (!$row) return; + + $wpdb->update( + $table, + ['status' => 'verified'], + ['id' => (int) $row['id']], + ['%s'], + ['%d'] + ); + + if ((int) $row['inviter_id'] <= 0) { + return; + } + + $this->reward_pair((int) $row['inviter_id'], $user_id); + } + + /** 用户被删除时清理邀请码映射 */ + public function on_delete_user(int $user_id): void { + $code = (string) get_user_meta($user_id, self::META_INVITE_CODE, true); + if ($code === '') return; + + $map = $this->load_reverse_map(); + if (isset($map[$code]) && (int) $map[$code] === $user_id) { + unset($map[$code]); + update_option(self::OPT_REVERSE_MAP, $map, false); + } + } + + /** 双向发放奖励 */ + private function reward_pair(int $inviter_id, int $invitee_id): void { + if (!class_exists('AKSRT_Credits') || !class_exists('AKSRT_Settings')) return; + + $invite_cfg = AKSRT_Settings::get_invite_reward_settings(); + if (empty($invite_cfg['enabled'])) return; + + // 反作弊:同 IP 当日上限 + if (!$this->ip_daily_check($invite_cfg)) return; + + $inviter_amount = (int) ($invite_cfg['inviter_amount'] ?? 0); + $invitee_amount = (int) ($invite_cfg['invitee_amount'] ?? 0); + + if ($inviter_amount > 0) { + AKSRT_Credits::add($inviter_id, $inviter_amount, 'invite', ['type' => 'user', 'id' => $invitee_id]); + } + if ($invitee_amount > 0) { + AKSRT_Credits::add($invitee_id, $invitee_amount, 'invitee', ['type' => 'user', 'id' => $inviter_id]); + } + + // 标记 rewarded + global $wpdb; + $table = AKSRT_DB_Migrator::table('invite'); + if ($table) { + $wpdb->update( + $table, + [ + 'status' => 'rewarded', + 'rewarded_at' => current_time('mysql', true), + ], + [ + 'inviter_id' => $inviter_id, + 'invitee_id' => $invitee_id, + ], + ['%s', '%s'], + ['%d', '%d'] + ); + } + } + + /** 同 IP 当日邀请兑现次数限制 */ + private function ip_daily_check(array $invite_cfg): bool { + $cap = (int) ($invite_cfg['ip_daily_cap'] ?? 3); + if ($cap <= 0) return true; + + $ip = function_exists('aksrt_get_client_ip') ? aksrt_get_client_ip() : ''; + if (!$ip) return true; + + global $wpdb; + $log_table = AKSRT_DB_Migrator::table('credit_log'); + if (!$log_table) return true; + + $today_utc = (new DateTimeImmutable('today', wp_timezone()))->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s'); + $count = (int) $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM {$log_table} WHERE reason = 'invite' AND ip = %s AND created_at >= %s", + $ip, + $today_utc + )); + return $count < $cap; + } + + /** 取或生成用户的邀请码 */ + public function get_or_create_code(int $user_id): string { + $code = (string) get_user_meta($user_id, self::META_INVITE_CODE, true); + + // 邀请码长度(后台可配置,4-12,默认 6) + $code_length = class_exists('AKSRT_Settings') + ? AKSRT_Settings::invite_code_length() + : max(4, min(12, (int) aksrt_get_setting('invite_code_length', 6))); + + // 兼容配置变更:已有码只要长度合理就复用 + if ($code !== '' && strlen($code) <= $code_length + 2) return $code; + + // 生成唯一码 + $alphabet = 'abcdefghjkmnpqrstuvwxyz23456789'; + $map = $this->load_reverse_map(); + for ($i = 0; $i < 5; $i++) { + $code = ''; + for ($j = 0; $j < $code_length; $j++) { + $code .= $alphabet[random_int(0, strlen($alphabet) - 1)]; + } + if (!isset($map[$code]) && !$this->code_exists_legacy($code)) break; + } + update_user_meta($user_id, self::META_INVITE_CODE, $code); + + // 同步反向映射 + $map[$code] = $user_id; + update_option(self::OPT_REVERSE_MAP, $map, false); + + return $code; + } + + /** + * 公开 API:根据邀请码反查邀请人 user_id(不存在返回 0)。 + * 给注册流程在 wp_create_user 之前做"邀请码是否合法"的预校验用。 + */ + public function resolve_code(string $code): int { + $code = sanitize_key($code); + if ($code === '') return 0; + return $this->get_user_by_code($code); + } + + /** + * 校验邀请码是否合法。 + * + * 用户邀请码与后台批量生成的独立邀请码都视为有效。 + */ + public function validate_code(string $code): bool { + $code = sanitize_key($code); + if ($code === '') { + return false; + } + + if ($this->get_user_by_code($code) > 0) { + return true; + } + + return $this->find_standalone_code_index($code) >= 0; + } + + /** + * 注册完成后处理邀请码。 + * + * - 用户邀请码:沿用 inviter/invitee canonical 关系 + * - 独立邀请码:记录使用人并标记为已消耗,不参与奖励 + */ + public function handle_registration_invite(int $invitee_id, string $invite_code): void { + $invitee_id = (int) $invitee_id; + $invite_code = sanitize_key($invite_code); + + if ($invitee_id <= 0 || $invite_code === '') { + return; + } + + if (class_exists('AKSRT_Settings') && !AKSRT_Settings::invite_registration_enabled()) { + return; + } + + $inviter_id = $this->get_user_by_code($invite_code); + if ($inviter_id > 0) { + if ($inviter_id === $invitee_id) { + return; + } + + $this->record_invite_registration($inviter_id, $invitee_id, $invite_code, 'registered'); + return; + } + + $index = $this->find_standalone_code_index($invite_code); + if ($index < 0) { + return; + } + + $requires_verify = class_exists('AKSRT_Settings') && AKSRT_Settings::registration_requires_email_verification(); + $status = $requires_verify ? 'registered' : 'verified'; + + if ($this->record_invite_registration(0, $invitee_id, $invite_code, $status)) { + $this->mark_standalone_code_used($invite_code, $invitee_id); + } + } + + /** 反查邀请码对应的用户(O(1) option 查表,回退 user_meta 扫描) */ + private function get_user_by_code(string $code): int { + if ($code === '') return 0; + + $map = $this->load_reverse_map(); + if (isset($map[$code])) { + $uid = (int) $map[$code]; + if ($uid > 0) { + // 校验:确保该用户的 meta 仍然指向该 code(用户可能已被删除或 meta 被清空) + $meta_code = (string) get_user_meta($uid, self::META_INVITE_CODE, true); + if ($meta_code === $code) { + return $uid; + } + // 失配 → 修正映射 + unset($map[$code]); + update_option(self::OPT_REVERSE_MAP, $map, false); + } + } + + // 回退 + 自愈:扫描 user_meta(应只在迁移期或映射损坏时触发) + $uid = $this->code_exists_legacy($code); + if ($uid > 0) { + $map[$code] = $uid; + update_option(self::OPT_REVERSE_MAP, $map, false); + } + return $uid; + } + + private function load_reverse_map(): array { + $map = get_option(self::OPT_REVERSE_MAP, []); + return is_array($map) ? $map : []; + } + + /** + * 写入邀请关系记录。 + */ + private function record_invite_registration(int $inviter_id, int $invitee_id, string $code, string $status = 'registered'): bool { + global $wpdb; + + $table = AKSRT_DB_Migrator::table('invite'); + if (!$table || $invitee_id <= 0 || $code === '') { + return false; + } + + $existing_code = $wpdb->get_var($wpdb->prepare( + "SELECT id FROM {$table} WHERE invite_code = %s LIMIT 1", + $code + )); + if ($existing_code) { + return false; + } + + $existing_invitee = $wpdb->get_var($wpdb->prepare( + "SELECT id FROM {$table} WHERE invitee_id = %d LIMIT 1", + $invitee_id + )); + if ($existing_invitee) { + return false; + } + + $result = $wpdb->insert( + $table, + [ + 'inviter_id' => max(0, $inviter_id), + 'invitee_id' => $invitee_id, + 'invite_code' => $code, + 'status' => $status, + 'created_at' => current_time('mysql', true), + ], + ['%d', '%d', '%s', '%s', '%s'] + ); + + return (bool) $result; + } + + private function load_standalone_codes(): array { + $codes = get_option(self::OPT_STANDALONE_CODES, []); + return is_array($codes) ? array_values($codes) : []; + } + + private function save_standalone_codes(array $codes): void { + update_option(self::OPT_STANDALONE_CODES, array_values($codes), false); + } + + private function normalize_standalone_code(array $entry): array { + $code = sanitize_key((string) ($entry['code'] ?? '')); + $used_by = (int) ($entry['used_by'] ?? 0); + $used = !empty($entry['used']) || $used_by > 0; + + return [ + 'code' => $code, + 'created_at' => sanitize_text_field((string) ($entry['created_at'] ?? '')), + 'used' => $used, + 'used_by' => $used_by, + 'used_at' => sanitize_text_field((string) ($entry['used_at'] ?? '')), + 'disabled' => !empty($entry['disabled']), + 'note' => sanitize_textarea_field((string) ($entry['note'] ?? '')), + 'batch_id' => sanitize_text_field((string) ($entry['batch_id'] ?? '')), + 'created_by' => (int) ($entry['created_by'] ?? 0), + ]; + } + + private function find_standalone_code_index(string $code): int { + $code = sanitize_key($code); + $codes = $this->load_standalone_codes(); + + foreach ($codes as $index => $entry) { + $normalized = $this->normalize_standalone_code((array) $entry); + if ($normalized['code'] !== $code) { + continue; + } + + if ($normalized['disabled'] || $normalized['used']) { + return -1; + } + + return $index; + } + + return -1; + } + + private function mark_standalone_code_used(string $code, int $invitee_id): void { + $code = sanitize_key($code); + $codes = $this->load_standalone_codes(); + + foreach ($codes as $index => $entry) { + $normalized = $this->normalize_standalone_code((array) $entry); + if ($normalized['code'] !== $code) { + continue; + } + + $normalized['used'] = true; + $normalized['used_by'] = $invitee_id; + $normalized['used_at'] = current_time('mysql', true); + $codes[$index] = $normalized; + $this->save_standalone_codes($codes); + return; + } + } + + /** 旧路径:通过 user_meta 反查(保留作为自愈兜底) */ + private function code_exists_legacy(string $code): int { + $users = get_users([ + 'meta_key' => self::META_INVITE_CODE, + 'meta_value' => $code, + 'number' => 1, + 'fields' => 'ID', + ]); + if (empty($users)) return 0; + return (int) $users[0]; + } + + private function code_exists(string $code): bool { + return $this->get_user_by_code($code) > 0; + } + + // ---------- REST ---------- + + public function rest_me(WP_REST_Request $request) { + if (class_exists('AKSRT_Settings') && !AKSRT_Settings::invite_registration_enabled()) { + return new WP_REST_Response(['code' => 'error', 'message' => '邀请注册功能已关闭'], 403); + } + + $user_id = get_current_user_id(); + $code = $this->get_or_create_code($user_id); + + global $wpdb; + $table = AKSRT_DB_Migrator::table('invite'); + $stats = [ + 'registered' => 0, + 'verified' => 0, + 'rewarded' => 0, + ]; + if ($table) { + $rows = $wpdb->get_results($wpdb->prepare( + "SELECT status, COUNT(*) AS c FROM {$table} WHERE inviter_id = %d GROUP BY status", + $user_id + ), ARRAY_A); + if (is_array($rows)) { + foreach ($rows as $r) { + $status = (string) $r['status']; + $count = (int) $r['c']; + + // The stored status is terminal; dashboard stats are milestone totals. + if ($status === 'registered') { + $stats['registered'] += $count; + } elseif ($status === 'verified') { + $stats['registered'] += $count; + $stats['verified'] += $count; + } elseif ($status === 'rewarded') { + $stats['registered'] += $count; + $stats['verified'] += $count; + $stats['rewarded'] += $count; + } + } + } + } + + $invite_url = add_query_arg('invite', $code, aksrt_get_register_url()); + + return new WP_REST_Response([ + 'code' => $code, + 'invite_url' => $invite_url, + 'stats' => $stats, + ], 200); + } + + public function rest_list(WP_REST_Request $request) { + if (class_exists('AKSRT_Settings') && !AKSRT_Settings::invite_registration_enabled()) { + return new WP_REST_Response(['code' => 'error', 'message' => '邀请注册功能已关闭'], 403); + } + + global $wpdb; + $user_id = get_current_user_id(); + $page = max(1, (int) $request->get_param('page')); + $per_page = min(50, max(1, (int) $request->get_param('per_page') ?: 20)); + $offset = ($page - 1) * $per_page; + + $table = AKSRT_DB_Migrator::table('invite'); + if (!$table) { + return new WP_REST_Response(['items' => [], 'total' => 0], 200); + } + + $total = (int) $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM {$table} WHERE inviter_id = %d", + $user_id + )); + $rows = $wpdb->get_results($wpdb->prepare( + "SELECT * FROM {$table} WHERE inviter_id = %d ORDER BY created_at DESC LIMIT %d OFFSET %d", + $user_id, + $per_page, + $offset + ), ARRAY_A); + + $items = []; + foreach ((array) $rows as $r) { + $invitee = $r['invitee_id'] ? get_userdata((int) $r['invitee_id']) : null; + $items[] = [ + 'id' => (int) $r['id'], + 'invitee_id' => (int) $r['invitee_id'], + 'invitee_name' => $invitee ? $invitee->display_name : '', + 'invitee_avatar' => $invitee ? get_avatar_url((int) $invitee->ID, ['size' => 64]) : '', + 'status' => (string) $r['status'], + 'created_at' => (string) $r['created_at'], + 'rewarded_at' => (string) $r['rewarded_at'], + ]; + } + + return new WP_REST_Response([ + 'items' => $items, + 'total' => $total, + 'page' => $page, + 'per_page' => $per_page, + ], 200); + } +} diff --git a/inc/classes/class-aksrt-ip-geo.php b/inc/classes/class-aksrt-ip-geo.php new file mode 100644 index 00000000..282dd90e --- /dev/null +++ b/inc/classes/class-aksrt-ip-geo.php @@ -0,0 +1,217 @@ +get_config(); + + if (empty($config['ip_geo_enabled'])) { + return $this->empty_result(); + } + + // 本地/私有 IP 跳过查询 + if ($this->is_private_ip($ip)) { + return $this->empty_result(); + } + + $cache_key = 'aksrt_ip_geo_' . md5($ip); + $ttl = max(3600, (int) ($config['ip_geo_cache_ttl'] ?? 86400)); + $cached = get_transient($cache_key); + if ($cached !== false && is_array($cached)) { + return $cached; + } + + $provider = sanitize_key((string) ($config['ip_addr_sdk'] ?? ($config['ip_geo_provider'] ?? 'null'))); + $result = $this->query_provider($provider, $ip, $config); + + if (!empty($result['province']) || !empty($result['city'])) { + set_transient($cache_key, $result, $ttl); + } + + return $result; + } + + /** + * 获取服务运行时状态 + */ + public function get_status(): array { + $config = $this->get_config(); + $provider = sanitize_key((string) ($config['ip_addr_sdk'] ?? ($config['ip_geo_provider'] ?? 'null'))); + $enabled = !empty($config['ip_geo_enabled']); + + $missing = []; + if ($enabled && $provider === 'qq') { + if (empty($config['ip_addr_sdk_qq_appkey'])) $missing[] = 'ip_addr_sdk_qq_appkey'; + if (empty($config['ip_addr_sdk_qq_secretkey'])) $missing[] = 'ip_addr_sdk_qq_secretkey'; + } + if ($enabled && $provider === 'amap') { + if (empty($config['ip_addr_sdk_amap_appkey'])) $missing[] = 'ip_addr_sdk_amap_appkey'; + if (empty($config['ip_addr_sdk_amap_secretkey'])) $missing[] = 'ip_addr_sdk_amap_secretkey'; + } + + return [ + 'enabled' => $enabled, + 'provider' => $provider, + 'configured' => $enabled && empty($missing), + 'available' => $enabled, + 'verified' => !empty($config['ip_geo_verified']), + 'transport' => 'http_api', + 'missing_fields' => $missing, + 'cache_ttl' => (int) ($config['ip_geo_cache_ttl'] ?? 86400), + 'display' => (array) ($config['ip_geo_display'] ?? ['comment']), + 'format' => (string) ($config['ip_geo_format'] ?? 'province_city'), + ]; + } + + /** + * 格式化归属地显示文本 + */ + public function format(array $geo, string $format = ''): string { + if ($format === '') { + $config = $this->get_config(); + $format = (string) ($config['ip_geo_format'] ?? 'province_city'); + } + + switch ($format) { + case 'province': + return $geo['province'] ?? ''; + case 'country_province': + return trim(($geo['country'] ?? '') . ' ' . ($geo['province'] ?? '')); + case 'province_city': + default: + $parts = array_filter([$geo['province'] ?? '', $geo['city'] ?? '']); + return implode(' ', $parts) ?: ($geo['country'] ?? ''); + } + } + + private function get_config(): array { + if (class_exists('AKSRT_Settings')) { + return AKSRT_Settings::get_ip_geo_settings(); + } + return ['ip_geo_enabled' => false]; + } + + private function query_provider(string $provider, string $ip, array $config): array { + switch ($provider) { + case 'qq': + return $this->query_qq($ip, $config); + case 'amap': + return $this->query_amap($ip, $config); + case 'polling': + foreach (['qq', 'amap', 'null'] as $fallback) { + $result = $this->query_provider($fallback, $ip, $config); + if (!empty($result['province']) || !empty($result['city']) || !empty($result['country'])) { + return $result; + } + } + return $this->empty_result(); + case 'null': + default: + return $this->query_ip_api($ip); + } + } + + private function query_qq(string $ip, array $config): array { + $appkey = trim((string) ($config['ip_addr_sdk_qq_appkey'] ?? '')); + if ($appkey === '') { + return $this->empty_result(); + } + + $url = add_query_arg([ + 'ip' => $ip, + 'key' => $appkey, + ], 'https://apis.map.qq.com/ws/location/v1/ip'); + + $response = wp_remote_get($url, [ + 'timeout' => 8, + 'headers' => ['Accept' => 'application/json'], + ]); + if (is_wp_error($response)) return $this->empty_result(); + + $data = json_decode(wp_remote_retrieve_body($response), true); + if (!is_array($data) || (int) ($data['status'] ?? 1) !== 0) return $this->empty_result(); + + $info = $data['result']['ad_info'] ?? []; + return [ + 'country' => '中国', + 'province' => (string) ($info['province'] ?? ''), + 'city' => (string) ($info['city'] ?? ''), + 'raw' => $data, + ]; + } + + private function query_amap(string $ip, array $config): array { + $appkey = trim((string) ($config['ip_addr_sdk_amap_appkey'] ?? '')); + if ($appkey === '') { + return $this->empty_result(); + } + + $url = add_query_arg([ + 'ip' => $ip, + 'key' => $appkey, + ], 'https://restapi.amap.com/v3/ip'); + + $response = wp_remote_get($url, [ + 'timeout' => 8, + 'headers' => ['Accept' => 'application/json'], + ]); + if (is_wp_error($response)) return $this->empty_result(); + + $data = json_decode(wp_remote_retrieve_body($response), true); + if (!is_array($data) || (string) ($data['status'] ?? '0') !== '1') return $this->empty_result(); + + return [ + 'country' => '中国', + 'province' => (string) ($data['province'] ?? ''), + 'city' => (string) ($data['city'] ?? ''), + 'raw' => $data, + ]; + } + + private function query_ip_api(string $ip): array { + $response = wp_remote_get("http://ip-api.com/json/{$ip}?lang=zh-CN", [ + 'timeout' => 5, + ]); + if (is_wp_error($response)) return $this->empty_result(); + + $data = json_decode(wp_remote_retrieve_body($response), true); + if (!is_array($data) || ($data['status'] ?? '') !== 'success') return $this->empty_result(); + + return [ + 'country' => (string) ($data['country'] ?? ''), + 'province' => (string) ($data['regionName'] ?? ''), + 'city' => (string) ($data['city'] ?? ''), + 'raw' => $data, + ]; + } + + private function is_private_ip(string $ip): bool { + return !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE); + } + + private function empty_result(): array { + return ['country' => '', 'province' => '', 'city' => '', 'raw' => []]; + } +} diff --git a/inc/classes/class-aksrt-meilisearch.php b/inc/classes/class-aksrt-meilisearch.php new file mode 100644 index 00000000..85e5e95d --- /dev/null +++ b/inc/classes/class-aksrt-meilisearch.php @@ -0,0 +1,284 @@ +is_configured()) return; + + add_filter('posts_search', [$this, 'override_search'], 10, 2); + add_action('aksrt_meilisearch_sync', [$this, 'sync_all_posts']); + add_action('save_post', [$this, 'on_save_post'], 10, 3); + add_action('delete_post', [$this, 'on_delete_post']); + + // Register cron schedule + add_filter('cron_schedules', function ($schedules) { + $schedules['aksrt_meilisearch_hourly'] = [ + 'interval' => HOUR_IN_SECONDS, + 'display' => __('每小时', 'aksrt'), + ]; + return $schedules; + }); + + if (!wp_next_scheduled('aksrt_meilisearch_sync')) { + wp_schedule_event(time(), 'aksrt_meilisearch_hourly', 'aksrt_meilisearch_sync'); + } + } + + /** + * Check if MeiliSearch is configured + */ + public function is_configured(): bool { + if (!class_exists('AKSRT_Settings')) return false; + $host = AKSRT_Settings::get_value('meilisearch_host', ''); + $key = AKSRT_Settings::get_value('meilisearch_api_key', ''); + return !empty($host) && !empty($key); + } + + /** + * Resolve which post types are indexed/searched. Shares the single + * data-selection contract with front search and search push. + * + * @return string[] + */ + private function indexable_post_types(): array { + if (function_exists('aksrt_searchable_post_types')) { + return aksrt_searchable_post_types(); + } + return ['post']; + } + + /** + * Get MeiliSearch config + */ + private function get_config(): array { + return [ + 'host' => (string) AKSRT_Settings::get_value('meilisearch_host', ''), + 'api_key' => (string) AKSRT_Settings::get_value('meilisearch_api_key', ''), + 'index' => (string) AKSRT_Settings::get_value('meilisearch_index', 'aksrt_posts'), + ]; + } + + /** + * Make HTTP request to MeiliSearch + */ + private function request(string $method, string $path, array $body = []): ?array { + $config = $this->get_config(); + $url = rtrim($config['host'], '/') . $path; + + $args = [ + 'method' => $method, + 'timeout' => 10, + 'headers' => [ + 'Authorization' => 'Bearer ' . $config['api_key'], + 'Content-Type' => 'application/json', + ], + ]; + + if (!empty($body)) { + $args['body'] = wp_json_encode($body); + } + + $response = wp_remote_request($url, $args); + if (is_wp_error($response)) { + return null; + } + + $code = wp_remote_retrieve_response_code($response); + if ($code < 200 || $code >= 300) { + return null; + } + + $data = json_decode(wp_remote_retrieve_body($response), true); + return is_array($data) ? $data : null; + } + + /** + * Search posts via MeiliSearch + */ + public function search(string $query, array $args = []): array { + $config = $this->get_config(); + $index = $config['index']; + + $body = [ + 'q' => $query, + 'limit' => $args['posts_per_page'] ?? get_option('posts_per_page', 10), + 'offset' => (($args['paged'] ?? 1) - 1) * ($args['posts_per_page'] ?? get_option('posts_per_page', 10)), + 'attributesToRetrieve' => ['id', 'title', 'excerpt', 'url', 'date', 'thumbnail'], + ]; + + // Filter by post type + if (!empty($args['post_type'])) { + $body['filter'] = 'post_type = ' . $args['post_type']; + } + + $result = $this->request('POST', "/indexes/{$index}/search", $body); + if (!$result) return []; + + return [ + 'hits' => $result['hits'] ?? [], + 'total' => $result['estimatedTotalHits'] ?? 0, + 'query_time' => $result['processingTimeMs'] ?? 0, + ]; + } + + /** + * Override WordPress search with MeiliSearch + */ + public function override_search(string $search, WP_Query $query): string { + if (!$query->is_search() || !$query->is_main_query()) { + return $search; + } + + $q = $query->get('s'); + if (empty($q)) return $search; + + $results = $this->search($q, [ + 'posts_per_page' => $query->get('posts_per_page'), + 'paged' => $query->get('paged') ?: 1, + 'post_type' => $query->get('post_type') ?: 'post', + ]); + + if (empty($results['hits'])) { + // No results — force empty + return 'AND 1=0'; + } + + $ids = array_map(function ($hit) { + return (int) ($hit['id'] ?? 0); + }, $results['hits']); + + $ids = array_filter($ids); + if (empty($ids)) return 'AND 1=0'; + + global $wpdb; + return 'AND ' . $wpdb->posts . '.ID IN (' . implode(',', $ids) . ')'; + } + + /** + * Index a single post + */ + public function index_post(int $post_id): bool { + $post = get_post($post_id); + if (!$post || $post->post_status !== 'publish') return false; + + $config = $this->get_config(); + $index = $config['index']; + + $doc = [ + 'id' => $post_id, + 'title' => $post->post_title, + 'excerpt' => wp_strip_all_tags(get_the_excerpt($post)), + 'content' => wp_strip_all_tags($post->post_content), + 'url' => get_permalink($post_id), + 'date' => $post->post_date, + 'post_type' => $post->post_type, + 'thumbnail' => get_the_post_thumbnail_url($post_id, 'medium') ?: '', + ]; + + $result = $this->request('PUT', "/indexes/{$index}/documents", [$doc]); + return $result !== null; + } + + /** + * Delete a post from index + */ + public function delete_post(int $post_id): bool { + $config = $this->get_config(); + $index = $config['index']; + $result = $this->request('DELETE', "/indexes/{$index}/documents/{$post_id}"); + return $result !== null; + } + + /** + * Sync all published posts + */ + public function sync_all_posts(): void { + $config = $this->get_config(); + $index = $config['index']; + + // Create index if not exists + $this->request('POST', '/indexes', ['uid' => $index, 'primaryKey' => 'id']); + + // Configure filterable attributes + $this->request('PUT', "/indexes/{$index}/settings", [ + 'filterableAttributes' => ['post_type'], + 'searchableAttributes' => ['title', 'excerpt', 'content'], + 'displayedAttributes' => ['id', 'title', 'excerpt', 'url', 'date', 'thumbnail', 'post_type'], + ]); + + $posts = get_posts([ + 'post_type' => $this->indexable_post_types(), + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'fields' => 'ids', + ]); + + $batch = []; + foreach ($posts as $post_id) { + $post = get_post($post_id); + if (!$post) continue; + + $batch[] = [ + 'id' => (int) $post_id, + 'title' => $post->post_title, + 'excerpt' => wp_strip_all_tags(get_the_excerpt($post)), + 'content' => wp_strip_all_tags($post->post_content), + 'url' => get_permalink($post_id), + 'date' => $post->post_date, + 'post_type' => $post->post_type, + 'thumbnail' => get_the_post_thumbnail_url($post_id, 'medium') ?: '', + ]; + + // Batch 100 at a time + if (count($batch) >= 100) { + $this->request('PUT', "/indexes/{$index}/documents", $batch); + $batch = []; + } + } + + if (!empty($batch)) { + $this->request('PUT', "/indexes/{$index}/documents", $batch); + } + } + + /** + * Hook: re-index on post save + */ + public function on_save_post(int $post_id, $post, bool $update): void { + if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) return; + if ($post->post_status !== 'publish') return; + if (!in_array($post->post_type, $this->indexable_post_types(), true)) return; + + // Debounce: schedule async + wp_schedule_single_event(time() + 5, 'aksrt_meilisearch_index_single', [$post_id]); + } + + /** + * Hook: remove from index on post delete + */ + public function on_delete_post(int $post_id): void { + $this->delete_post($post_id); + } +} diff --git a/inc/classes/class-aksrt-message.php b/inc/classes/class-aksrt-message.php new file mode 100644 index 00000000..13e9b160 --- /dev/null +++ b/inc/classes/class-aksrt-message.php @@ -0,0 +1,219 @@ +table(); + if ($table !== '') { + global $wpdb; + $count = (int) $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM {$table} WHERE user_id = %d AND is_read = 0", + $user_id + )); + // 同步到 user_meta 以兼容旧读取方 + update_user_meta($user_id, 'aksrt_unread_message_count', $count); + return $count; + } + + // 降级:从 user_meta 读取 + $count = (int) get_user_meta($user_id, 'aksrt_unread_message_count', true); + return max(0, $count); + } + + /** + * 标记单条通知为已读 + */ + public function mark_read(int $user_id, string $message_id): bool { + if ($user_id <= 0 || empty($message_id)) return false; + + $table = $this->table(); + if ($table !== '') { + global $wpdb; + $result = $wpdb->update( + $table, + ['is_read' => 1, 'read_at' => current_time('mysql')], + ['id' => (int) $message_id, 'user_id' => $user_id, 'is_read' => 0], + ['%d', '%s'], + ['%d', '%d', '%d'] + ); + return $result !== false; + } + + // 降级:仅更新计数 + $current = $this->get_unread_count($user_id); + if ($current > 0) { + update_user_meta($user_id, 'aksrt_unread_message_count', max(0, $current - 1)); + } + return true; + } + + /** + * 标记用户所有通知为已读 + */ + public function mark_all_read(int $user_id): bool { + if ($user_id <= 0) return false; + + $table = $this->table(); + if ($table !== '') { + global $wpdb; + $wpdb->update( + $table, + ['is_read' => 1, 'read_at' => current_time('mysql')], + ['user_id' => $user_id, 'is_read' => 0], + ['%d', '%s'], + ['%d', '%d'] + ); + } + + update_user_meta($user_id, 'aksrt_unread_message_count', 0); + return true; + } + + /** + * 发送站内通知(持久化到 aksrt_notification 表) + * + * @param int $to_user_id 接收人 ID + * @param string $type 通知类型 (comment_reply, download_ready, conversation, notice, ...) + * @param string $content 通知内容摘要 + * @param array $data 附加数据 (title, target_url, ...) + * @return bool + */ + public function send(int $to_user_id, string $type, string $content, array $data = []): bool { + if ($to_user_id <= 0 || empty($content)) return false; + + $table = $this->table(); + if ($table !== '') { + global $wpdb; + $wpdb->insert($table, [ + 'user_id' => $to_user_id, + 'type' => sanitize_key($type), + 'title' => sanitize_text_field((string) ($data['title'] ?? $content)), + 'description' => sanitize_textarea_field($content), + 'target_url' => esc_url_raw((string) ($data['target_url'] ?? '')), + 'is_read' => 0, + 'created_at' => current_time('mysql'), + ], ['%d', '%s', '%s', '%s', '%s', '%d', '%s']); + } + + // 同步更新 user_meta 未读计数 + $current = (int) get_user_meta($to_user_id, 'aksrt_unread_message_count', true); + update_user_meta($to_user_id, 'aksrt_unread_message_count', max(0, $current) + 1); + + // 触发通知钩子 + do_action('aksrt_notification_sent', $to_user_id, $type, $data); + + return true; + } + + /** + * 获取用户通知列表 + */ + public function get_notifications(int $user_id, array $args = []): array { + if ($user_id <= 0) return []; + + $table = $this->table(); + if ($table !== '') { + global $wpdb; + + $where = ['user_id = %d']; + $params = [$user_id]; + + $type = sanitize_key((string) ($args['type'] ?? '')); + if ($type !== '' && $type !== 'all') { + if ($type === 'unread') { + $where[] = 'is_read = 0'; + } else { + $where[] = 'type = %s'; + $params[] = $type; + } + } + + $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); + $offset = max(0, (int) ($args['offset'] ?? 0)); + + $where_sql = implode(' AND ', $where); + $sql = "SELECT * FROM {$table} WHERE {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d"; + $params[] = $limit; + $params[] = $offset; + + return (array) $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A); + } + + return []; + } + + /** + * 获取通知收件箱完整视图(供 page-messages.php 使用) + */ + public function get_all_notifications(int $user_id): array { + return $this->get_notifications($user_id, ['limit' => 200]); + } + + private function table(): string { + if (class_exists('AKSRT_DB_Migrator')) { + return AKSRT_DB_Migrator::table('notification'); + } + return ''; + } + + /** + * AJAX 标记已读 + */ + public function ajax_mark_read(): void { + check_ajax_referer('aksrt_message', '_nonce'); + + $user_id = get_current_user_id(); + if ($user_id <= 0) { + wp_send_json_error(['message' => __('请先登录', 'aksrt')]); + } + + $message_id = sanitize_text_field((string) ($_POST['message_id'] ?? '')); + if ($this->mark_read($user_id, $message_id)) { + wp_send_json_success(['unread' => $this->get_unread_count($user_id)]); + } + + wp_send_json_error(['message' => __('操作失败', 'aksrt')]); + } + + /** + * AJAX 获取未读数 + */ + public function ajax_get_unread_count(): void { + check_ajax_referer('aksrt_message', '_nonce'); + + $user_id = get_current_user_id(); + if ($user_id <= 0) { + wp_send_json_error(['message' => __('请先登录', 'aksrt')]); + } + + wp_send_json_success(['unread' => $this->get_unread_count($user_id)]); + } +} diff --git a/inc/classes/class-aksrt-moderation.php b/inc/classes/class-aksrt-moderation.php new file mode 100644 index 00000000..e1c4d880 --- /dev/null +++ b/inc/classes/class-aksrt-moderation.php @@ -0,0 +1,307 @@ + moderation decision payload. + * + * @var array> + */ + private $moderation_result_map = []; + + /** + * Runtime map: fingerprint => forced comment_approved value. + * + * @var array + */ + private $forced_approved_map = []; + + public static function get_instance(): self { + if (null === self::$instance) { + self::$instance = new self(); + } + return self::$instance; + } + + private function __construct() { + $this->config = get_option('aksrt_moderation_config', []); + } + + public function init(): void { + add_filter('preprocess_comment', [$this, 'process'], 5); + add_filter('pre_comment_approved', [$this, 'enforce_pre_comment_approved'], 50, 2); + add_action('comment_post', [$this, 'after_post'], 10, 3); + + // Native WP Admin > Comments column. + add_filter('manage_edit-comments_columns', [$this, 'register_comments_columns']); + add_action('manage_comments_custom_column', [$this, 'render_comments_custom_column'], 10, 2); + } + + public function get_config(): array { + $defaults = [ + 'enabled' => false, + 'akismet_enabled' => false, + 'akismet_site_url' => '', + 'akismet_blog_lang' => 'zh-CN', + 'akismet_api_key' => '', + 'ai_enabled' => false, + 'ai_moderate_comments' => true, + 'ai_moderate_background' => true, + 'auto_approve_low_risk' => true, + 'auto_reject_high_risk' => true, + 'low_risk_max_score' => 35, + 'high_risk_min_score' => 80, + 'blocked_keywords' => '', + 'rate_limit_enabled' => false, + 'rate_limit_min_interval' => 8, + 'rate_limit_max_per_hour' => 15, + 'geoip_enabled' => false, + 'geoip_provider' => 'ipapi', + 'geoip_api_key' => '', + 'geoip_block_countries' => [], + ]; + return array_merge($defaults, $this->config); + } + + /** + * Main moderation entry. + */ + public function process(array $commentdata): array { + $config = $this->get_config(); + + if (empty($config['enabled'])) { + return $commentdata; + } + + $client_ip = $this->get_client_ip(); + $decision = $this->new_default_result(); + $decision['ip'] = $client_ip; + + try { + // 1) Rate limit. + $rate_result = $this->check_rate_limit($commentdata, $config, $client_ip); + if ($rate_result !== true) { + $this->remember_result($commentdata, [ + 'result' => 'blocked', + 'source' => 'rate_limit', + 'reason' => 'rate_limit_triggered', + ]); + wp_die( + '

' . esc_html($rate_result) . '

', + __('Rate limit', 'aksrt'), + ['response' => 429] + ); + } + + // 2) GeoIP restriction. + $geoip_result = $this->check_geoip($config, $client_ip); + if ($geoip_result !== true) { + $this->remember_result($commentdata, [ + 'result' => 'blocked', + 'source' => 'geoip', + 'reason' => 'geoip_blocked', + ]); + wp_die( + '

' . esc_html($geoip_result) . '

', + __('Geo restriction', 'aksrt'), + ['response' => 403] + ); + } + + // 3) Blocked keywords: force pending and write AI-like reason. + $keyword = $this->check_blocked_keywords($commentdata, $config); + $keyword_hit = !empty($keyword['hit']); + if ($keyword_hit) { + $commentdata['comment_approved'] = 0; + $decision['result'] = 'pending'; + $decision['source'] = 'ai'; + $decision['reason'] = 'keyword_hit'; + $decision['ai_flagged'] = '1'; + $decision['ai_score'] = '100'; + $decision['ai_top_category'] = 'blocked_keyword'; + $decision['keyword'] = (string) ($keyword['keyword'] ?? ''); + $decision['keyword_match_type'] = (string) ($keyword['match_type'] ?? 'plain'); + } + + // 4) Akismet. + $akismet = $this->check_akismet($commentdata, $config, $client_ip); + if ($akismet === 'spam') { + $commentdata['comment_approved'] = 'spam'; + $decision['result'] = 'spam'; + $decision['source'] = 'akismet'; + $decision['reason'] = 'spam'; + $decision['akismet'] = 'spam'; + } elseif ($akismet === 'ham') { + $decision['akismet'] = 'ham'; + if ($decision['source'] === 'wp_default') { + $decision['source'] = 'akismet'; + $decision['reason'] = 'ham'; + } + } elseif ($akismet === 'error') { + $decision['akismet'] = 'error'; + } else { + $decision['akismet'] = 'unknown'; + } + + // 5) AI moderation provider (Baidu only). + // If keyword already hit, keep keyword decision locked and skip AI override. + if (!$keyword_hit) { + $ai = $this->check_ai_moderation($commentdata, $config); + if (is_array($ai)) { + $score = (float) ($ai['score'] ?? 0); + $flagged = !empty($ai['flagged']) ? '1' : '0'; + $top_category = (string) ($ai['top_category'] ?? ''); + + $decision['ai_score'] = (string) round($score, 2); + $decision['ai_flagged'] = $flagged; + $decision['ai_top_category'] = $top_category; + + if (!empty($config['auto_reject_high_risk']) && $score >= (float) ($config['high_risk_min_score'] ?? 80)) { + // High-risk comments are auto rejected into spam list. + $commentdata['comment_approved'] = 'spam'; + $decision['result'] = 'spam'; + $decision['source'] = 'ai'; + $decision['reason'] = 'ai_high_risk_auto_reject'; + } elseif (!empty($config['auto_approve_low_risk']) && $score <= (float) ($config['low_risk_max_score'] ?? 35)) { + // Do not override stricter decision. + if (!isset($commentdata['comment_approved']) || (string) $commentdata['comment_approved'] === '1') { + $commentdata['comment_approved'] = 1; + $decision['result'] = 'approved'; + $decision['source'] = 'ai'; + $decision['reason'] = 'ai_low_risk_auto_approve'; + } + } else { + if (!isset($commentdata['comment_approved']) || (string) $commentdata['comment_approved'] === '1') { + $commentdata['comment_approved'] = 0; + $decision['result'] = 'pending'; + $decision['source'] = 'ai'; + $decision['reason'] = 'ai_medium_risk_pending'; + } + } + } elseif ($ai === 'rejected') { + $commentdata['comment_approved'] = 'spam'; + $decision['result'] = 'spam'; + $decision['source'] = 'ai'; + $decision['reason'] = 'ai_rejected_auto_reject'; + } elseif ($ai === 'error') { + // Fail-safe: AI enabled but response is invalid/unusable, do not auto approve. + if (!isset($commentdata['comment_approved']) || (string) $commentdata['comment_approved'] === '1') { + $commentdata['comment_approved'] = 0; + $decision['result'] = 'pending'; + } + $decision['source'] = 'ai'; + $decision['reason'] = 'ai_response_invalid_pending'; + $decision['ai_flagged'] = '1'; + } + } + + $decision = $this->enrich_moderation_result($decision, $commentdata); + $this->remember_result($commentdata, $decision); + } catch (Exception $e) { + // Intentionally silent: keep moderation flow without writing runtime logs. + } + + return $commentdata; + } + + /** + * Keep pre_comment_approved in sync with preprocess_comment outcome. + */ + public function enforce_pre_comment_approved($approved, array $commentdata) { + if (!is_array($commentdata)) { + return $approved; + } + + $fingerprint = $this->comment_fingerprint($commentdata); + if ($fingerprint !== '' && array_key_exists($fingerprint, $this->forced_approved_map)) { + $forced = $this->forced_approved_map[$fingerprint]; + return $forced; + } + + if (array_key_exists('comment_approved', $commentdata)) { + return $commentdata['comment_approved']; + } + + return $approved; + } + + /** + * Persist moderation details to comment meta. + */ + public function after_post(int $comment_id, $comment_approved, array $commentdata = []): void { + $comment = get_comment($comment_id); + if (!$comment instanceof WP_Comment) { + return; + } + + $decision = $this->resolve_result_from_runtime_or_default($commentdata, $comment); + $decision = $this->enrich_moderation_result($decision, (array) $comment); + + update_comment_meta($comment_id, 'aksrt_moderation_result', (string) $decision['result']); + update_comment_meta($comment_id, 'aksrt_moderation_source', (string) $decision['source']); + update_comment_meta($comment_id, 'aksrt_moderation_reason', (string) $decision['reason']); + update_comment_meta($comment_id, 'aksrt_moderation_akismet', (string) $decision['akismet']); + update_comment_meta($comment_id, 'aksrt_moderation_ai_score', (string) $decision['ai_score']); + update_comment_meta($comment_id, 'aksrt_moderation_ai_flagged', (string) $decision['ai_flagged']); + update_comment_meta($comment_id, 'aksrt_moderation_ai_top_category', (string) $decision['ai_top_category']); + update_comment_meta($comment_id, 'aksrt_moderation_keyword', (string) $decision['keyword']); + update_comment_meta($comment_id, 'aksrt_moderation_keyword_match_type', (string) $decision['keyword_match_type']); + + $moderation_user_id = isset($comment->user_id) ? (int) $comment->user_id : 0; + $moderation_user_ip = trim((string) ($comment->comment_author_IP ?? '')); + if ($moderation_user_ip === '') { + $moderation_user_ip = trim((string) ($decision['ip'] ?? '')); + } + if ($moderation_user_ip === '') { + $moderation_user_ip = trim((string) ($commentdata['comment_author_IP'] ?? ($commentdata['comment_author_ip'] ?? ''))); + } + + update_comment_meta($comment_id, 'aksrt_moderation_user_id', (string) $moderation_user_id); + update_comment_meta($comment_id, 'aksrt_moderation_user_ip', $moderation_user_ip); + + // Convenience marker for external tools expecting Akismet style keys. + if ($decision['source'] === 'akismet' && $decision['akismet'] === 'spam') { + update_comment_meta($comment_id, 'akismet_result', 'true'); + } elseif ($decision['source'] === 'akismet' && $decision['akismet'] === 'ham') { + update_comment_meta($comment_id, 'akismet_result', 'false'); + } + + // Clean runtime maps. + $fp_comment = $this->comment_fingerprint((array) $comment); + $fp_data = $this->comment_fingerprint($commentdata); + foreach (array_unique(array_filter([$fp_comment, $fp_data])) as $fp) { + unset($this->moderation_result_map[$fp], $this->forced_approved_map[$fp]); + } + } + + public function register_comments_columns(array $columns): array { + $new = []; + foreach ($columns as $key => $label) { + $new[$key] = $label; + if ($key === 'comment') { + $new['aksrt_moderation'] = __('Moderation', 'aksrt'); + } + } + if (!isset($new['aksrt_moderation'])) { + $new['aksrt_moderation'] = __('Moderation', 'aksrt'); + } + return $new; + } + +} + +return AKSRT_Moderation::get_instance(); diff --git a/inc/classes/class-aksrt-oauth-proxy-client.php b/inc/classes/class-aksrt-oauth-proxy-client.php new file mode 100644 index 00000000..81ad3e34 --- /dev/null +++ b/inc/classes/class-aksrt-oauth-proxy-client.php @@ -0,0 +1,316 @@ + $provider, + 'agent_back_url' => $back_url, + 'action' => $action, + 'nonce' => $nonce, + 'uid' => $uid, + 'ts' => time(), + ]; + + $params['sign'] = $this->sign($params, $secret); + + $server_url = rtrim($server_url, '/'); + return $server_url . '/oauth/proxy/' . $provider . '?' . http_build_query($params); + } + + /** + * 处理代理回调(从服务端重定向回来) + */ + public function handle_proxy_callback(): void { + if (!get_query_var('aksrt_proxy_callback')) { + return; + } + + $secret = AKSRT_Settings::oauth_proxy_secret(); + if (empty($secret)) { + wp_die(__('代理登录配置错误', 'aksrt')); + } + + $sign = sanitize_text_field($_GET['sign'] ?? ''); + $provider = sanitize_key($_GET['provider'] ?? ''); + $action = sanitize_key($_GET['action'] ?? 'login'); + + if (empty($sign) || empty($provider)) { + wp_die(__('代理回调参数无效', 'aksrt')); + } + + // 验证签名 + $verify_params = $_GET; + unset($verify_params['sign']); + + if (!$this->verify_sign($verify_params, $secret, $sign)) { + wp_die(__('代理回调签名验证失败', 'aksrt')); + } + + // 验证时间戳(5分钟内有效) + $ts = (int) ($_GET['ts'] ?? 0); + if (abs(time() - $ts) > 300) { + wp_die(__('代理回调已过期,请重新登录', 'aksrt')); + } + + // 提取用户数据 + $provider_id = sanitize_text_field($_GET['provider_id'] ?? ''); + if (empty($provider_id)) { + wp_die(__('代理回调缺少用户标识', 'aksrt')); + } + + $user_data = [ + 'provider' => $provider, + 'provider_id' => $provider_id, + 'username' => sanitize_user($_GET['username'] ?? '', true), + 'name' => sanitize_text_field($_GET['name'] ?? ''), + 'email' => sanitize_email($_GET['email'] ?? ''), + 'email_verified' => !empty($_GET['email_verified']), + 'avatar_url' => esc_url_raw($_GET['avatar_url'] ?? ''), + 'bio' => sanitize_text_field($_GET['bio'] ?? ''), + ]; + + if ($action === 'bind') { + $this->handle_proxy_bind($provider, $user_data); + return; + } + + $this->handle_proxy_login($provider, $user_data); + } + + /** + * 处理代理登录 + */ + private function handle_proxy_login(string $provider, array $user_data): void { + $user = $this->find_or_create_user_from_proxy($provider, $user_data); + + // 未匹配已有账号 → 跳转绑定页 + if (is_wp_error($user) && $user->get_error_code() === 'oauth_no_match') { + $error_data = $user->get_error_data(); + $obk = 'aksrt_ob_' . wp_generate_password(24, false); + set_transient($obk, [ + 'provider' => $error_data['provider'], + 'provider_id' => $error_data['user_data']['provider_id'] ?? '', + 'user_data' => $error_data['user_data'], + 'token' => [], + ], 15 * MINUTE_IN_SECONDS); + + $bind_url = aksrt_get_bind_url(); + wp_safe_redirect(add_query_arg('obk', $obk, $bind_url)); + exit; + } + + if (is_wp_error($user)) { + wp_die(__('登录失败:', 'aksrt') . esc_html($user->get_error_message())); + } + + // 登录成功 + wp_set_auth_cookie($user->ID, true); + $redirect_url = function_exists('aksrt_get_user_center_url') ? aksrt_get_user_center_url() : home_url('/'); + wp_safe_redirect(add_query_arg(['oauth_action' => 'login', 'oauth_bound' => $provider], $redirect_url)); + exit; + } + + /** + * 处理代理绑定(已登录用户绑定第三方账号) + */ + private function handle_proxy_bind(string $provider, array $user_data): void { + $current_uid = get_current_user_id(); + if (!$current_uid) { + wp_safe_redirect(aksrt_get_login_url()); + exit; + } + + // 检查是否已被其他用户占用 + $existing = $this->find_user_by_provider_meta($provider, $user_data['provider_id']); + if ($existing && (int) $existing->ID !== $current_uid) { + wp_die(__('该第三方账号已被其他用户绑定', 'aksrt')); + } + + $this->bind_provider_to_user($current_uid, $provider, $user_data); + + $redirect_url = function_exists('aksrt_get_user_center_url') + ? add_query_arg(['oauth_action' => 'bind', 'oauth_bound' => $provider], aksrt_get_user_center_url('settings')) + : home_url('/'); + wp_safe_redirect($redirect_url); + exit; + } + + /** + * 查找或创建用户(代理模式) + * + * @return WP_User|WP_Error + */ + private function find_or_create_user_from_proxy(string $provider, array $user_data) { + $email = $user_data['email'] ?? ''; + + // 1. 通过 provider_id 查找 + if (!empty($user_data['provider_id'])) { + $existing = $this->find_user_by_provider_meta($provider, $user_data['provider_id']); + if ($existing instanceof WP_User) { + $this->ensure_binding_meta($existing->ID, $provider, $user_data); + return $existing; + } + } + + // 2. 通过邮箱查找 + if (!empty($email) && is_email($email)) { + $existing = get_user_by('email', $email); + if ($existing && !$existing instanceof WP_Error) { + $this->ensure_binding_meta($existing->ID, $provider, $user_data); + return $existing; + } + } + + // 3. 未匹配 + return new WP_Error('oauth_no_match', __('需要绑定已有账号', 'aksrt'), [ + 'provider' => $provider, + 'user_data' => $user_data, + ]); + } + + /** + * 确保绑定 meta 存在 + */ + private function ensure_binding_meta(int $user_id, string $provider, array $user_data): void { + update_user_meta($user_id, "aksrt_{$provider}_id", $user_data['provider_id'] ?? ''); + + $bindings = get_user_meta($user_id, 'aksrt_oauth_bindings', true) ?: []; + if (!is_array($bindings)) { + $bindings = []; + } + if (!in_array($provider, $bindings, true)) { + $bindings[] = $provider; + update_user_meta($user_id, 'aksrt_oauth_bindings', $bindings); + } + + if (!empty($user_data['avatar_url'])) { + update_user_meta($user_id, 'aksrt_oauth_avatar', esc_url_raw($user_data['avatar_url'])); + } + } + + /** + * 绑定第三方账号到用户 + */ + private function bind_provider_to_user(int $user_id, string $provider, array $user_data): void { + if (!empty($user_data['provider_id'])) { + update_user_meta($user_id, "aksrt_{$provider}_id", $user_data['provider_id']); + } + + $bindings = get_user_meta($user_id, 'aksrt_oauth_bindings', true) ?: []; + if (!is_array($bindings)) { + $bindings = []; + } + if (!in_array($provider, $bindings, true)) { + $bindings[] = $provider; + update_user_meta($user_id, 'aksrt_oauth_bindings', $bindings); + } + + if (!empty($user_data['avatar_url'])) { + update_user_meta($user_id, 'aksrt_oauth_avatar', esc_url_raw($user_data['avatar_url'])); + } + } + + /** + * 通过 provider meta 查找用户 + */ + private function find_user_by_provider_meta(string $provider, string $provider_id): ?WP_User { + if (empty($provider_id)) { + return null; + } + $users = get_users([ + 'meta_key' => "aksrt_{$provider}_id", + 'meta_value' => $provider_id, + 'number' => 1, + ]); + return !empty($users[0]) ? $users[0] : null; + } + + /** + * 生成 HMAC-SHA256 签名 + */ + private function sign(array $data, string $secret): string { + unset($data['sign']); + ksort($data); + $payload = http_build_query($data); + return hash_hmac('sha256', $payload, $secret); + } + + /** + * 验证 HMAC-SHA256 签名 + */ + private function verify_sign(array $data, string $secret, string $expected_sign): bool { + $computed = $this->sign($data, $secret); + return hash_equals($computed, $expected_sign); + } +} + +return AKSRT_OAuth_Proxy_Client::get_instance(); diff --git a/inc/classes/class-aksrt-oauth-proxy-server.php b/inc/classes/class-aksrt-oauth-proxy-server.php new file mode 100644 index 00000000..18d533b8 --- /dev/null +++ b/inc/classes/class-aksrt-oauth-proxy-server.php @@ -0,0 +1,294 @@ +handle_proxy_callback($provider); + } else { + $this->handle_proxy_request($provider); + } + } + + private function handle_proxy_request(string $provider): void { + $secret = AKSRT_Settings::oauth_proxy_secret(); + if (empty($secret)) { + wp_die(__('代理登录服务端未配置密钥', 'aksrt')); + } + + $agent_back_url = esc_url_raw($_GET['agent_back_url'] ?? ''); + $sign = sanitize_text_field($_GET['sign'] ?? ''); + $action = sanitize_key($_GET['action'] ?? 'login'); + + if (empty($agent_back_url) || empty($sign)) { + wp_die(__('代理请求参数不完整', 'aksrt')); + } + + $verify_params = $_GET; + unset($verify_params['sign']); + if (!$this->verify_sign($verify_params, $secret, $sign)) { + wp_die(__('代理请求签名验证失败', 'aksrt')); + } + + $ts = (int) ($_GET['ts'] ?? 0); + if (abs(time() - $ts) > 300) { + wp_die(__('代理请求已过期,请重试', 'aksrt')); + } + + @session_start(); + $_SESSION['aksrt_proxy_agent_back_url'] = $agent_back_url; + $_SESSION['aksrt_proxy_action'] = $action; + $_SESSION['aksrt_proxy_nonce'] = sanitize_text_field($_GET['nonce'] ?? ''); + $_SESSION['aksrt_proxy_uid'] = (int) ($_GET['uid'] ?? 0); + + try { + $oauth = AKSRT_OAuth::get_instance(); + $auth_url = $oauth->get_auth_url($provider, $action); + if (empty($auth_url)) { + wp_die(__('无法生成授权链接', 'aksrt')); + } + wp_safe_redirect($auth_url); + exit; + } catch (\Exception $e) { + wp_die(__('代理登录错误:', 'aksrt') . esc_html($e->getMessage())); + } + } + + private function handle_proxy_callback(string $provider): void { + @session_start(); + + $agent_back_url = $_SESSION['aksrt_proxy_agent_back_url'] ?? ''; + if (empty($agent_back_url)) return; + + $_SESSION['aksrt_proxy_agent_back_url'] = ''; + $_SESSION['aksrt_proxy_action'] = ''; + $_SESSION['aksrt_proxy_nonce'] = ''; + $_SESSION['aksrt_proxy_uid'] = 0; + + $secret = AKSRT_Settings::oauth_proxy_secret(); + if (empty($secret)) { + wp_die(__('代理登录服务端配置错误', 'aksrt')); + } + + $error = $_GET['error'] ?? ''; + if ($error) { + wp_safe_redirect(add_query_arg(['error' => urlencode($error)], $agent_back_url)); + exit; + } + + $code = sanitize_text_field($_GET['code'] ?? ''); + if (empty($code)) { + wp_safe_redirect(add_query_arg(['error' => urlencode('missing_code')], $agent_back_url)); + exit; + } + + try { + $user_data = $this->fetch_user_data($provider, $code); + + if (empty($user_data['provider_id'])) { + wp_safe_redirect(add_query_arg(['error' => urlencode('no_user_info')], $agent_back_url)); + exit; + } + + $callback_params = [ + 'provider' => $provider, + 'provider_id' => $user_data['provider_id'], + 'username' => $user_data['username'] ?? '', + 'name' => $user_data['name'] ?? '', + 'email' => $user_data['email'] ?? '', + 'email_verified' => !empty($user_data['email_verified']) ? '1' : '0', + 'avatar_url' => $user_data['avatar_url'] ?? '', + 'bio' => $user_data['bio'] ?? '', + 'action' => sanitize_key($_SESSION['aksrt_proxy_action'] ?? 'login'), + 'ts' => time(), + ]; + + $callback_params['sign'] = $this->sign($callback_params, $secret); + $redirect_url = $agent_back_url . (strpos($agent_back_url, '?') !== false ? '&' : '?') . http_build_query($callback_params); + wp_safe_redirect($redirect_url); + exit; + + } catch (\Exception $e) { + wp_safe_redirect(add_query_arg(['error' => urlencode($e->getMessage())], $agent_back_url)); + exit; + } + } + + /** + * 获取用户数据 + */ + private function fetch_user_data(string $provider, string $code): array { + $config = get_option('aksrt_oauth', [])[$provider] ?? []; + if (empty($config['client_id']) || empty($config['client_secret'])) { + throw new \Exception(__('未配置此平台的 OAuth 凭据', 'aksrt')); + } + + $redirect_uri = !empty($config['redirect_uri']) + ? $config['redirect_uri'] + : home_url('/oauth/proxy/' . $provider . '/callback'); + + switch ($provider) { + case 'github': + $oauth = new \Yurun\OAuthLogin\Github\OAuth2( + $config['client_id'], + $config['client_secret'], + $redirect_uri + ); + $oauth->getAccessToken($_GET['state'] ?? '', $code, $_GET['state'] ?? null); + $userInfo = $oauth->getUserInfo(); + + $email = $userInfo['email'] ?? ''; + if (empty($email)) { + $emails_json = $oauth->http->header('Authorization', ' token ' . $oauth->accessToken) + ->get('https://api.github.com/user/emails') + ->body(); + $emails = json_decode($emails_json, true) ?: []; + foreach ($emails as $em) { + if (!empty($em['primary']) && !empty($em['email'])) { + $email = $em['email']; + break; + } + } + } + + return [ + 'provider' => 'github', + 'provider_id' => (string) ($userInfo['id'] ?? ''), + 'username' => $userInfo['login'] ?? '', + 'name' => $userInfo['name'] ?? '', + 'email' => $email, + 'avatar_url' => $userInfo['avatar_url'] ?? '', + 'bio' => $userInfo['bio'] ?? '', + ]; + + case 'gitee': + $oauth = new \Yurun\OAuthLogin\Gitee\OAuth2( + $config['client_id'], + $config['client_secret'], + $redirect_uri + ); + $oauth->getAccessToken($_GET['state'] ?? '', $code, $_GET['state'] ?? null); + $userInfo = $oauth->getUserInfo(); + + return [ + 'provider' => 'gitee', + 'provider_id' => (string) ($userInfo['id'] ?? ''), + 'username' => $userInfo['login'] ?? '', + 'name' => $userInfo['name'] ?? '', + 'email' => $userInfo['email'] ?? '', + 'avatar_url' => $userInfo['avatar_url'] ?? '', + 'bio' => $userInfo['bio'] ?? '', + ]; + + case 'qq': + $oauth = new \Yurun\OAuthLogin\QQ\OAuth2( + $config['client_id'], + $config['client_secret'], + $redirect_uri + ); + $oauth->getAccessToken($_GET['state'] ?? '', $code, $_GET['state'] ?? null); + $openid = $oauth->getOpenID(); + $userInfo = $oauth->getUserInfo(); + + return [ + 'provider' => 'qq', + 'provider_id' => $openid, + 'username' => $userInfo['nickname'] ?? '', + 'name' => $userInfo['nickname'] ?? '', + 'email' => '', + 'avatar_url' => $userInfo['figureurl_qq_2'] ?? $userInfo['figureurl_2'] ?? '', + ]; + + case 'google': + $response = wp_remote_post('https://oauth2.googleapis.com/token', [ + 'body' => [ + 'code' => $code, + 'client_id' => $config['client_id'], + 'client_secret' => $config['client_secret'], + 'redirect_uri' => $redirect_uri, + 'grant_type' => 'authorization_code', + ], + ]); + if (is_wp_error($response)) throw new \Exception($response->get_error_message()); + $token_data = json_decode(wp_remote_retrieve_body($response), true); + if (empty($token_data['access_token'])) throw new \Exception(__('Google token 获取失败', 'aksrt')); + + $user_response = wp_remote_get('https://www.googleapis.com/oauth2/v2/userinfo', [ + 'headers' => ['Authorization' => 'Bearer ' . $token_data['access_token']], + ]); + if (is_wp_error($user_response)) throw new \Exception($user_response->get_error_message()); + $user_info = json_decode(wp_remote_retrieve_body($user_response), true); + + return [ + 'provider' => 'google', + 'provider_id' => (string) ($user_info['id'] ?? ''), + 'username' => $user_info['email'] ?? '', + 'name' => $user_info['name'] ?? '', + 'email' => $user_info['email'] ?? '', + 'avatar_url' => $user_info['picture'] ?? '', + ]; + + default: + throw new \Exception(__('不支持的 OAuth 平台', 'aksrt')); + } + } + + private function sign(array $data, string $secret): string { + unset($data['sign']); + ksort($data); + return hash_hmac('sha256', http_build_query($data), $secret); + } + + private function verify_sign(array $data, string $secret, string $expected_sign): bool { + return hash_equals($this->sign($data, $secret), $expected_sign); + } +} + +return AKSRT_OAuth_Proxy_Server::get_instance(); diff --git a/inc/classes/class-aksrt-oauth.php b/inc/classes/class-aksrt-oauth.php new file mode 100644 index 00000000..d4b435b4 --- /dev/null +++ b/inc/classes/class-aksrt-oauth.php @@ -0,0 +1,422 @@ +get_proxy_auth_url($provider, $action); + } + return ''; + } + + $config = $this->get_provider_config($provider); + if (!$config) return ''; + + $uid = get_current_user_id(); + $nonce = wp_create_nonce('aksrt_oauth_' . $provider . '_' . $uid); + $state = ($action === 'bind' && $uid) + ? 'bind_' . $provider . '_' . $nonce + : $provider . '_' . $nonce; + + $redirect_uri = !empty($config['redirect_uri']) ? $config['redirect_uri'] : home_url('/oauth/callback/'); + + try { + switch ($provider) { + case 'github': + $oauth = new \Yurun\OAuthLogin\Github\OAuth2( + $config['client_id'], + $config['client_secret'], + $redirect_uri + ); + return $oauth->getAuthUrl($redirect_uri, $state); + + case 'gitee': + $oauth = new \Yurun\OAuthLogin\Gitee\OAuth2( + $config['client_id'], + $config['client_secret'], + $redirect_uri + ); + return $oauth->getAuthUrl($redirect_uri, $state); + + case 'qq': + $oauth = new \Yurun\OAuthLogin\QQ\OAuth2( + $config['client_id'], + $config['client_secret'], + $redirect_uri + ); + return $oauth->getAuthUrl($redirect_uri, $state); + + case 'weibo': + $oauth = new \Yurun\OAuthLogin\Weibo\OAuth2( + $config['client_id'], + $config['client_secret'], + $redirect_uri + ); + return $oauth->getAuthUrl($redirect_uri, $state); + + case 'weixin': + $oauth = new \Yurun\OAuthLogin\Weixin\OAuth2( + $config['client_id'], + $config['client_secret'], + $redirect_uri + ); + return $oauth->getAuthUrl($redirect_uri, $state); + + case 'baidu': + $oauth = new \Yurun\OAuthLogin\Baidu\OAuth2( + $config['client_id'], + $config['client_secret'], + $redirect_uri + ); + return $oauth->getAuthUrl($redirect_uri, $state); + + case 'alipay': + $oauth = new \Yurun\OAuthLogin\Alipay\OAuth2( + $config['client_id'], + $config['client_secret'], + $redirect_uri + ); + return $oauth->getAuthUrl($redirect_uri, $state); + + case 'google': + return $this->get_google_auth_url($config, $redirect_uri, $state); + + case 'weixingzh': + return $this->get_weixingzh_auth_url($config, $redirect_uri, $state); + + default: + return ''; + } + } catch (\Exception $e) { + return ''; + } + } + + /** + * 处理OAuth回调 — 路由分发 + */ + public function handle_callback(): void { + $provider = $this->resolve_callback_provider(); + if ($provider === '' || !in_array($provider, $this->get_enabled_providers())) return; + + $error = $_GET['error'] ?? ''; + if ($error) { + wp_die(__('登录失败:', 'aksrt') . esc_html($error)); + } + + $code = $_GET['code'] ?? ''; + $state = $_GET['state'] ?? ''; + if (empty($code)) { + wp_die(__('OAuth回调缺少授权码', 'aksrt')); + } + + $is_bind = strpos($state, 'bind_') === 0; + $rest = $is_bind ? substr($state, 5) : $state; + $sep = strpos($rest, '_'); + + if ($sep === false || $sep >= strlen($rest) - 1) { + wp_die(__('无效的OAuth状态参数', 'aksrt')); + } + + $parsed_provider = sanitize_key(substr($rest, 0, $sep)); + if (!in_array($parsed_provider, $this->get_enabled_providers())) { + wp_die(__('未知的OAuth提供商', 'aksrt')); + } + + $nonce_state = substr($rest, $sep + 1); + + if ($is_bind) { + $this->handle_bind_callback($parsed_provider, $code, $nonce_state); + } else { + $this->handle_login_callback($parsed_provider, $code, $nonce_state); + } + } + + private function handle_login_callback(string $provider, string $code, string $nonce): void { + try { + if (!wp_verify_nonce($nonce, "aksrt_oauth_{$provider}_0")) { + throw new \Exception(__('安全验证失败,请重试', 'aksrt')); + } + + $user_data = $this->get_user_data($provider, $code); + + if (!$user_data || empty($user_data['provider_id'])) { + throw new \Exception(__('无法获取用户信息', 'aksrt')); + } + + $user = $this->find_or_create_user($provider, $user_data); + + if (is_wp_error($user) && $user->get_error_code() === 'oauth_no_match') { + $error_data = $user->get_error_data(); + $obk = 'aksrt_ob_' . wp_generate_password(24, false); + set_transient($obk, [ + 'provider' => $error_data['provider'], + 'provider_id' => $error_data['user_data']['provider_id'] ?? '', + 'user_data' => $error_data['user_data'], + ], 15 * MINUTE_IN_SECONDS); + + $bind_url = aksrt_get_bind_url(); + wp_safe_redirect(add_query_arg('obk', $obk, $bind_url)); + exit; + } + + if (is_wp_error($user)) { + throw new \Exception($user->get_error_message()); + } + + wp_set_auth_cookie($user->ID, true); + $redirect_url = home_url('/'); + if (function_exists('aksrt_get_user_center_url')) { + $redirect_url = aksrt_get_user_center_url(); + } + wp_safe_redirect(add_query_arg(['oauth_action' => 'login', 'oauth_bound' => $provider], $redirect_url)); + exit; + + } catch (\Exception $e) { + wp_die(__('登录失败:', 'aksrt') . '
' . esc_html($e->getMessage())); + } + } + + private function handle_bind_callback(string $provider, string $code, string $nonce): void { + $current_uid = get_current_user_id(); + if (!$current_uid) { + wp_safe_redirect(aksrt_get_login_url()); + exit; + } + + try { + $expected = wp_create_nonce("aksrt_oauth_{$provider}_{$current_uid}"); + if (!hash_equals($expected, $nonce)) { + throw new \Exception(__('安全验证失败,请重试', 'aksrt')); + } + + $existing_binding = get_user_meta($current_uid, "aksrt_{$provider}_id", true); + if (!empty($existing_binding)) { + wp_safe_redirect(add_query_arg(['tab' => 'settings', 'oauth_action' => 'bind', 'oauth_bound' => $provider], $this->get_profile_url())); + exit; + } + + $user_data = $this->get_user_data($provider, $code); + + if (!$user_data || empty($user_data['provider_id'])) { + throw new \Exception(__('无法获取用户信息', 'aksrt')); + } + + $existing = $this->find_user_by_provider($provider, $user_data['provider_id'] ?? ''); + if ($existing && $existing->ID !== $current_uid) { + throw new \Exception(__('该第三方账号已被其他用户绑定', 'aksrt')); + } + + $this->bind_provider_to_user($current_uid, $provider, $user_data); + + wp_safe_redirect(add_query_arg(['tab' => 'settings', 'oauth_action' => 'bind', 'oauth_bound' => $provider], $this->get_profile_url())); + exit; + + } catch (\Exception $e) { + wp_die(__('绑定失败:', 'aksrt') . '
' . esc_html($e->getMessage())); + } + } + + /** + * 通过授权码获取用户数据(统一输出格式) + */ + private function get_user_data(string $provider, string $code): array { + $config = $this->get_provider_config($provider); + $redirect_uri = !empty($config['redirect_uri']) ? $config['redirect_uri'] : home_url('/oauth/callback/'); + + switch ($provider) { + case 'github': + return $this->get_github_user_data($config, $code, $redirect_uri); + case 'gitee': + return $this->get_gitee_user_data($config, $code, $redirect_uri); + case 'qq': + return $this->get_qq_user_data($config, $code, $redirect_uri); + case 'weibo': + return $this->get_weibo_user_data($config, $code, $redirect_uri); + case 'weixin': + return $this->get_weixin_user_data($config, $code, $redirect_uri); + case 'baidu': + return $this->get_baidu_user_data($config, $code, $redirect_uri); + case 'alipay': + return $this->get_alipay_user_data($config, $code, $redirect_uri); + case 'google': + return $this->get_google_user_data($config, $code, $redirect_uri); + case 'weixingzh': + return $this->get_weixingzh_user_data($config, $code, $redirect_uri); + default: + throw new \Exception(__('不支持的OAuth平台:', 'aksrt') . $provider); + } + } + + private function get_github_user_data(array $config, string $code, string $redirect_uri): array { + $oauth = new \Yurun\OAuthLogin\Github\OAuth2( + $config['client_id'], + $config['client_secret'], + $redirect_uri + ); + + $oauth->getAccessToken($_GET['state'] ?? '', $code, $_GET['state'] ?? null); + $userInfo = $oauth->getUserInfo(); + + // 获取邮箱 + $email = $userInfo['email'] ?? ''; + if (empty($email)) { + $emails_json = $oauth->http->header('Authorization', ' token ' . $oauth->accessToken) + ->get('https://api.github.com/user/emails') + ->body(); + $emails = json_decode($emails_json, true) ?: []; + foreach ($emails as $em) { + if (!empty($em['primary']) && !empty($em['email'])) { + $email = $em['email']; + break; + } + } + } + + return [ + 'provider' => 'github', + 'provider_id' => (string) ($userInfo['id'] ?? ''), + 'username' => $userInfo['login'] ?? '', + 'name' => $userInfo['name'] ?? '', + 'email' => $email, + 'avatar_url' => $userInfo['avatar_url'] ?? '', + 'url' => $userInfo['html_url'] ?? '', + 'bio' => $userInfo['bio'] ?? '', + ]; + } + + private function get_gitee_user_data(array $config, string $code, string $redirect_uri): array { + $oauth = new \Yurun\OAuthLogin\Gitee\OAuth2( + $config['client_id'], + $config['client_secret'], + $redirect_uri + ); + + $oauth->getAccessToken($_GET['state'] ?? '', $code, $_GET['state'] ?? null); + $userInfo = $oauth->getUserInfo(); + + return [ + 'provider' => 'gitee', + 'provider_id' => (string) ($userInfo['id'] ?? ''), + 'username' => $userInfo['login'] ?? '', + 'name' => $userInfo['name'] ?? '', + 'email' => $userInfo['email'] ?? '', + 'avatar_url' => $userInfo['avatar_url'] ?? '', + 'url' => $userInfo['html_url'] ?? '', + 'bio' => $userInfo['bio'] ?? '', + ]; + } + + private function get_qq_user_data(array $config, string $code, string $redirect_uri): array { + $oauth = new \Yurun\OAuthLogin\QQ\OAuth2( + $config['client_id'], + $config['client_secret'], + $redirect_uri + ); + + $oauth->getAccessToken($_GET['state'] ?? '', $code, $_GET['state'] ?? null); + $openid = $oauth->getOpenID(); + $userInfo = $oauth->getUserInfo(); + + return [ + 'provider' => 'qq', + 'provider_id' => $openid, + 'username' => $userInfo['nickname'] ?? '', + 'name' => $userInfo['nickname'] ?? '', + 'email' => '', + 'avatar_url' => $userInfo['figureurl_qq_2'] ?? $userInfo['figureurl_2'] ?? $userInfo['figureurl'] ?? '', + 'gender' => $userInfo['gender'] ?? '', + ]; + } + + private function get_weibo_user_data(array $config, string $code, string $redirect_uri): array { + $oauth = new \Yurun\OAuthLogin\Weibo\OAuth2( + $config['client_id'], + $config['client_secret'], + $redirect_uri + ); + + $oauth->getAccessToken($_GET['state'] ?? '', $code, $_GET['state'] ?? null); + $userInfo = $oauth->getUserInfo(); + + return [ + 'provider' => 'weibo', + 'provider_id' => (string) ($userInfo['id'] ?? ''), + 'username' => $userInfo['screen_name'] ?? '', + 'name' => $userInfo['name'] ?? '', + 'email' => '', + 'avatar_url' => $userInfo['avatar_large'] ?? $userInfo['profile_image_url'] ?? '', + 'gender' => ($userInfo['gender'] ?? '') === 'm' ? 'male' : (($userInfo['gender'] ?? '') === 'f' ? 'female' : ''), + ]; + } + + private function get_weixin_user_data(array $config, string $code, string $redirect_uri): array { + $oauth = new \Yurun\OAuthLogin\Weixin\OAuth2( + $config['client_id'], + $config['client_secret'], + $redirect_uri + ); + + $oauth->getAccessToken($_GET['state'] ?? '', $code, $_GET['state'] ?? null); + $userInfo = $oauth->getUserInfo(); + + return [ + 'provider' => 'weixin', + 'provider_id' => (string) ($userInfo['openid'] ?? ''), + 'username' => $userInfo['nickname'] ?? '', + 'name' => $userInfo['nickname'] ?? '', + 'email' => '', + 'avatar_url' => $userInfo['headimgurl'] ?? '', + 'gender' => ($userInfo['sex'] ?? 0) == 1 ? 'male' : (($userInfo['sex'] ?? 0) == 2 ? 'female' : ''), + ]; + } + +} + +return AKSRT_OAuth::get_instance(); diff --git a/inc/classes/class-aksrt-order-repository.php b/inc/classes/class-aksrt-order-repository.php new file mode 100644 index 00000000..1e1f4eb4 --- /dev/null +++ b/inc/classes/class-aksrt-order-repository.php @@ -0,0 +1,685 @@ +generate_order_number(); + + $wpdb->insert($this->table(), [ + 'order_number' => $order_number, + 'user_id' => (int) ($data['user_id'] ?? 0), + 'product_type' => sanitize_key((string) ($data['product_type'] ?? '')), + 'product_id' => (int) ($data['product_id'] ?? 0), + 'amount' => (float) ($data['amount'] ?? 0), + 'currency' => sanitize_key((string) ($data['currency'] ?? 'CNY')), + 'status' => AKSRT_Domain_Model::normalize_order_status((string) ($data['status'] ?? AKSRT_Domain_Model::ORDER_STATUS_PENDING)), + 'payment_method' => sanitize_key((string) ($data['payment_method'] ?? '')), + 'payment_transaction_id' => sanitize_text_field((string) ($data['payment_transaction_id'] ?? '')), + 'created_at' => $data['created_at'] ?? current_time('mysql'), + 'paid_at' => $data['paid_at'] ?? null, + 'expires_at' => $data['expires_at'] ?? null, + 'note' => sanitize_textarea_field((string) ($data['note'] ?? '')), + ], ['%s', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s']); + + if ($wpdb->insert_id) { + return $order_number; + } + + return ''; + } + + /** + * 获取单个订单 + */ + public function get(string $order_number): ?array { + global $wpdb; + + $row = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM {$this->table()} WHERE order_number = %s", + $order_number + ), ARRAY_A); + + if ($row) { + return $this->normalize_row($row); + } + + return null; + } + + /** + * 获取用户订单列表 + */ + public function get_user_orders(int $user_id, array $args = []): array { + global $wpdb; + + $where = []; + $params = []; + + if ($user_id > 0) { + $where[] = 'user_id = %d'; + $params[] = $user_id; + } + + $status = sanitize_key((string) ($args['status'] ?? '')); + if ($status !== '') { + $where[] = 'status = %s'; + $params[] = $status; + } + + $product_type = sanitize_key((string) ($args['product_type'] ?? '')); + if ($product_type !== '') { + $where[] = 'product_type = %s'; + $params[] = $product_type; + } + + $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); + $offset = max(0, (int) ($args['offset'] ?? 0)); + + $where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; + $sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d"; + $params[] = $limit; + $params[] = $offset; + + $rows = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A); + + return array_map([$this, 'normalize_row'], (array) $rows); + } + + /** + * 获取单个用户订单。 + */ + public function get_user_order(int $user_id, string $order_number): ?array { + $order = $this->get($order_number); + if (!$order) { + return null; + } + + if ($user_id > 0 && (int) ($order['user_id'] ?? 0) !== $user_id) { + return null; + } + + return $order; + } + + /** + * 获取用户订单总数。 + */ + public function count_user_orders(int $user_id, array $args = []): int { + global $wpdb; + + if ($user_id <= 0) { + return 0; + } + + $where = ['user_id = %d']; + $params = [$user_id]; + + $status = sanitize_key((string) ($args['status'] ?? '')); + if ($status !== '') { + $where[] = 'status = %s'; + $params[] = $status; + } + + $product_type = sanitize_key((string) ($args['product_type'] ?? '')); + if ($product_type !== '') { + $where[] = 'product_type = %s'; + $params[] = $product_type; + } + + $where_sql = 'WHERE ' . implode(' AND ', $where); + $sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}"; + + return (int) $wpdb->get_var($wpdb->prepare($sql, $params)); + } + + /** + * Build a stable admin-facing order context payload for workbench pages. + */ + public function get_admin_order_context(string $order_number): array { + $order_number = sanitize_text_field($order_number); + $contexts = $this->get_admin_order_context_map([$order_number]); + + return $contexts[$order_number] ?? $this->get_empty_admin_order_context($order_number); + } + + /** + * Build admin-facing order context payloads in bulk to avoid per-row order queries. + * + * @param array $order_numbers + * @return array> + */ + public function get_admin_order_context_map(array $order_numbers): array { + global $wpdb; + + $order_numbers = array_values(array_unique(array_filter(array_map( + static function ($order_number): string { + return sanitize_text_field((string) $order_number); + }, + $order_numbers + )))); + + if (empty($order_numbers)) { + return []; + } + + $contexts = []; + foreach ($order_numbers as $order_number) { + $contexts[$order_number] = $this->get_empty_admin_order_context($order_number); + } + + $placeholders = implode(', ', array_fill(0, count($order_numbers), '%s')); + $sql = "SELECT * FROM {$this->table()} WHERE order_number IN ({$placeholders})"; + $rows = $wpdb->get_results($wpdb->prepare($sql, $order_numbers), ARRAY_A); + + if (empty($rows)) { + return $contexts; + } + + $orders = []; + foreach ((array) $rows as $row) { + $order = $this->normalize_row($row); + $orders[(string) ($order['order_number'] ?? '')] = $order; + } + + $resolved_order_numbers = array_keys($orders); + $shipping_map = class_exists('AKSRT_Shipping') + ? AKSRT_Shipping::get_instance()->get_summary_map($resolved_order_numbers) + : []; + $after_sale_map = class_exists('AKSRT_After_Sale') + ? AKSRT_After_Sale::get_instance()->get_summary_map($resolved_order_numbers) + : []; + + foreach ($orders as $order_number => $order) { + $contexts[$order_number] = $this->build_admin_order_context($order, [ + 'shipping_summary' => $shipping_map[$order_number] ?? [], + 'after_sale_summary' => $after_sale_map[$order_number] ?? [], + ]); + } + + return $contexts; + } + + /** + * 更新订单状态(带状态机验证) + */ + public function update_status(string $order_number, string $new_status, array $extra = []): bool { + global $wpdb; + + $valid = AKSRT_Domain_Model::order_status_labels(); + if (!isset($valid[$new_status])) return false; + + $order = $this->get($order_number); + if (!$order) return false; + + $current_status = $order['status'] ?? 'created'; + + if (empty($extra['force']) && !AKSRT_Domain_Model::can_transition_order_status($current_status, $new_status)) { + return false; + } + + $update_data = ['status' => $new_status]; + $format = ['%s']; + + $time_fields = [ + 'paid' => 'paid_at', + 'delivered' => 'delivered_at', + 'completed' => 'completed_at', + 'cancelled' => 'cancelled_at', + 'refunded' => 'refunded_at', + ]; + + foreach ($time_fields as $status_key => $field) { + if ($new_status === $status_key) { + $update_data[$field] = current_time('mysql'); + $format[] = '%s'; + } + } + + if (!empty($extra['payment_method'])) { + $update_data['payment_method'] = sanitize_key($extra['payment_method']); + $format[] = '%s'; + } + + if (!empty($extra['payment_transaction_id'])) { + $update_data['payment_transaction_id'] = sanitize_text_field($extra['payment_transaction_id']); + $format[] = '%s'; + } + + if (!empty($extra['note'])) { + $update_data['note'] = sanitize_textarea_field($extra['note']); + $format[] = '%s'; + } + + $result = $wpdb->update($this->table(), $update_data, ['order_number' => $order_number], $format, ['%s']); + + if ($result !== false) { + // 到账事件:让充值/VIP/商品履约等挂接(单一履约入口)。 + // 仅在「首次」转为 paid 时触发,避免回调多次到达重复履约。 + if ($new_status === AKSRT_Domain_Model::ORDER_STATUS_PAID && $current_status !== AKSRT_Domain_Model::ORDER_STATUS_PAID) { + $paid_order = array_merge($order, [ + 'status' => $new_status, + 'payment_method' => $update_data['payment_method'] ?? ($order['payment_method'] ?? ''), + 'payment_transaction_id' => $update_data['payment_transaction_id'] ?? ($order['payment_transaction_id'] ?? ''), + ]); + + /** + * 订单到账。 + * + * @param string $order_number 订单号 + * @param array $paid_order 订单数据(status=paid) + */ + do_action('aksrt_order_paid', $order_number, $paid_order); + } + } + + return $result !== false; + } + + /** + * 管理后台订单列表 — 支持 search(order_number / product_type 模糊匹配) + */ + public function get_orders_admin(array $args = [], string $search = ''): array { + global $wpdb; + + $where = []; + $params = []; + + $status = sanitize_key((string) ($args['status'] ?? '')); + if ($status !== '' && $status !== 'all') { + $where[] = 'status = %s'; + $params[] = $status; + } + + if ($search !== '') { + $where[] = '(order_number LIKE %s OR product_type LIKE %s)'; + $like = '%' . $wpdb->esc_like($search) . '%'; + $params[] = $like; + $params[] = $like; + } + + $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); + $offset = max(0, (int) ($args['offset'] ?? 0)); + + $where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; + $sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d"; + $params[] = $limit; + $params[] = $offset; + + $rows = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A); + return array_map([$this, 'normalize_row'], (array) $rows); + } + + /** + * 管理后台订单计数 — 与 get_orders_admin 共用过滤条件 + */ + public function get_orders_count(array $args = [], string $search = ''): int { + global $wpdb; + + $where = []; + $params = []; + + $status = sanitize_key((string) ($args['status'] ?? '')); + if ($status !== '' && $status !== 'all') { + $where[] = 'status = %s'; + $params[] = $status; + } + + if ($search !== '') { + $where[] = '(order_number LIKE %s OR product_type LIKE %s)'; + $like = '%' . $wpdb->esc_like($search) . '%'; + $params[] = $like; + $params[] = $like; + } + + $where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; + $sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}"; + + if (!empty($params)) { + return (int) $wpdb->get_var($wpdb->prepare($sql, $params)); + } + return (int) $wpdb->get_var($sql); + } + + /** + * 获取订单统计 + */ + public function get_stats(int $user_id = 0): array { + global $wpdb; + + $where = ''; + $params = []; + if ($user_id > 0) { + $where = 'WHERE user_id = %d'; + $params[] = $user_id; + } + + $sql = "SELECT + COUNT(*) as total, + SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending, + SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) as paid, + SUM(CASE WHEN status = 'delivered' THEN 1 ELSE 0 END) as delivered, + SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed, + SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) as cancelled, + SUM(CASE WHEN status = 'refunded' THEN 1 ELSE 0 END) as refunded, + SUM(CASE WHEN status IN ('paid','delivered','completed') THEN amount ELSE 0 END) as revenue + FROM {$this->table()} {$where}"; + + if (!empty($params)) { + $row = $wpdb->get_row($wpdb->prepare($sql, $params), ARRAY_A); + } else { + $row = $wpdb->get_row($sql, ARRAY_A); + } + + return [ + 'total' => (int) ($row['total'] ?? 0), + 'pending' => (int) ($row['pending'] ?? 0), + 'paid' => (int) ($row['paid'] ?? 0), + 'delivered' => (int) ($row['delivered'] ?? 0), + 'completed' => (int) ($row['completed'] ?? 0), + 'cancelled' => (int) ($row['cancelled'] ?? 0), + 'refunded' => (int) ($row['refunded'] ?? 0), + 'revenue' => (float) ($row['revenue'] ?? 0), + ]; + } + + public function has_paid_access(int $user_id, string $product_type, int $product_id): bool { + if ($user_id <= 0 || $product_id <= 0) { + return false; + } + + return null !== $this->find_latest_success_order($user_id, $product_type, $product_id); + } + + public function get_valid_vip_order(int $user_id): ?array { + return $this->find_latest_success_order($user_id, AKSRT_Domain_Model::ORDER_PRODUCT_VIP, 0, true); + } + + /* ------------------------------------------------------------------ + * Migration bridge: option → table + * ----------------------------------------------------------------*/ + + /** + * 一次性迁移:把 option 中的订单搬到表中。 + * 幂等 — 已存在的 order_number 不会重复插入。 + */ + public function migrate_from_option(): int { + global $wpdb; + + $orders = get_option('aksrt_orders', []); + if (!is_array($orders) || empty($orders)) return 0; + + $migrated = 0; + foreach ($orders as $order_number => $data) { + $existing = $wpdb->get_var($wpdb->prepare( + "SELECT id FROM {$this->table()} WHERE order_number = %s", + $order_number + )); + if ($existing) continue; + + $wpdb->insert($this->table(), [ + 'order_number' => $order_number, + 'user_id' => (int) ($data['user_id'] ?? 0), + 'product_type' => sanitize_key((string) ($data['product_type'] ?? '')), + 'product_id' => (int) ($data['product_id'] ?? 0), + 'amount' => (float) ($data['amount'] ?? 0), + 'currency' => sanitize_key((string) ($data['currency'] ?? 'CNY')), + 'status' => AKSRT_Domain_Model::normalize_order_status((string) ($data['status'] ?? AKSRT_Domain_Model::ORDER_STATUS_PENDING)), + 'payment_method' => sanitize_key((string) ($data['payment_method'] ?? '')), + 'created_at' => $data['created_at'] ?? current_time('mysql'), + 'paid_at' => $data['paid_at'] ?? null, + 'delivered_at' => $data['delivered_at'] ?? null, + 'completed_at' => $data['completed_at'] ?? null, + 'cancelled_at' => $data['cancelled_at'] ?? null, + 'refunded_at' => $data['refunded_at'] ?? null, + 'expires_at' => $data['expires_at'] ?? null, + 'note' => '', + ], ['%s', '%d', '%s', '%d', '%f', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s']); + + if ($wpdb->insert_id) $migrated++; + } + + update_option(AKSRT_Domain_Model::ORDER_MIGRATED_OPTION, time()); + + return $migrated; + } + + /* ------------------------------------------------------------------ + * Internal helpers + * ----------------------------------------------------------------*/ + + private function normalize_row(array $row): array { + $row['id'] = (int) $row['id']; + $row['user_id'] = (int) $row['user_id']; + $row['product_id'] = (int) $row['product_id']; + $row['amount'] = (float) $row['amount']; + $row['status'] = AKSRT_Domain_Model::normalize_order_status((string) ($row['status'] ?? '')); + + $row['meta_payload'] = []; + if (!empty($row['note']) && is_string($row['note'])) { + $decoded = json_decode($row['note'], true); + if (is_array($decoded)) { + $row['meta_payload'] = $decoded; + if (isset($decoded['customer_note']) && is_string($decoded['customer_note'])) { + $row['note_text'] = $decoded['customer_note']; + } + } else { + $row['note_text'] = $row['note']; + } + } + + return $row; + } + + private function find_latest_success_order(int $user_id, string $product_type, int $product_id = 0, bool $ignore_product_id = false): ?array { + global $wpdb; + + if ($user_id <= 0 || $product_type === '') { + return null; + } + + $status_placeholders = implode(', ', array_fill(0, count(AKSRT_Domain_Model::paid_order_statuses()), '%s')); + $params = array_merge( + [$user_id, sanitize_key($product_type)], + $ignore_product_id ? [] : [$product_id], + AKSRT_Domain_Model::paid_order_statuses() + ); + + $product_sql = $ignore_product_id ? '' : ' AND product_id = %d'; + $sql = "SELECT * FROM {$this->table()} + WHERE user_id = %d + AND product_type = %s{$product_sql} + AND status IN ({$status_placeholders}) + ORDER BY COALESCE(paid_at, created_at) DESC, id DESC + LIMIT 1"; + + $row = $wpdb->get_row($wpdb->prepare($sql, $params), ARRAY_A); + + return is_array($row) ? $this->normalize_row($row) : null; + } + + private function get_empty_admin_order_context(string $order_number): array { + return [ + 'exists' => false, + 'summary' => [ + 'order_number' => $order_number, + 'status' => '', + 'status_label' => '', + 'amount' => 0.0, + 'currency_symbol' => '¥', + 'created_at' => '', + 'paid_at' => '', + ], + 'buyer' => [], + 'address' => [], + 'payment_method' => [], + 'shipping_method' => [], + 'shipping' => [ + 'count' => 0, + 'latest' => [], + ], + 'after_sale' => [ + 'count' => 0, + 'latest' => [], + ], + 'coupon' => [], + 'pricing' => [ + 'subtotal' => 0.0, + 'shipping_fee' => 0.0, + 'discount' => 0.0, + 'amount' => 0.0, + ], + 'items' => [], + 'customer_note' => '', + 'raw_order' => null, + ]; + } + + /** + * @param array $order + * @param array $related + * @return array + */ + private function build_admin_order_context(array $order, array $related = []): array { + $meta = is_array($order['meta_payload'] ?? null) ? $order['meta_payload'] : []; + $user = get_userdata((int) ($order['user_id'] ?? 0)); + $status = (string) ($order['status'] ?? ''); + $address = is_array($meta['address'] ?? null) ? $meta['address'] : []; + $payment_method = is_array($meta['payment_method'] ?? null) ? $meta['payment_method'] : []; + $shipping_method = is_array($meta['shipping_method'] ?? null) ? $meta['shipping_method'] : []; + $coupon = is_array($meta['coupon'] ?? null) ? $meta['coupon'] : []; + $items = is_array($meta['items'] ?? null) ? $meta['items'] : []; + $shipping_fee = (float) ($meta['shipping_fee'] ?? 0); + $discount = (float) ($meta['discount'] ?? 0); + $amount = (float) ($order['amount'] ?? 0); + $subtotal = $this->calculate_order_subtotal($items, max(0, $amount + $discount - $shipping_fee)); + $shipping_summary = is_array($related['shipping_summary'] ?? null) ? $related['shipping_summary'] : []; + $after_sale_summary = is_array($related['after_sale_summary'] ?? null) ? $related['after_sale_summary'] : []; + + $address['full_text'] = $this->build_address_text($address); + + return [ + 'exists' => true, + 'summary' => [ + 'order_number' => (string) ($order['order_number'] ?? ''), + 'status' => $status, + 'status_label' => class_exists('AKSRT_Order') ? (AKSRT_Order::get_valid_statuses()[$status] ?? $status) : $status, + 'amount' => $amount, + 'currency_symbol' => '¥', + 'created_at' => (string) ($order['created_at'] ?? ''), + 'paid_at' => (string) ($order['paid_at'] ?? ''), + 'product_type' => (string) ($order['product_type'] ?? ''), + 'product_id' => (int) ($order['product_id'] ?? 0), + ], + 'buyer' => [ + 'id' => (int) ($order['user_id'] ?? 0), + 'display_name' => $user ? (string) $user->display_name : '', + 'user_login' => $user ? (string) $user->user_login : '', + 'user_email' => $user ? (string) $user->user_email : '', + ], + 'address' => $address, + 'payment_method' => [ + 'value' => (string) ($payment_method['value'] ?? $order['payment_method'] ?? ''), + 'label' => (string) ($payment_method['label'] ?? $order['payment_method'] ?? ''), + ], + 'shipping_method' => [ + 'value' => (string) ($shipping_method['value'] ?? ''), + 'label' => (string) ($shipping_method['label'] ?? ''), + 'fee' => $shipping_fee, + ], + 'shipping' => [ + 'count' => (int) ($shipping_summary['count'] ?? 0), + 'latest' => is_array($shipping_summary['latest'] ?? null) ? $shipping_summary['latest'] : [], + ], + 'after_sale' => [ + 'count' => (int) ($after_sale_summary['count'] ?? 0), + 'latest' => is_array($after_sale_summary['latest'] ?? null) ? $after_sale_summary['latest'] : [], + ], + 'coupon' => $coupon, + 'pricing' => [ + 'subtotal' => $subtotal, + 'shipping_fee' => $shipping_fee, + 'discount' => $discount, + 'amount' => $amount, + ], + 'items' => $items, + 'customer_note' => (string) ($meta['customer_note'] ?? $order['note_text'] ?? ''), + 'raw_order' => $order, + ]; + } + + /** + * @param array> $items + */ + private function calculate_order_subtotal(array $items, float $fallback): float { + if (empty($items)) { + return round($fallback, 2); + } + + $subtotal = 0.0; + foreach ($items as $item) { + if (isset($item['line_total'])) { + $subtotal += (float) $item['line_total']; + continue; + } + + $subtotal += (float) ($item['price'] ?? 0) * max(1, (int) ($item['quantity'] ?? 1)); + } + + if ($subtotal <= 0) { + return round($fallback, 2); + } + + return round($subtotal, 2); + } + + private function build_address_text(array $address): string { + $segments = []; + + foreach (['name', 'phone', 'province', 'city', 'district', 'address'] as $key) { + $value = trim((string) ($address[$key] ?? '')); + if ($value !== '') { + $segments[] = $value; + } + } + + if (!empty($address['full_text']) && is_string($address['full_text'])) { + return (string) $address['full_text']; + } + + return implode(' / ', $segments); + } + + private function generate_order_number(): string { + return 'AKS' . date('YmdHis') . str_pad((string) random_int(0, 9999), 4, '0', STR_PAD_LEFT); + } +} diff --git a/inc/classes/class-aksrt-order.php b/inc/classes/class-aksrt-order.php new file mode 100644 index 00000000..134af951 --- /dev/null +++ b/inc/classes/class-aksrt-order.php @@ -0,0 +1,146 @@ +repo()->create([ + 'user_id' => $user_id, + 'product_type' => sanitize_key($product_type), + 'product_id' => $product_id, + 'amount' => $amount, + 'currency' => sanitize_key($currency), + 'status' => self::STATUS_PENDING, + ]); + } + + public function get(string $order_number): ?array { + return $this->repo()->get($order_number); + } + + public function get_user_orders(int $user_id, array $args = []): array { + return $this->repo()->get_user_orders($user_id, $args); + } + + public function update_status(string $order_number, string $new_status, array $extra = []): bool { + return $this->repo()->update_status($order_number, $new_status, $extra); + } + + public function get_stats(int $user_id = 0): array { + return $this->repo()->get_stats($user_id); + } + + public function has_paid_access(int $user_id, string $product_type, int $product_id): bool { + return $this->repo()->has_paid_access($user_id, $product_type, $product_id); + } + + public function get_valid_vip_order(int $user_id): ?array { + return $this->repo()->get_valid_vip_order($user_id); + } + + public function ajax_list(): void { + check_ajax_referer('aksrt_public_nonce', 'nonce'); + if (!current_user_can('manage_options')) { + wp_send_json_error(['message' => __('权限不足', 'aksrt')]); + } + + $status = sanitize_key((string) ($_GET['status'] ?? '')); + $search = sanitize_text_field((string) ($_GET['search'] ?? '')); + $limit = max(1, min(100, (int) ($_GET['limit'] ?? 20))); + $offset = max(0, (int) ($_GET['offset'] ?? 0)); + + $orders = $this->repo()->get_orders_admin([ + 'status' => $status, + 'limit' => $limit, + 'offset' => $offset, + ], $search); + + wp_send_json_success([ + 'orders' => $orders, + 'stats' => $this->get_stats(), + 'total' => $this->repo()->get_orders_count(['status' => $status], $search), + ]); + } + + public function ajax_update_status(): void { + check_ajax_referer('aksrt_public_nonce', 'nonce'); + if (!current_user_can('manage_options')) { + wp_send_json_error(['message' => __('权限不足', 'aksrt')]); + } + + $order_number = sanitize_text_field((string) ($_POST['order_number'] ?? '')); + $new_status = sanitize_key((string) ($_POST['status'] ?? '')); + $note = sanitize_textarea_field((string) ($_POST['note'] ?? '')); + + if ($order_number === '' || $new_status === '') { + wp_send_json_error(['message' => __('参数无效', 'aksrt')]); + } + + if ($this->update_status($order_number, $new_status, ['note' => $note, 'force' => true])) { + wp_send_json_success(['message' => __('订单状态已更新', 'aksrt')]); + } + + wp_send_json_error(['message' => __('更新失败', 'aksrt')]); + } + + public function ajax_stats(): void { + check_ajax_referer('aksrt_public_nonce', 'nonce'); + if (!current_user_can('manage_options')) { + wp_send_json_error(['message' => __('权限不足', 'aksrt')]); + } + + wp_send_json_success($this->get_stats()); + } + + private function repo(): AKSRT_Order_Repository { + return AKSRT_Order_Repository::get_instance(); + } +} diff --git a/inc/classes/class-aksrt-payment-gateway.php b/inc/classes/class-aksrt-payment-gateway.php new file mode 100644 index 00000000..e254c179 --- /dev/null +++ b/inc/classes/class-aksrt-payment-gateway.php @@ -0,0 +1,429 @@ +gateways[$id] = $gateway; + } + + /** + * 获取网关 + */ + public function get_gateway(string $id): ?AKSRT_Payment_Gateway_Interface { + return $this->gateways[$id] ?? null; + } + + /** + * 获取所有已注册网关 + * + * @return AKSRT_Payment_Gateway_Interface[] + */ + public function get_all(): array { + return $this->gateways; + } + + /** + * 获取可用网关列表(已配置的) + */ + public function get_available(): array { + $available = []; + foreach ($this->gateways as $id => $gateway) { + if ($gateway->is_configured()) { + $available[$id] = $gateway; + } + } + return $available; + } + + /** + * 获取网关状态摘要(用于后台展示) + */ + public function get_status_summary(): array { + $summary = []; + foreach ($this->gateways as $id => $gateway) { + $summary[] = [ + 'id' => $id, + 'name' => $gateway->get_name(), + 'configured' => $gateway->is_configured(), + 'available' => $gateway->is_configured(), + ]; + } + return $summary; + } + + /** + * 绑定支付回调路由。必须在 functions.php 中初始化。 + * + * 此前网关 create_payment() 把 notify_url/return_url 指向 + * /wp-json/aksrt/v1/payment/{gateway}/{notify|return|cancel},但这些路由 + * 从未注册,导致所有跳转型网关(支付宝/微信/PayPal/Stripe)无法完成支付。 + */ + public function init(): void { + add_action('rest_api_init', [$this, 'register_routes']); + } + + public function register_routes(): void { + $args = [ + 'methods' => ['GET', 'POST'], + 'permission_callback' => '__return_true', // 真实性由各网关签名校验保证 + 'args' => [ + 'gateway' => ['sanitize_callback' => 'sanitize_key'], + ], + ]; + + register_rest_route('aksrt/v1', '/payment/(?P[a-z0-9_]+)/notify', array_merge($args, [ + 'callback' => [$this, 'handle_notify'], + ])); + register_rest_route('aksrt/v1', '/payment/(?P[a-z0-9_]+)/return', array_merge($args, [ + 'callback' => [$this, 'handle_return'], + ])); + register_rest_route('aksrt/v1', '/payment/(?P[a-z0-9_]+)/cancel', array_merge($args, [ + 'callback' => [$this, 'handle_cancel'], + ])); + } + + /** + * 异步通知(服务器到服务器)。验签 → 到账 → 输出网关期望的 ACK。 + */ + public function handle_notify(WP_REST_Request $request) { + $gateway_id = sanitize_key((string) $request->get_param('gateway')); + $gateway = $this->get_gateway($gateway_id); + if (!$gateway) { + return new WP_REST_Response('gateway not found', 404); + } + + $verify = $gateway->verify_callback($request); + $verified = !empty($verify['success']); + $paid = $verified && ($verify['status'] ?? '') === 'paid'; + + if ($paid) { + $this->complete_order($gateway_id, $verify); + } + + // 网关各自要求的 ACK 格式 + if ($gateway_id === 'wechatpay' && method_exists($gateway, 'output_callback_response')) { + $gateway->output_callback_response(['success' => $verified]); // exit + } + if ($gateway_id === 'alipay') { + // 支付宝异步通知要求返回纯文本 success,否则会重复通知 + header('Content-Type: text/plain; charset=utf-8'); + echo $verified ? 'success' : 'fail'; + exit; + } + + // Stripe / PayPal webhook:200 表示已接收;验签失败返回 400 触发重试 + return new WP_REST_Response(['received' => $verified], $verified ? 200 : 400); + } + + /** + * 用户浏览器同步返回。尽力确认到账(兜底异步通知延迟),再跳回订单中心。 + */ + public function handle_return(WP_REST_Request $request) { + $gateway_id = sanitize_key((string) $request->get_param('gateway')); + $order_number = sanitize_text_field((string) ($request->get_param('order') + ?: $request->get_param('out_trade_no') + ?: '')); + $gateway = $this->get_gateway($gateway_id); + $status = 'pending'; + + if ($gateway) { + if ($gateway_id === 'alipay') { + // 支付宝同步返回带签名参数,可直接验签作为到账兜底 + $verify = $gateway->verify_callback($request); + if (!empty($verify['success'])) { + $order_number = $order_number ?: sanitize_text_field((string) ($verify['order_number'] ?? '')); + if (($verify['status'] ?? '') === 'paid' && $this->complete_order($gateway_id, $verify)) { + $status = 'success'; + } + } + } elseif ($gateway_id === 'paypal' && method_exists($gateway, 'capture_order')) { + // PayPal v2:用户批准后在 return 时捕获(token = PayPal 订单号) + $paypal_order_id = sanitize_text_field((string) ($request->get_param('token') ?: '')); + if ($paypal_order_id !== '') { + $cap = $gateway->capture_order($paypal_order_id); + if (!empty($cap['success']) && ($cap['status'] ?? '') === 'paid') { + $order_number = $order_number ?: sanitize_text_field((string) ($cap['order_number'] ?? '')); + $cap['order_number'] = $order_number; + if ($this->complete_order($gateway_id, $cap)) { + $status = 'success'; + } + } + } + } elseif ($order_number !== '') { + // 其余网关:主动查询订单状态兜底 + $q = $gateway->query_status($order_number); + if (!empty($q['success']) && ($q['status'] ?? '') === 'paid') { + $done = $this->complete_order($gateway_id, [ + 'order_number' => $order_number, + 'amount' => 0, // 由网关 API 已确认到账,金额校验交给 notify 路径 + 'transaction_id' => $q['transaction_id'] ?? '', + 'status' => 'paid', + ]); + if ($done) { + $status = 'success'; + } + } + } + } + + $this->redirect_to_orders($order_number, $status); + return null; + } + + /** + * 用户取消支付,跳回订单中心。 + */ + public function handle_cancel(WP_REST_Request $request) { + $order_number = sanitize_text_field((string) ($request->get_param('order') + ?: $request->get_param('out_trade_no') + ?: '')); + $this->redirect_to_orders($order_number, 'cancelled'); + return null; + } + + /** + * 幂等、带金额校验的订单完成。仅由验签通过的回调调用。 + * + * @param string $gateway_id 网关 ID + * @param array $verify 网关 verify_callback / query 结果 + * @return bool + */ + public function complete_order(string $gateway_id, array $verify): bool { + $order_number = sanitize_text_field((string) ($verify['order_number'] ?? '')); + if ($order_number === '' || !class_exists('AKSRT_Order_Repository')) { + return false; + } + + $repo = AKSRT_Order_Repository::get_instance(); + $order = $repo->get($order_number); + if (!$order) { + return false; + } + + $current = (string) ($order['status'] ?? ''); + + // 幂等:已支付/已交付/已完成视为成功,不重复处理(回调可能多次到达) + if (in_array($current, ['paid', 'delivered', 'completed'], true)) { + return true; + } + // 只允许从未支付态完成 + if (!in_array($current, ['created', 'pending'], true)) { + return false; + } + + // 金额校验,防止前端/回调金额被篡改 + $expected = (float) ($order['amount'] ?? 0); + $paid_amount = (float) ($verify['amount'] ?? 0); + if ($expected > 0 && $paid_amount > 0 && abs($expected - $paid_amount) > 0.01) { + if (defined('WP_DEBUG') && WP_DEBUG) { + error_log(sprintf('[AKSRT Payment] amount mismatch order=%s expected=%.2f paid=%.2f gateway=%s', $order_number, $expected, $paid_amount, $gateway_id)); + } + return false; + } + + return $repo->update_status($order_number, AKSRT_Order::STATUS_PAID, [ + 'payment_method' => $gateway_id, + 'payment_transaction_id' => sanitize_text_field((string) ($verify['transaction_id'] ?? '')), + 'note' => __('支付网关回调确认到账。', 'aksrt'), + ]); + } + + private function redirect_to_orders(string $order_number, string $status): void { + $url = add_query_arg(array_filter([ + 'payment' => sanitize_key($status), + 'order' => $order_number, + ]), $this->orders_page_url()); + + nocache_headers(); + wp_safe_redirect($url); + exit; + } + + private function orders_page_url(): string { + $pages = get_posts([ + 'post_type' => 'page', + 'post_status' => 'publish', + 'posts_per_page' => 1, + 'meta_key' => '_wp_page_template', + 'meta_value' => 'templates/pages/page-orders.php', + 'fields' => 'ids', + 'no_found_rows' => true, + ]); + + if (!empty($pages)) { + return (string) get_permalink((int) $pages[0]); + } + + return home_url('/orders/'); + } +} + +/** + * 站内余额支付网关(内置实现) + * + * 使用积分/余额进行支付,无需第三方。 + */ +class AKSRT_Balance_Gateway implements AKSRT_Payment_Gateway_Interface { + + public function get_id(): string { + return 'balance'; + } + + public function get_name(): string { + return __('站内余额', 'aksrt'); + } + + public function is_configured(): bool { + return class_exists('AKSRT_Credits'); + } + + public function create_payment(array $order): array { + if (!class_exists('AKSRT_Credits')) { + return ['success' => false, 'message' => __('积分系统未启用', 'aksrt')]; + } + + $user_id = (int) ($order['user_id'] ?? 0); + $amount = (float) ($order['amount'] ?? 0); + $credits_cost = (int) ceil($amount * 100); // 1 元 = 100 积分 + + $balance = AKSRT_Credits::balance($user_id); + + if ($balance < $credits_cost) { + return ['success' => false, 'message' => __('余额不足', 'aksrt')]; + } + + // Deduct credits + $result = AKSRT_Credits::deduct($user_id, $credits_cost, 'order_payment', sprintf(__('订单 %s 支付', 'aksrt'), $order['order_number'] ?? '')); + + if ($result) { + return [ + 'success' => true, + 'transaction_id' => 'BAL_' . ($order['order_number'] ?? time()), + 'message' => __('余额支付成功', 'aksrt'), + ]; + } + + return ['success' => false, 'message' => __('扣款失败', 'aksrt')]; + } + + public function verify_callback(WP_REST_Request $request): array { + // Balance payment is synchronous — no callback needed + return ['success' => false, 'message' => __('余额支付无需回调验证', 'aksrt')]; + } + + public function query_status(string $order_number): array { + $order_repo = AKSRT_Order_Repository::get_instance(); + $order = $order_repo->get($order_number); + + if (!$order) { + return ['success' => false, 'message' => __('订单不存在', 'aksrt')]; + } + + return [ + 'success' => true, + 'status' => $order['status'], + 'transaction_id' => $order['payment_transaction_id'] ?? '', + ]; + } + + public function refund(string $order_number, float $amount): array { + if (!class_exists('AKSRT_Credits')) { + return ['success' => false, 'message' => __('积分系统未启用', 'aksrt')]; + } + + $order_repo = AKSRT_Order_Repository::get_instance(); + $order = $order_repo->get($order_number); + + if (!$order) { + return ['success' => false, 'message' => __('订单不存在', 'aksrt')]; + } + + $user_id = (int) $order['user_id']; + $credits_refund = (int) ceil($amount * 100); + + $result = AKSRT_Credits::add($user_id, $credits_refund, 'order_refund', sprintf(__('订单 %s 退款', 'aksrt'), $order_number)); + + if ($result) { + return ['success' => true, 'refund_id' => 'BAL_REF_' . $order_number, 'message' => __('退款成功', 'aksrt')]; + } + + return ['success' => false, 'message' => __('退款失败', 'aksrt')]; + } +} diff --git a/inc/classes/class-aksrt-post-meta-box.php b/inc/classes/class-aksrt-post-meta-box.php new file mode 100644 index 00000000..2849cc15 --- /dev/null +++ b/inc/classes/class-aksrt-post-meta-box.php @@ -0,0 +1,550 @@ + []]; + if (!empty($filter_settings['custom_filter'])) { + add_meta_box( + 'aksrt_custom_filter_meta', + __('高级筛选', 'aksrt'), + [$this, 'render_custom_filter_meta_box'], + 'post', + 'side', + 'default' + ); + } + } + + /** + * Render the page meta box (page_header_style / cover override). + */ + public function render_page_meta_box(WP_Post $post): void { + $header_style = get_post_meta($post->ID, 'page_header_style', true); + $cover_img = get_post_meta($post->ID, 'page_header_cover_img', true); + wp_nonce_field('aksrt_page_meta', 'aksrt_page_meta_nonce'); + ?> +

+ + + +

+ + + ID); + wp_nonce_field('aksrt_post_meta', 'aksrt_post_meta_nonce'); + ?> +

+

+ + +

+ +

+ + +

+ + +

+ +

+ +

+ __('阅读进度条', 'aksrt'), + 'enable_lightbox' => __('图片灯箱', 'aksrt'), + 'show_related' => __('相关推荐', 'aksrt'), + 'enable_like' => __('点赞按钮', 'aksrt'), + 'enable_favorite' => __('收藏按钮', 'aksrt'), + 'enable_share' => __('分享按钮', 'aksrt'), + 'enable_print' => __('打印按钮', 'aksrt'), + 'enable_pdf' => __('PDF 导出按钮', 'aksrt'), + ]; + foreach ($toggle_fields as $field_key => $field_label) : + ?> +

+ + + +

+ + +

+

+ + +

+ +

+ +

+ +

+ + +

+ + +
+

+ + +

+ + + +

+ + + +

+ + +

+ + 0 表示第 N 集。', 'aksrt'); ?> + +
+

+

+ + +

+

+ + +

+ + []]; + $dimensions = $filter_settings['custom_filter']; + + if (empty($dimensions)) { + echo '

' . esc_html__('尚未定义筛选维度,请前往 文章设置 → 高级筛选 添加。', 'aksrt') . '

'; + return; + } + + foreach ($dimensions as $dim) : + $meta_key = '_aksrt_cf_' . $dim['key']; + $current = get_post_meta($post->ID, $meta_key, true); + ?> +

+ + +

+ save_quick_edit($post_id); + return; + } + + $raw = $_POST['aksrt_post_meta'] ?? []; + $sanitized = [ + 'cover_images' => isset($raw['cover_images']) ? sanitize_text_field($raw['cover_images']) : '', + 'custom_excerpt' => isset($raw['custom_excerpt']) ? sanitize_textarea_field($raw['custom_excerpt']) : '', + 'layout_override' => isset($raw['layout_override']) ? sanitize_key($raw['layout_override']) : '', + 'header_image_mode' => isset($raw['header_image_mode']) ? sanitize_key($raw['header_image_mode']) : '', + 'show_toc' => !empty($raw['show_toc']) ? '1' : '', + 'show_reading_progress' => isset($raw['show_reading_progress']) ? sanitize_key((string) $raw['show_reading_progress']) : '', + 'enable_lightbox' => isset($raw['enable_lightbox']) ? sanitize_key((string) $raw['enable_lightbox']) : '', + 'show_related' => isset($raw['show_related']) ? sanitize_key((string) $raw['show_related']) : '', + 'related_count' => isset($raw['related_count']) ? (int) $raw['related_count'] : 0, + 'enable_like' => isset($raw['enable_like']) ? sanitize_key((string) $raw['enable_like']) : '', + 'enable_favorite' => isset($raw['enable_favorite']) ? sanitize_key((string) $raw['enable_favorite']) : '', + 'enable_share' => isset($raw['enable_share']) ? sanitize_key((string) $raw['enable_share']) : '', + 'enable_print' => isset($raw['enable_print']) ? sanitize_key((string) $raw['enable_print']) : '', + 'enable_pdf' => isset($raw['enable_pdf']) ? sanitize_key((string) $raw['enable_pdf']) : '', + 'copyright_text' => isset($raw['copyright_text']) ? sanitize_textarea_field($raw['copyright_text']) : '', + 'is_featured' => !empty($raw['is_featured']), + // §3.8 文章扩展字段 + 'thumbnail_url' => isset($raw['thumbnail_url']) ? esc_url_raw((string) $raw['thumbnail_url']) : '', + 'featured_slide' => isset($raw['featured_slide']) ? sanitize_text_field($raw['featured_slide']) : '', + 'featured_video' => isset($raw['featured_video']) ? esc_url_raw((string) $raw['featured_video']) : '', + 'featured_video_episode' => isset($raw['featured_video_episode']) ? max(0, (int) $raw['featured_video_episode']) : 0, + 'subtitle' => isset($raw['subtitle']) ? sanitize_text_field($raw['subtitle']) : '', + 'views' => isset($raw['views']) ? max(0, (int) $raw['views']) : 0, + 'likes' => isset($raw['likes']) ? max(0, (int) $raw['likes']) : 0, + 'maxheight' => isset($raw['maxheight']) ? max(0, (int) $raw['maxheight']) : 0, + ]; + + $valid_modes = ['', 'card', 'list', 'mini', 'grid-cms']; + if (!in_array($sanitized['layout_override'], $valid_modes, true)) { + $sanitized['layout_override'] = ''; + } + + $valid_header_modes = ['', 'featured', 'auto', 'none']; + if (!in_array($sanitized['header_image_mode'], $valid_header_modes, true)) { + $sanitized['header_image_mode'] = ''; + } + + foreach (['show_reading_progress', 'enable_lightbox', 'show_related', 'enable_like', 'enable_favorite', 'enable_share', 'enable_print', 'enable_pdf'] as $toggle_key) { + if (!in_array($sanitized[$toggle_key], ['', '0', '1'], true)) { + $sanitized[$toggle_key] = ''; + } + } + + $sanitized['related_count'] = max(0, min(20, (int) $sanitized['related_count'])); + + update_post_meta($post_id, self::META_KEY, $sanitized); + + // §3.8 Save custom filter dimension values as individual post meta + if (isset($_POST['aksrt_custom_filter_meta_nonce']) && wp_verify_nonce($_POST['aksrt_custom_filter_meta_nonce'], 'aksrt_custom_filter_meta')) { + if (!defined('DOING_AUTOSAVE') && current_user_can('edit_post', $post_id)) { + $cf_settings = class_exists('AKSRT_Settings') + ? AKSRT_Settings::get_post_filter_settings() + : ['custom_filter' => []]; + foreach ($cf_settings['custom_filter'] as $dim) { + $meta_key = '_aksrt_cf_' . $dim['key']; + $value = isset($_POST[$meta_key]) ? sanitize_key((string) $_POST[$meta_key]) : ''; + if ($value !== '') { + update_post_meta($post_id, $meta_key, $value); + } else { + delete_post_meta($post_id, $meta_key); + } + } + } + } + } + + /** + * §3.8 Add custom columns to the posts list table. + */ + public function add_columns(array $columns): array { + $columns['aksrt_views'] = __('阅读量', 'aksrt'); + $columns['aksrt_likes'] = __('点赞数', 'aksrt'); + return $columns; + } + + /** + * §3.8 Render custom column content. + */ + public function render_column(string $column, int $post_id): void { + $meta = self::get_meta($post_id); + if ($column === 'aksrt_views') { + $views = (int) ($meta['views'] ?? 0); + echo esc_html($views > 0 ? (string) $views : '—'); + } elseif ($column === 'aksrt_likes') { + $likes = (int) ($meta['likes'] ?? 0); + $layout = esc_attr($meta['layout_override'] ?? ''); + $toc = esc_attr($meta['show_toc'] ?? ''); + $mh = (int) ($meta['maxheight'] ?? 0); + echo '' . esc_html($likes > 0 ? (string) $likes : '—') . ''; + } + } + + /** + * §3.8 Render quick edit and bulk edit fields. + */ + public function render_quick_edit(string $column_name, string $post_type): void { + if ($column_name !== 'aksrt_views' || $post_type !== 'post') { + return; + } + wp_nonce_field('aksrt_post_meta', 'aksrt_post_meta_nonce_qe'); + ?> +
+
+

+ + + + + + +
+
+
+ base !== 'edit' || $screen->post_type !== 'post') { + return; + } + ?> + + prefix . 'aksrt_conversation'; + } + + private function table_msg(): string { + global $wpdb; + return $wpdb->prefix . 'aksrt_conversation_message'; + } + + /* ------------------------------------------------------------------ + * Conversation CRUD + * ----------------------------------------------------------------*/ + + /** + * Get or create a 1-to-1 conversation between two users. + */ + public function get_or_create_conversation(int $user_a, int $user_b): int { + global $wpdb; + + if ($user_a <= 0 || $user_b <= 0 || $user_a === $user_b) return 0; + + // Ensure consistent ordering + $min_id = min($user_a, $user_b); + $max_id = max($user_a, $user_b); + + $existing = $wpdb->get_var($wpdb->prepare( + "SELECT id FROM {$this->table_conv()} WHERE user_a = %d AND user_b = %d", + $min_id, $max_id + )); + + if ($existing) return (int) $existing; + + $wpdb->insert($this->table_conv(), [ + 'user_a' => $min_id, + 'user_b' => $max_id, + 'last_message' => '', + 'last_message_at' => current_time('mysql'), + 'unread_a' => 0, + 'unread_b' => 0, + 'created_at' => current_time('mysql'), + ], ['%d', '%d', '%s', '%s', '%d', '%d', '%s']); + + return (int) $wpdb->insert_id; + } + + /** + * List conversations for a user (most recent first). + * + * @param int $user_id + * @param int $limit + * @return array + */ + public function get_conversations(int $user_id, int $limit = 20): array { + global $wpdb; + + if ($user_id <= 0) return []; + + $table_conv = $this->table_conv(); + $table_msg = $this->table_msg(); + + $rows = $wpdb->get_results($wpdb->prepare( + "SELECT c.*, + CASE WHEN c.user_a = %d THEN c.unread_a ELSE c.unread_b END AS my_unread, + CASE WHEN c.user_a = %d THEN c.user_b ELSE c.user_a END AS other_user_id + FROM {$table_conv} c + WHERE c.user_a = %d OR c.user_b = %d + ORDER BY c.last_message_at DESC + LIMIT %d", + $user_id, $user_id, $user_id, $user_id, $limit + ), ARRAY_A); + + $conversations = []; + foreach ((array) $rows as $row) { + $other_id = (int) $row['other_user_id']; + $other_user = get_userdata($other_id); + $conversations[] = [ + 'id' => (int) $row['id'], + 'other_user_id' => $other_id, + 'other_name' => $other_user ? $other_user->display_name : __('用户', 'aksrt'), + 'other_avatar' => $other_user ? get_avatar_url($other_id, ['size' => 48]) : '', + 'last_message' => $row['last_message'] ?? '', + 'last_message_at' => $row['last_message_at'] ?? '', + 'unread' => (int) $row['my_unread'], + ]; + } + + return $conversations; + } + + /* ------------------------------------------------------------------ + * Message CRUD + * ----------------------------------------------------------------*/ + + /** + * Send a message in a conversation. + * + * @param int $conversation_id + * @param int $sender_id + * @param string $content + * @return array{success: bool, message_id?: int, message: string} + */ + public function send(int $conversation_id, int $sender_id, string $content): array { + global $wpdb; + + if ($conversation_id <= 0 || $sender_id <= 0) { + return ['success' => false, 'message' => __('参数无效', 'aksrt')]; + } + + $content = sanitize_textarea_field($content); + if ($content === '') { + return ['success' => false, 'message' => __('消息不能为空', 'aksrt')]; + } + + // Verify user is a participant + $conv = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM {$this->table_conv()} WHERE id = %d", + $conversation_id + ), ARRAY_A); + + if (!$conv) { + return ['success' => false, 'message' => __('会话不存在', 'aksrt')]; + } + + if ((int) $conv['user_a'] !== $sender_id && (int) $conv['user_b'] !== $sender_id) { + return ['success' => false, 'message' => __('无权操作', 'aksrt')]; + } + + $now = current_time('mysql'); + + // Insert message + $wpdb->insert($this->table_msg(), [ + 'conversation_id' => $conversation_id, + 'sender_id' => $sender_id, + 'content' => $content, + 'created_at' => $now, + ], ['%d', '%d', '%s', '%s']); + + $message_id = (int) $wpdb->insert_id; + + if (!$message_id) { + return ['success' => false, 'message' => __('发送失败', 'aksrt')]; + } + + // Update conversation metadata + $is_a = (int) $conv['user_a'] === $sender_id; + $unread_field = $is_a ? 'unread_b' : 'unread_a'; + + $wpdb->update($this->table_conv(), [ + 'last_message' => mb_substr($content, 0, 200), + 'last_message_at' => $now, + $unread_field => (int) $conv[$unread_field] + 1, + ], ['id' => $conversation_id]); + + // Create notification for the recipient + $recipient_id = $is_a ? (int) $conv['user_b'] : (int) $conv['user_a']; + if (class_exists('AKSRT_Message')) { + $sender_name = ''; + $sender_user = get_userdata($sender_id); + if ($sender_user) { + $sender_name = $sender_user->display_name; + } + AKSRT_Message::get_instance()->send( + $recipient_id, + 'conversation', + mb_substr($content, 0, 100), + [ + 'title' => sprintf(__('%s 发来私信', 'aksrt'), $sender_name), + 'target_url' => add_query_arg(['msg_type' => 'conversation']), + ] + ); + } + + do_action('aksrt_private_message_sent', $conversation_id, $sender_id, $recipient_id, $message_id); + + return ['success' => true, 'message_id' => $message_id, 'message' => __('已发送', 'aksrt')]; + } + + /** + * Get messages in a conversation (paginated, newest first). + * + * @param int $conversation_id + * @param int $user_id Must be a participant. + * @param int $page + * @param int $per_page + * @return array + */ + public function get_thread(int $conversation_id, int $user_id, int $page = 1, int $per_page = 30): array { + global $wpdb; + + if ($conversation_id <= 0 || $user_id <= 0) return ['messages' => [], 'total' => 0]; + + // Verify participation + $conv = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM {$this->table_conv()} WHERE id = %d", + $conversation_id + ), ARRAY_A); + + if (!$conv || ((int) $conv['user_a'] !== $user_id && (int) $conv['user_b'] !== $user_id)) { + return ['messages' => [], 'total' => 0]; + } + + $offset = max(0, ($page - 1) * $per_page); + + $total = (int) $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM {$this->table_msg()} WHERE conversation_id = %d", + $conversation_id + )); + + $rows = $wpdb->get_results($wpdb->prepare( + "SELECT * FROM {$this->table_msg()} + WHERE conversation_id = %d + ORDER BY created_at DESC + LIMIT %d OFFSET %d", + $conversation_id, $per_page, $offset + ), ARRAY_A); + + $messages = []; + foreach ((array) $rows as $row) { + $sender = get_userdata((int) $row['sender_id']); + $messages[] = [ + 'id' => (int) $row['id'], + 'sender_id' => (int) $row['sender_id'], + 'sender_name' => $sender ? $sender->display_name : __('用户', 'aksrt'), + 'sender_avatar' => $sender ? get_avatar_url((int) $row['sender_id'], ['size' => 32]) : '', + 'content' => $row['content'] ?? '', + 'is_mine' => (int) $row['sender_id'] === $user_id, + 'created_at' => $row['created_at'] ?? '', + ]; + } + + // Mark as read for this user + $this->mark_conversation_read($conversation_id, $user_id); + + return [ + 'messages' => $messages, + 'total' => $total, + 'page' => $page, + 'pages' => max(1, (int) ceil($total / $per_page)), + ]; + } + + /** + * Mark all messages in a conversation as read for a user. + */ + public function mark_conversation_read(int $conversation_id, int $user_id): void { + global $wpdb; + + $conv = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM {$this->table_conv()} WHERE id = %d", + $conversation_id + ), ARRAY_A); + + if (!$conv) return; + + $is_a = (int) $conv['user_a'] === $user_id; + $field = $is_a ? 'unread_a' : 'unread_b'; + + $wpdb->update($this->table_conv(), [$field => 0], ['id' => $conversation_id]); + } + + /** + * Get total unread conversation count for a user. + */ + public function get_unread_count(int $user_id): int { + global $wpdb; + + if ($user_id <= 0) return 0; + + $count = $wpdb->get_var($wpdb->prepare( + "SELECT SUM(CASE WHEN user_a = %d THEN unread_a ELSE unread_b END) + FROM {$this->table_conv()} + WHERE user_a = %d OR user_b = %d", + $user_id, $user_id, $user_id + )); + + return max(0, (int) $count); + } + + /* ------------------------------------------------------------------ + * AJAX Handlers + * ----------------------------------------------------------------*/ + + public function ajax_send(): void { + check_ajax_referer('aksrt_pm', '_nonce'); + + $user_id = get_current_user_id(); + if ($user_id <= 0) wp_send_json_error(['message' => __('请先登录', 'aksrt')]); + + $recipient_id = (int) ($_POST['recipient_id'] ?? 0); + $content = sanitize_textarea_field((string) ($_POST['content'] ?? '')); + + if ($recipient_id <= 0) wp_send_json_error(['message' => __('收件人无效', 'aksrt')]); + + $conv_id = $this->get_or_create_conversation($user_id, $recipient_id); + if (!$conv_id) wp_send_json_error(['message' => __('无法创建会话', 'aksrt')]); + + $result = $this->send($conv_id, $user_id, $content); + + if ($result['success']) { + wp_send_json_success($result); + } + wp_send_json_error($result); + } + + public function ajax_conversations(): void { + check_ajax_referer('aksrt_pm', '_nonce'); + + $user_id = get_current_user_id(); + if ($user_id <= 0) wp_send_json_error(['message' => __('请先登录', 'aksrt')]); + + $limit = max(1, min(50, (int) ($_GET['limit'] ?? 20))); + $conversations = $this->get_conversations($user_id, $limit); + + wp_send_json_success(['conversations' => $conversations]); + } + + public function ajax_thread(): void { + check_ajax_referer('aksrt_pm', '_nonce'); + + $user_id = get_current_user_id(); + if ($user_id <= 0) wp_send_json_error(['message' => __('请先登录', 'aksrt')]); + + $conv_id = (int) ($_GET['conversation_id'] ?? 0); + $page = max(1, (int) ($_GET['page'] ?? 1)); + + $result = $this->get_thread($conv_id, $user_id, $page); + wp_send_json_success($result); + } + + public function ajax_mark_read(): void { + check_ajax_referer('aksrt_pm', '_nonce'); + + $user_id = get_current_user_id(); + if ($user_id <= 0) wp_send_json_error(['message' => __('请先登录', 'aksrt')]); + + $conv_id = (int) ($_POST['conversation_id'] ?? 0); + $this->mark_conversation_read($conv_id, $user_id); + + wp_send_json_success(['unread' => $this->get_unread_count($user_id)]); + } + + public function ajax_unread_count(): void { + check_ajax_referer('aksrt_pm', '_nonce'); + + $user_id = get_current_user_id(); + if ($user_id <= 0) wp_send_json_error(['message' => __('请先登录', 'aksrt')]); + + wp_send_json_success(['unread' => $this->get_unread_count($user_id)]); + } +} diff --git a/inc/classes/class-aksrt-product-review.php b/inc/classes/class-aksrt-product-review.php new file mode 100644 index 00000000..60cf0cdd --- /dev/null +++ b/inc/classes/class-aksrt-product-review.php @@ -0,0 +1,313 @@ + '超赞', + 4 => '很棒', + 3 => '不错', + 2 => '一般', + 1 => '较差', + 0 => '很差', + ]; + + public static function get_instance(): self { + if (null === self::$instance) { + self::$instance = new self(); + } + return self::$instance; + } + + public function init(): void { + add_action('aksrt_daily_cron', [$this, 'run_auto_positive_cron']); + } + + public function submit_review(int $user_id, string $order_number, int $product_id, array $data): array { + if ($user_id <= 0 || $order_number === '' || $product_id <= 0) { + return ['success' => false, 'message' => __('参数错误', 'aksrt')]; + } + + if (!$this->can_review($user_id, $order_number, $product_id)) { + return ['success' => false, 'message' => __('当前订单不可评价', 'aksrt')]; + } + + if ($this->has_reviewed($user_id, $order_number, $product_id)) { + return ['success' => false, 'message' => __('已评价过该商品', 'aksrt')]; + } + + $rating_product = max(0, min(5, (int) ($data['rating_product'] ?? 5))); + $rating_service = max(0, min(5, (int) ($data['rating_service'] ?? 5))); + $rating_shipping = max(0, min(5, (int) ($data['rating_shipping'] ?? 5))); + $average = round(($rating_product + $rating_service + $rating_shipping) / 3, 1); + + $content = sanitize_textarea_field((string) ($data['content'] ?? '')); + $images = []; + $max_images = $this->get_max_images(); + foreach ((array) ($data['images'] ?? []) as $img) { + if (count($images) >= $max_images) break; + $url = esc_url_raw((string) $img); + if ($url !== '') { + $images[] = $url; + } + } + + $comment_id = wp_insert_comment([ + 'comment_post_ID' => $product_id, + 'user_id' => $user_id, + 'comment_content' => $content ?: __('好评!', 'aksrt'), + 'comment_type' => self::COMMENT_TYPE, + 'comment_approved' => 1, + ]); + + if (!$comment_id) { + return ['success' => false, 'message' => __('评价提交失败', 'aksrt')]; + } + + update_comment_meta($comment_id, '_aksrt_order_number', $order_number); + update_comment_meta($comment_id, '_aksrt_rating_product', $rating_product); + update_comment_meta($comment_id, '_aksrt_rating_service', $rating_service); + update_comment_meta($comment_id, '_aksrt_rating_shipping', $rating_shipping); + update_comment_meta($comment_id, '_aksrt_rating_average', $average); + if (!empty($images)) { + update_comment_meta($comment_id, '_aksrt_review_images', $images); + } + + $this->refresh_product_scores($product_id); + + if (class_exists('AKSRT_Order_Repository')) { + $order = AKSRT_Order_Repository::get_instance()->get($order_number); + $status = (string) ($order['status'] ?? ''); + if ($status === AKSRT_Order::STATUS_PENDING_REVIEW) { + AKSRT_Order_Repository::get_instance()->update_status( + $order_number, + AKSRT_Order::STATUS_COMPLETED, + ['note' => __('用户已评价', 'aksrt')] + ); + } + } + + return ['success' => true, 'comment_id' => $comment_id]; + } + + public function can_review(int $user_id, string $order_number, int $product_id): bool { + if (!class_exists('AKSRT_Order_Repository')) return false; + + $order = AKSRT_Order_Repository::get_instance()->get($order_number); + if (!$order || (int) ($order['user_id'] ?? 0) !== $user_id) return false; + + $status = (string) ($order['status'] ?? ''); + if (!in_array($status, [ + AKSRT_Order::STATUS_COMPLETED, + AKSRT_Order::STATUS_PENDING_REVIEW, + AKSRT_Order::STATUS_DELIVERED, + ], true)) { + return false; + } + + if (class_exists('AKSRT_After_Sale')) { + $tickets = AKSRT_After_Sale::get_instance()->get_list([ + 'order_number' => $order_number, + 'limit' => 1, + ]); + foreach (($tickets['items'] ?? []) as $ticket) { + if (in_array(($ticket['status'] ?? ''), ['pending', 'processing'], true)) { + return false; + } + } + } + + return true; + } + + public function has_reviewed(int $user_id, string $order_number, int $product_id): bool { + $comments = get_comments([ + 'post_id' => $product_id, + 'user_id' => $user_id, + 'type' => self::COMMENT_TYPE, + 'number' => 1, + 'meta_query' => [ + ['key' => '_aksrt_order_number', 'value' => $order_number], + ], + ]); + return !empty($comments); + } + + public function get_reviews(int $product_id, array $args = []): array { + $page = max(1, (int) ($args['page'] ?? 1)); + $per_page = max(1, min(50, (int) ($args['per_page'] ?? 10))); + $filter = sanitize_key((string) ($args['filter'] ?? '')); + + $query_args = [ + 'post_id' => $product_id, + 'type' => self::COMMENT_TYPE, + 'status' => 'approve', + 'number' => $per_page, + 'offset' => ($page - 1) * $per_page, + 'orderby' => 'comment_date', + 'order' => 'DESC', + ]; + + if ($filter === 'good') { + $query_args['meta_query'] = [['key' => '_aksrt_rating_average', 'value' => 4, 'compare' => '>=', 'type' => 'NUMERIC']]; + } elseif ($filter === 'bad') { + $query_args['meta_query'] = [['key' => '_aksrt_rating_average', 'value' => 3, 'compare' => '<', 'type' => 'NUMERIC']]; + } elseif ($filter === 'has_image') { + $query_args['meta_query'] = [['key' => '_aksrt_review_images', 'compare' => 'EXISTS']]; + } + + $comments = get_comments($query_args); + $total = (int) get_comments(array_merge($query_args, ['count' => true, 'number' => 0, 'offset' => 0])); + + $items = []; + foreach ($comments as $comment) { + $cid = (int) $comment->comment_ID; + $items[] = [ + 'id' => $cid, + 'user_id' => (int) $comment->user_id, + 'user_name' => (string) $comment->comment_author, + 'user_avatar' => function_exists('aksrt_get_avatar_url') ? aksrt_get_avatar_url((int) $comment->user_id) : get_avatar_url((int) $comment->user_id), + 'content' => (string) $comment->comment_content, + 'date' => (string) $comment->comment_date, + 'rating_product' => (int) get_comment_meta($cid, '_aksrt_rating_product', true), + 'rating_service' => (int) get_comment_meta($cid, '_aksrt_rating_service', true), + 'rating_shipping' => (int) get_comment_meta($cid, '_aksrt_rating_shipping', true), + 'rating_average' => (float) get_comment_meta($cid, '_aksrt_rating_average', true), + 'images' => (array) (get_comment_meta($cid, '_aksrt_review_images', true) ?: []), + 'order_number' => (string) get_comment_meta($cid, '_aksrt_order_number', true), + ]; + } + + return ['items' => $items, 'total' => $total, 'page' => $page, 'per_page' => $per_page]; + } + + public function get_product_scores(int $product_id): array { + $cached = get_post_meta($product_id, '_aksrt_review_scores', true); + if (is_array($cached)) { + return $cached; + } + return $this->refresh_product_scores($product_id); + } + + public function refresh_product_scores(int $product_id): array { + global $wpdb; + + $results = $wpdb->get_row($wpdb->prepare( + "SELECT COUNT(*) as total, + AVG(CAST(m.meta_value AS DECIMAL(3,1))) as average + FROM {$wpdb->comments} c + JOIN {$wpdb->commentmeta} m ON c.comment_ID = m.comment_id AND m.meta_key = '_aksrt_rating_average' + WHERE c.comment_post_ID = %d AND c.comment_type = %s AND c.comment_approved = '1'", + $product_id, self::COMMENT_TYPE + ), ARRAY_A); + + $total = (int) ($results['total'] ?? 0); + $average = $total > 0 ? round((float) ($results['average'] ?? 0), 1) : 0; + + $good_count = (int) $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->comments} c + JOIN {$wpdb->commentmeta} m ON c.comment_ID = m.comment_id AND m.meta_key = '_aksrt_rating_average' + WHERE c.comment_post_ID = %d AND c.comment_type = %s AND c.comment_approved = '1' + AND CAST(m.meta_value AS DECIMAL(3,1)) >= 4", + $product_id, self::COMMENT_TYPE + )); + + $bad_count = (int) $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->comments} c + JOIN {$wpdb->commentmeta} m ON c.comment_ID = m.comment_id AND m.meta_key = '_aksrt_rating_average' + WHERE c.comment_post_ID = %d AND c.comment_type = %s AND c.comment_approved = '1' + AND CAST(m.meta_value AS DECIMAL(3,1)) < 3", + $product_id, self::COMMENT_TYPE + )); + + $has_image = (int) $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->comments} c + JOIN {$wpdb->commentmeta} m ON c.comment_ID = m.comment_id AND m.meta_key = '_aksrt_review_images' + WHERE c.comment_post_ID = %d AND c.comment_type = %s AND c.comment_approved = '1'", + $product_id, self::COMMENT_TYPE + )); + + $scores = [ + 'total' => $total, + 'average' => $average, + 'good' => $good_count, + 'bad' => $bad_count, + 'has_image' => $has_image, + 'level' => self::RATING_LEVELS[min(5, max(0, (int) round($average)))] ?? '', + ]; + + update_post_meta($product_id, '_aksrt_review_scores', $scores); + return $scores; + } + + public function run_auto_positive_cron(): void { + if (!class_exists('AKSRT_Order_Repository') || !class_exists('AKSRT_Settings')) { + return; + } + + $shop = AKSRT_Settings::get_shop_settings(); + $max_day = max(1, (int) ($shop['timing']['comment_max_day'] ?? 15)); + $cutoff = date('Y-m-d H:i:s', strtotime("-{$max_day} days")); + + $repo = AKSRT_Order_Repository::get_instance(); + $orders = $repo->get_orders_admin([ + 'status' => AKSRT_Order::STATUS_PENDING_REVIEW, + 'limit' => 200, + ]); + + foreach ($orders as $order) { + $completed_at = (string) ($order['completed_at'] ?? $order['delivered_at'] ?? ''); + if ($completed_at === '' || $completed_at > $cutoff) { + continue; + } + + $user_id = (int) ($order['user_id'] ?? 0); + $order_number = (string) ($order['order_number'] ?? ''); + if ($user_id <= 0 || $order_number === '') continue; + + $items = class_exists('AKSRT_Shop') + ? AKSRT_Shop::get_instance()->get_order_items($order_number) + : []; + + $product_ids = array_column($items, 'product_id'); + if (empty($product_ids) && !empty($order['product_id'])) { + $product_ids = [(int) $order['product_id']]; + } + + foreach ($product_ids as $pid) { + if (!$this->has_reviewed($user_id, $order_number, (int) $pid)) { + $this->submit_review($user_id, $order_number, (int) $pid, [ + 'rating_product' => 5, + 'rating_service' => 5, + 'rating_shipping' => 5, + 'content' => __('系统默认好评', 'aksrt'), + ]); + } + } + + $repo->update_status($order_number, AKSRT_Order::STATUS_COMPLETED, [ + 'note' => sprintf(__('超过 %d 天未评价,系统自动好评。', 'aksrt'), $max_day), + ]); + } + } + + private function get_max_images(): int { + if (!class_exists('AKSRT_Settings')) return 9; + $shop = AKSRT_Settings::get_shop_settings(); + return max(1, min(20, (int) ($shop['timing']['comment_img_num'] ?? 9))); + } +} diff --git a/inc/classes/class-aksrt-rebate.php b/inc/classes/class-aksrt-rebate.php new file mode 100644 index 00000000..f79ab8a4 --- /dev/null +++ b/inc/classes/class-aksrt-rebate.php @@ -0,0 +1,179 @@ +insert($this->table(), [ + 'user_id' => $user_id, + 'order_number' => $order_number, + 'source_user_id' => $source_user_id > 0 ? $source_user_id : null, + 'amount' => $amount, + 'type' => $type, + 'status' => self::STATUS_PENDING, + 'created_at' => current_time('mysql'), + 'note' => $note, + ]); + + return $result ? (int) $wpdb->insert_id : 0; + } + + /** + * 结算返佣 + */ + public function settle(int $id): bool { + global $wpdb; + + $row = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM {$this->table()} WHERE id = %d AND status = %s", + $id, self::STATUS_PENDING + ), ARRAY_A); + + if (!$row) return false; + + $user_id = (int) $row['user_id']; + $amount = (float) $row['amount']; + + $result = $wpdb->update($this->table(), [ + 'status' => self::STATUS_SETTLED, + 'settled_at' => current_time('mysql'), + ], ['id' => $id]); + + if ($result && class_exists('AKSRT_Credits')) { + AKSRT_Credits::add($user_id, (int) $amount, 'rebate', [ + 'ref_type' => 'rebate', + 'ref_id' => $id, + ]); + } + + return (bool) $result; + } + + /** + * 取消返佣 + */ + public function cancel(int $id): bool { + global $wpdb; + return (bool) $wpdb->update($this->table(), [ + 'status' => self::STATUS_CANCELLED, + ], ['id' => $id, 'status' => self::STATUS_PENDING]); + } + + /** + * 获取返佣列表 + */ + public function get_list(array $args = []): array { + global $wpdb; + + $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); + $offset = max(0, (int) ($args['offset'] ?? 0)); + $status = sanitize_key((string) ($args['status'] ?? '')); + $user_id = (int) ($args['user_id'] ?? 0); + + $where = []; + $params = []; + + if ($status !== '') { + $where[] = 'status = %s'; + $params[] = $status; + } + if ($user_id > 0) { + $where[] = 'user_id = %d'; + $params[] = $user_id; + } + + $where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; + + $count_sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}"; + $total = $params ? (int) $wpdb->get_var($wpdb->prepare($count_sql, $params)) : (int) $wpdb->get_var($count_sql); + + $sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d"; + $params[] = $limit; + $params[] = $offset; + + $items = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A); + + return [ + 'items' => $items ?: [], + 'total' => $total, + 'page' => floor($offset / $limit) + 1, + 'max_pages' => ceil($total / $limit), + ]; + } + + /** + * 获取统计 + */ + public function get_stats(): array { + global $wpdb; + $table = $this->table(); + + $row = $wpdb->get_row( + "SELECT + COUNT(*) as total, + SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending, + SUM(CASE WHEN status = 'settled' THEN 1 ELSE 0 END) as settled, + SUM(amount) as total_amount, + SUM(CASE WHEN status = 'settled' THEN amount ELSE 0 END) as settled_amount + FROM {$table}", + ARRAY_A + ); + + return [ + 'total' => (int) ($row['total'] ?? 0), + 'pending' => (int) ($row['pending'] ?? 0), + 'settled' => (int) ($row['settled'] ?? 0), + 'total_amount' => (float) ($row['total_amount'] ?? 0), + 'settled_amount' => (float) ($row['settled_amount'] ?? 0), + ]; + } +} diff --git a/inc/classes/class-aksrt-report-center.php b/inc/classes/class-aksrt-report-center.php new file mode 100644 index 00000000..88e3419a --- /dev/null +++ b/inc/classes/class-aksrt-report-center.php @@ -0,0 +1,309 @@ +table(); + if ($table === '') return 0; + + global $wpdb; + $wpdb->insert($table, [ + 'reporter_id' => $reporter_id, + 'target_type' => sanitize_key($target_type), + 'target_id' => $target_id, + 'reason' => sanitize_textarea_field($reason), + 'category' => sanitize_key($category), + 'status' => 'pending', + 'created_at' => current_time('mysql'), + ], ['%d', '%s', '%d', '%s', '%s', '%s', '%s']); + + return $wpdb->insert_id ? (int) $wpdb->insert_id : 0; + } + + /** + * 获取待处理举报数 + */ + public function get_pending_count(): int { + $table = $this->table(); + if ($table === '') return 0; + + global $wpdb; + return (int) $wpdb->get_var( + "SELECT COUNT(*) FROM {$table} WHERE status = 'pending'" + ); + } + + /** + * 获取举报列表 + */ + public function get_reports(array $args = []): array { + $table = $this->table(); + if ($table === '') return []; + + global $wpdb; + + $where = []; + $params = []; + + $status = sanitize_key((string) ($args['status'] ?? '')); + if ($status !== '') { + $where[] = 'status = %s'; + $params[] = $status; + } + + $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); + $offset = max(0, (int) ($args['offset'] ?? 0)); + + $where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; + $sql = "SELECT * FROM {$table} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d"; + $params[] = $limit; + $params[] = $offset; + + $rows = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A); + + // 附加审核备注 + foreach ($rows as &$row) { + $row['id'] = (int) $row['id']; + $row['reporter_id'] = (int) $row['reporter_id']; + $row['target_id'] = (int) $row['target_id']; + $row['notes'] = isset($row['notes_json']) ? json_decode($row['notes_json'], true) ?? [] : []; + } + + return $rows; + } + + /** + * 更新举报状态 + */ + public function update_status(int $report_id, string $new_status, string $note = ''): bool { + $valid_statuses = ['pending', 'reviewing', 'resolved', 'dismissed']; + if (!in_array($new_status, $valid_statuses, true)) return false; + + $table = $this->table(); + if ($table === '') return false; + + global $wpdb; + + $existing = $wpdb->get_row($wpdb->prepare( + "SELECT id, notes_json FROM {$table} WHERE id = %d", + $report_id + )); + + if (!$existing) return false; + + $update_data = [ + 'status' => $new_status, + 'updated_at' => current_time('mysql'), + ]; + $format = ['%s', '%s']; + + if ($note !== '') { + $notes = json_decode($existing->notes_json ?? '[]', true) ?? []; + $notes[] = [ + 'note' => sanitize_textarea_field($note), + 'status' => $new_status, + 'operator' => get_current_user_id(), + 'timestamp' => current_time('mysql'), + ]; + $update_data['notes_json'] = wp_json_encode($notes); + $format[] = '%s'; + } + + $result = $wpdb->update($table, $update_data, ['id' => $report_id], $format, ['%d']); + return $result !== false; + } + + /** + * Get status counts for the admin dashboard. + * + * @return array{pending: int, reviewing: int, resolved: int, dismissed: int, total: int} + */ + public function get_status_counts(): array { + $table = $this->table(); + if ($table === '') { + return ['pending' => 0, 'reviewing' => 0, 'resolved' => 0, 'dismissed' => 0, 'total' => 0]; + } + + global $wpdb; + $rows = $wpdb->get_results( + "SELECT status, COUNT(*) as cnt FROM {$table} GROUP BY status", + ARRAY_A + ); + + $counts = ['pending' => 0, 'reviewing' => 0, 'resolved' => 0, 'dismissed' => 0]; + $total = 0; + foreach ((array) $rows as $row) { + $s = $row['status'] ?? ''; + if (isset($counts[$s])) { + $counts[$s] = (int) $row['cnt']; + } + $total += (int) $row['cnt']; + } + $counts['total'] = $total; + return $counts; + } + + /** + * 一次性迁移:把 option 中的举报数据搬到表中。 + * 幂等 — 已存在的 ID 不会重复插入。 + */ + public function migrate_from_option(): int { + $table = $this->table(); + if ($table === '') return 0; + + $reports = get_option('aksrt_reports', []); + if (!is_array($reports) || empty($reports)) return 0; + + global $wpdb; + $migrated = 0; + + foreach ($reports as $old_id => $report) { + // 检查是否已迁移(通过 reporter_id + target_type + target_id + created_at 去重) + $existing = $wpdb->get_var($wpdb->prepare( + "SELECT id FROM {$table} WHERE reporter_id = %d AND target_type = %s AND target_id = %d AND created_at = %s", + (int) ($report['reporter_id'] ?? 0), + sanitize_key((string) ($report['target_type'] ?? '')), + (int) ($report['target_id'] ?? 0), + (string) ($report['created_at'] ?? '') + )); + if ($existing) continue; + + $notes_json = ''; + if (!empty($report['notes']) && is_array($report['notes'])) { + $notes_json = wp_json_encode($report['notes']); + } + + $wpdb->insert($table, [ + 'reporter_id' => (int) ($report['reporter_id'] ?? 0), + 'target_type' => sanitize_key((string) ($report['target_type'] ?? '')), + 'target_id' => (int) ($report['target_id'] ?? 0), + 'reason' => sanitize_textarea_field((string) ($report['reason'] ?? '')), + 'category' => sanitize_key((string) ($report['category'] ?? '')), + 'status' => sanitize_key((string) ($report['status'] ?? 'pending')), + 'created_at' => (string) ($report['created_at'] ?? current_time('mysql')), + 'updated_at' => (string) ($report['updated_at'] ?? ''), + 'notes_json' => $notes_json, + ], ['%d', '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s']); + + if ($wpdb->insert_id) $migrated++; + } + + if ($migrated > 0) { + update_option('aksrt_report_migrated_to_table', time()); + } + + return $migrated; + } + + /** + * Initialize AJAX hooks for admin operations. + */ + public function init(): void { + add_action('wp_ajax_aksrt_report_update_status', [$this, 'ajax_update_status']); + add_action('wp_ajax_aksrt_report_list', [$this, 'ajax_list']); + add_action('wp_ajax_aksrt_report_counts', [$this, 'ajax_counts']); + + // 一次性迁移钩子 + add_action('admin_init', [$this, 'maybe_migrate'], 20); + } + + /** + * 自动迁移旧 option 数据(幂等) + */ + public function maybe_migrate(): void { + if (get_option('aksrt_report_migrated_to_table')) return; + $this->migrate_from_option(); + } + + /** + * AJAX: Update report status (admin only). + */ + public function ajax_update_status(): void { + if (!current_user_can('manage_options')) { + wp_send_json_error(['message' => __('权限不足', 'aksrt')]); + } + + check_ajax_referer('aksrt_report', '_nonce'); + + $report_id = (int) ($_POST['report_id'] ?? 0); + $new_status = sanitize_key((string) ($_POST['status'] ?? '')); + $note = sanitize_textarea_field((string) ($_POST['note'] ?? '')); + + if ($report_id <= 0 || $new_status === '') { + wp_send_json_error(['message' => __('参数无效', 'aksrt')]); + } + + $result = $this->update_status($report_id, $new_status, $note); + if ($result) { + wp_send_json_success(['message' => __('状态已更新', 'aksrt')]); + } + + wp_send_json_error(['message' => __('更新失败', 'aksrt')]); + } + + /** + * AJAX: Get report list (admin only). + */ + public function ajax_list(): void { + if (!current_user_can('manage_options')) { + wp_send_json_error(['message' => __('权限不足', 'aksrt')]); + } + + $status = sanitize_key((string) ($_GET['status'] ?? '')); + $limit = max(1, min(100, (int) ($_GET['limit'] ?? 20))); + $offset = max(0, (int) ($_GET['offset'] ?? 0)); + + $reports = $this->get_reports(['status' => $status, 'limit' => $limit, 'offset' => $offset]); + $counts = $this->get_status_counts(); + + wp_send_json_success(['reports' => $reports, 'counts' => $counts]); + } + + /** + * AJAX: Get status counts (admin only). + */ + public function ajax_counts(): void { + if (!current_user_can('manage_options')) { + wp_send_json_error(['message' => __('权限不足', 'aksrt')]); + } + + wp_send_json_success($this->get_status_counts()); + } +} diff --git a/inc/classes/class-aksrt-resource-meta-box.php b/inc/classes/class-aksrt-resource-meta-box.php new file mode 100644 index 00000000..56d9317e --- /dev/null +++ b/inc/classes/class-aksrt-resource-meta-box.php @@ -0,0 +1,464 @@ + + + +
+ + + + + +
+ +
+ + +
+
+ +
+ +
+ +

0 = 用户需支付对应积分。', 'aksrt'); ?>

+
+
+ +
+ +
+ +
+
+ +
+ +
+
+
+ + +
+
+ + +
+
+

+
+
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
+
+
+ +
+ +

+
+
+
+ + +
+
+
+ +
+ +
+
+ + +
+

+
+
+
+ + + + + + + + +
+
+
+ +
+
+ > + +
+
+ > + +
+

+
+
+
+ + +
+
+ +
+ +
+ +

+
+
+ +
+ +
+ +

0 表示限量,售完即止。', 'aksrt'); ?>

+
+
+ +
+ +
+ +

+
+
+ +
+ +
+ +

+
+
+ +
+ +
+ +

+
+
+ +
+ +
+ > + +
+
+
+
+ + + post_type !== 'post') return; + + if (empty($_POST['aksrt_pay_meta_nonce']) + || !wp_verify_nonce((string) $_POST['aksrt_pay_meta_nonce'], 'aksrt_pay_meta_save')) { + return; + } + if (!current_user_can('edit_post', $post_id)) return; + + // 付费类型 + $allowed_types = array_keys(aksrt_get_pay_types()); + $pay_type = sanitize_key((string) ($_POST['aksrt_pay_type'] ?? 'no')); + if (!in_array($pay_type, $allowed_types, true)) { + $pay_type = 'no'; + } + + // 价格字段 + $price = max(0, (float) ($_POST['aksrt_pay_price'] ?? 0)); + $original_price = max(0, (float) ($_POST['aksrt_pay_original_price'] ?? 0)); + $vip1_price = max(0, (float) ($_POST['aksrt_pay_vip1_price'] ?? 0)); + $vip2_price = max(0, (float) ($_POST['aksrt_pay_vip2_price'] ?? 0)); + + // 资源信息 + $version = sanitize_text_field((string) ($_POST['aksrt_pay_version'] ?? '')); + $size = sanitize_text_field((string) ($_POST['aksrt_pay_size'] ?? '')); + $compat = sanitize_text_field((string) ($_POST['aksrt_pay_compat'] ?? '')); + $license = sanitize_text_field((string) ($_POST['aksrt_pay_license'] ?? '')); + $demo_url = esc_url_raw((string) ($_POST['aksrt_pay_demo_url'] ?? '')); + $video_url = esc_url_raw((string) ($_POST['aksrt_pay_video_url'] ?? '')); + + // 布尔字段 + $require_login = !empty($_POST['aksrt_pay_require_login']); + $require_captcha = !empty($_POST['aksrt_pay_require_captcha']); + + // JSON 字段 + $downloads = $this->parse_json_array($_POST['aksrt_pay_downloads'] ?? ''); + $attributes = $this->parse_json_array($_POST['aksrt_pay_attributes'] ?? ''); + $gallery = $this->parse_json_array($_POST['aksrt_pay_gallery'] ?? ''); + + // 清洗下载源 + if ($downloads !== null && class_exists('AKSRT_Setup') && method_exists('AKSRT_Setup', 'sanitize_downloads_meta')) { + $downloads = AKSRT_Setup::sanitize_downloads_meta($downloads); + } + + // 保存统一元数据 + $pay_meta = AKSRT_Domain_Model::sanitize_pay_meta([ + 'pay_type' => $pay_type, + 'price' => $price, + 'original_price' => $original_price, + 'vip1_price' => $vip1_price, + 'vip2_price' => $vip2_price, + 'version' => $version, + 'size' => $size, + 'compat' => $compat, + 'license' => $license, + 'demo_url' => $demo_url, + 'video_url' => $video_url, + 'downloads' => $downloads ?? [], + 'attributes' => $attributes ?? [], + 'gallery' => $gallery ?? [], + 'require_login' => $require_login, + 'require_captcha' => $require_captcha, + // §3.8 电商增强字段 + 'virtual_sales' => $_POST['aksrt_pay_virtual_sales'] ?? 0, + 'stock' => $_POST['aksrt_pay_stock'] ?? -1, + 'purchase_limit' => $_POST['aksrt_pay_purchase_limit'] ?? 0, + 'card_keys' => isset($_POST['aksrt_pay_card_keys']) ? array_filter(array_map('trim', explode("\n", (string) $_POST['aksrt_pay_card_keys']))) : [], + 'free_preview' => $_POST['aksrt_pay_free_preview'] ?? '', + 'show_sales' => !empty($_POST['aksrt_pay_show_sales']), + ]); + + update_post_meta($post_id, AKSRT_Domain_Model::PAY_META_KEY, $pay_meta); + + // 同步标量字段用于查询(模仿 zibll 的 zibpay_type 同步) + update_post_meta($post_id, AKSRT_Domain_Model::PAY_TYPE_KEY, (string) $pay_meta['pay_type']); + update_post_meta($post_id, AKSRT_Domain_Model::PAY_PRICE_KEY, (float) $pay_meta['price']); + + // 清理旧的 _aksrt_is_resource 标记 + update_post_meta( + $post_id, + AKSRT_Domain_Model::RESOURCE_MARKER_KEY, + AKSRT_Domain_Model::is_resource_pay_type((string) $pay_meta['pay_type']) ? 1 : 0 + ); + } + + /** + * 安全解析 JSON 数组。 + */ + private function parse_json_array(string $raw): ?array { + $raw = trim($raw); + if ($raw === '') return []; + + $decoded = json_decode($raw, true); + return is_array($decoded) ? $decoded : null; + } +} diff --git a/inc/classes/class-aksrt-revenue.php b/inc/classes/class-aksrt-revenue.php new file mode 100644 index 00000000..a341d244 --- /dev/null +++ b/inc/classes/class-aksrt-revenue.php @@ -0,0 +1,118 @@ +insert($this->table(), [ + 'user_id' => $user_id, + 'order_number' => sanitize_text_field($order_number), + 'amount' => $amount, + 'type' => sanitize_key($type), + 'status' => 'pending', + 'created_at' => current_time('mysql'), + ], ['%d', '%s', '%f', '%s', '%s', '%s']); + + return (int) $wpdb->insert_id; + } + + /** + * 结算收益 + */ + public function settle(int $revenue_id): bool { + global $wpdb; + + return $wpdb->update( + $this->table(), + ['status' => 'settled', 'settled_at' => current_time('mysql')], + ['id' => $revenue_id, 'status' => 'pending'], + ['%s', '%s'], + ['%d', '%s'] + ) !== false; + } + + /** + * 获取用户收益列表 + */ + public function get_user_revenues(int $user_id, array $args = []): array { + global $wpdb; + + $where = ['user_id = %d']; + $params = [$user_id]; + + $status = sanitize_key((string) ($args['status'] ?? '')); + if ($status !== '') { + $where[] = 'status = %s'; + $params[] = $status; + } + + $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); + $offset = max(0, (int) ($args['offset'] ?? 0)); + + $sql = "SELECT * FROM {$this->table()} WHERE " . implode(' AND ', $where) . " ORDER BY created_at DESC LIMIT %d OFFSET %d"; + $params[] = $limit; + $params[] = $offset; + + return (array) $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A); + } + + /** + * 获取收益统计 + */ + public function get_stats(int $user_id = 0): array { + global $wpdb; + + $where = ''; + $params = []; + if ($user_id > 0) { + $where = 'WHERE user_id = %d'; + $params[] = $user_id; + } + + $sql = "SELECT + COALESCE(SUM(amount), 0) as total, + COALESCE(SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END), 0) as pending, + COALESCE(SUM(CASE WHEN status = 'settled' THEN amount ELSE 0 END), 0) as settled, + COUNT(*) as count + FROM {$this->table()} {$where}"; + + if (!empty($params)) { + $row = $wpdb->get_row($wpdb->prepare($sql, $params), ARRAY_A); + } else { + $row = $wpdb->get_row($sql, ARRAY_A); + } + + return [ + 'total' => (float) ($row['total'] ?? 0), + 'pending' => (float) ($row['pending'] ?? 0), + 'settled' => (float) ($row['settled'] ?? 0), + 'count' => (int) ($row['count'] ?? 0), + ]; + } +} diff --git a/inc/classes/class-aksrt-scripts.php b/inc/classes/class-aksrt-scripts.php new file mode 100644 index 00000000..56b2ba11 --- /dev/null +++ b/inc/classes/class-aksrt-scripts.php @@ -0,0 +1,68 @@ + 'https://fonts.googleapis.com', + 'crossorigin', + ]; + $hints[] = [ + 'href' => 'https://fonts.gstatic.com', + 'crossorigin', + ]; + $hints[] = [ + 'href' => 'https://cdn.jsdelivr.net', + 'crossorigin', + ]; + } + return $hints; + } + +} + +return AKSRT_Scripts::get_instance(); diff --git a/inc/classes/class-aksrt-search-push.php b/inc/classes/class-aksrt-search-push.php new file mode 100644 index 00000000..27520cfd --- /dev/null +++ b/inc/classes/class-aksrt-search-push.php @@ -0,0 +1,237 @@ +ID) || wp_is_post_autosave($post->ID)) { + return; + } + // 仅推送公开可索引的内容类型。 + $allowed = function_exists('aksrt_searchable_post_types') + ? aksrt_searchable_post_types() + : ['post']; + if (!in_array($post->post_type, $allowed, true)) { + return; + } + + $config = $this->get_config(); + if (empty($config['baidu_push_enabled'])) { + return; + } + + $is_new = $old_status !== 'publish'; + if ($is_new && empty($config['baidu_push_on_publish'])) { + return; + } + if (!$is_new && empty($config['baidu_push_on_update'])) { + return; + } + + $this->push_url((string) get_permalink($post)); + } + + /** + * 前台输出百度自动推送 JS(由 wp_footer 调用)。 + */ + public function output_auto_push_script(): void { + if (is_admin()) { + return; + } + $script = $this->get_auto_push_script(); + if ($script !== '') { + echo $script; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped — 固定常量脚本,无用户输入 + } + } + + /** + * 推送单条 URL 到百度 + * + * @param string $url 待推送的完整 URL + * @return array{success: bool, message: string, result: array} + */ + public function push_url(string $url): array { + $config = $this->get_config(); + + if (empty($config['baidu_push_enabled'])) { + return ['success' => false, 'message' => __('百度推送未启用', 'aksrt'), 'result' => []]; + } + + $token = (string) ($config['baidu_push_token'] ?? ''); + if ($token === '') { + return ['success' => false, 'message' => __('推送 Token 未配置', 'aksrt'), 'result' => []]; + } + + $api_url = "http://data.zz.baidu.com/urls?site=" . rawurlencode(home_url('/')) . "&token={$token}"; + + $response = wp_remote_post($api_url, [ + 'timeout' => 10, + 'headers' => ['Content-Type' => 'text/plain'], + 'body' => $url, + ]); + + if (is_wp_error($response)) { + $this->log('error', $url, $response->get_error_message()); + return ['success' => false, 'message' => $response->get_error_message(), 'result' => []]; + } + + $body = json_decode(wp_remote_retrieve_body($response), true); + $status = wp_remote_retrieve_response_code($response); + + if ($status === 200 && isset($body['success'])) { + $this->log('success', $url, wp_json_encode($body)); + return ['success' => true, 'message' => __('推送成功', 'aksrt'), 'result' => $body]; + } + + $err_msg = $body['message'] ?? ($body['error'] ?? sprintf('HTTP %d', $status)); + $this->log('error', $url, $err_msg); + return ['success' => false, 'message' => $err_msg, 'result' => $body ?? []]; + } + + /** + * 批量推送 URL + * + * @param string[] $urls + * @return array{success: int, failed: int, results: array} + */ + public function push_batch(array $urls): array { + $config = $this->get_config(); + $batch_size = max(1, min(500, (int) ($config['baidu_push_batch_size'] ?? 50))); + $success = 0; + $failed = 0; + $results = []; + + $chunks = array_chunk($urls, $batch_size); + foreach ($chunks as $chunk) { + $token = (string) ($config['baidu_push_token'] ?? ''); + $api_url = "http://data.zz.baidu.com/urls?site=" . rawurlencode(home_url('/')) . "&token={$token}"; + $body_str = implode("\n", $chunk); + + $response = wp_remote_post($api_url, [ + 'timeout' => 15, + 'headers' => ['Content-Type' => 'text/plain'], + 'body' => $body_str, + ]); + + if (is_wp_error($response)) { + $failed += count($chunk); + $results[] = ['error' => $response->get_error_message()]; + continue; + } + + $body = json_decode(wp_remote_retrieve_body($response), true); + if (isset($body['success'])) { + $success += (int) ($body['success'] ?? 0); + $failed += (int) ($body['not_same_site'] ?? 0) + (int) ($body['not_valid'] ?? 0); + } else { + $failed += count($chunk); + } + $results[] = $body ?? []; + } + + return ['success' => $success, 'failed' => $failed, 'results' => $results]; + } + + /** + * 获取自动推送 JS 代码 + */ + public function get_auto_push_script(): string { + $config = $this->get_config(); + if (empty($config['baidu_push_enabled']) || empty($config['baidu_push_auto'])) { + return ''; + } + + $site_url = home_url('/'); + return << +SCRIPT; + } + + /** + * 获取服务运行时状态 + */ + public function get_status(): array { + $config = $this->get_config(); + $enabled = !empty($config['baidu_push_enabled']); + $token = (string) ($config['baidu_push_token'] ?? ''); + + $missing = []; + if ($enabled && $token === '') { + $missing[] = 'baidu_push_token'; + } + + return [ + 'enabled' => $enabled, + 'provider' => 'baidu', + 'configured' => $enabled && $token !== '', + 'available' => $enabled && $token !== '', + 'verified' => !empty($config['baidu_push_verified']), + 'transport' => 'http_api', + 'missing_fields' => $missing, + 'auto_push' => !empty($config['baidu_push_auto']), + 'on_publish' => !empty($config['baidu_push_on_publish']), + 'on_update' => !empty($config['baidu_push_on_update']), + 'batch_size' => max(1, (int) ($config['baidu_push_batch_size'] ?? 50)), + ]; + } + + private function get_config(): array { + if (class_exists('AKSRT_Settings')) { + return AKSRT_Settings::get_search_push_settings(); + } + return ['baidu_push_enabled' => false]; + } + + private function log(string $level, string $url, string $message): void { + if (!defined('WP_DEBUG') || !WP_DEBUG) return; + error_log(sprintf('[AKSRT Search Push] [%s] URL: %s — %s', $level, $url, $message)); + } +} diff --git a/inc/classes/class-aksrt-seo-head.php b/inc/classes/class-aksrt-seo-head.php new file mode 100644 index 00000000..f98615f8 --- /dev/null +++ b/inc/classes/class-aksrt-seo-head.php @@ -0,0 +1,384 @@ + + */ + private function config(): array { + static $cache = null; + if ($cache !== null) { + return $cache; + } + + if (class_exists('AKSRT_Settings')) { + $s = AKSRT_Settings::get_all(); + } else { + $s = []; + } + + $cache = [ + 'connector' => trim((string) ($s['seo_connector'] ?? '-')) ?: '-', + 'site_title' => trim((string) ($s['seo_title'] ?? '')), + 'description' => trim((string) ($s['seo_description'] ?? ($s['site_description'] ?? ''))), + 'keywords' => trim((string) ($s['seo_keywords'] ?? '')), + 'canonical' => !isset($s['canonical_tag']) || !empty($s['canonical_tag']), + 'og_enabled' => empty($s['disable_og_meta']), + 'jsonld' => !isset($s['seo_jsonld']) || !empty($s['seo_jsonld']), + 'jsonld_single' => !isset($s['seo_jsonld_single']) || !empty($s['seo_jsonld_single']), + 'jsonld_page' => !isset($s['seo_jsonld_page']) || !empty($s['seo_jsonld_page']), + 'default_image' => trim((string) ($s['seo_image_default'] ?? '')), + 'always_default' => !empty($s['seo_image_always_default']), + ]; + + return $cache; + } + + /** + * 解析当前请求的规范 SEO 负载。 + * + * @return array{title:string,description:string,keywords:string,canonical:string,image:string,og_type:string,object:?WP_Post} + */ + private function resolve_payload(): array { + $cfg = $this->config(); + $site_name = get_bloginfo('name'); + + $payload = [ + 'title' => wp_get_document_title(), + 'description' => $cfg['description'], + 'keywords' => $cfg['keywords'], + 'canonical' => home_url(isset($_SERVER['REQUEST_URI']) ? wp_unslash($_SERVER['REQUEST_URI']) : '/'), + 'image' => $cfg['default_image'], + 'og_type' => 'website', + 'object' => null, + ]; + + if (is_singular()) { + $post = get_queried_object(); + if ($post instanceof WP_Post) { + $payload['object'] = $post; + $payload['canonical'] = (string) get_permalink($post); + $payload['og_type'] = in_array($post->post_type, ['post', 'aksrt_resource'], true) ? 'article' : 'website'; + + // 单篇 SEO 覆盖:per-post meta → 文章字段 → 站点默认。 + $meta = $this->post_seo_meta($post->ID); + + $payload['title'] = $meta['title'] !== '' ? $meta['title'] : wp_strip_all_tags(get_the_title($post)); + if ($meta['description'] !== '') { + $payload['description'] = $meta['description']; + } else { + $excerpt = has_excerpt($post) + ? wp_strip_all_tags(get_the_excerpt($post)) + : wp_trim_words(wp_strip_all_tags((string) $post->post_content), 40, '…'); + $payload['description'] = trim((string) $excerpt); + } + if ($meta['keywords'] !== '') { + $payload['keywords'] = $meta['keywords']; + } elseif ($payload['keywords'] === '') { + $payload['keywords'] = $this->terms_to_keywords($post); + } + + $payload['image'] = $this->resolve_image($post, $cfg); + } + } elseif (is_home() || is_front_page()) { + if ($cfg['site_title'] !== '') { + $payload['title'] = $cfg['site_title']; + } + $payload['canonical'] = home_url('/'); + } elseif (is_category() || is_tag() || is_tax()) { + $term = get_queried_object(); + if ($term instanceof WP_Term) { + $payload['canonical'] = (string) get_term_link($term); + $term_desc = trim(wp_strip_all_tags((string) term_description($term))); + if ($term_desc !== '') { + $payload['description'] = $term_desc; + } + } + } + + if ($cfg['always_default'] && $cfg['default_image'] !== '') { + $payload['image'] = $cfg['default_image']; + } + + /** + * 允许扩展(如 AI SEO)在最终输出前调整 head 负载。 + * + * @param array $payload 规范 SEO 负载。 + */ + return apply_filters('aksrt_seo_head_payload', $payload); + } + + /** + * 读取单篇 SEO 覆盖(per-post meta)。 + * + * @param int $post_id Post ID. + * @return array{title:string,description:string,keywords:string} + */ + private function post_seo_meta(int $post_id): array { + $get = static function (string $key) use ($post_id): string { + return trim((string) get_post_meta($post_id, $key, true)); + }; + + return [ + 'title' => $get('_aksrt_seo_title'), + 'description' => $get('_aksrt_seo_description'), + 'keywords' => $get('_aksrt_seo_keywords'), + ]; + } + + /** + * 由分类/标签生成关键词回退。 + */ + private function terms_to_keywords(WP_Post $post): string { + $names = []; + foreach ((array) get_the_category($post->ID) as $cat) { + if ($cat instanceof WP_Term) { + $names[] = $cat->name; + } + } + $tags = get_the_terms($post, 'post_tag'); + if (is_array($tags)) { + foreach ($tags as $tag) { + $names[] = $tag->name; + } + } + + return implode(',', array_slice(array_values(array_unique(array_filter($names))), 0, 10)); + } + + /** + * 解析单篇缩略图:特色图 → 内容首图 → 站点默认 → logo。 + */ + private function resolve_image(WP_Post $post, array $cfg): string { + if (has_post_thumbnail($post)) { + $image = (string) get_the_post_thumbnail_url($post, 'large'); + if ($image !== '') { + return $image; + } + } + if (function_exists('aksrt_get_first_content_image_url')) { + $image = (string) aksrt_get_first_content_image_url($post->ID); + if ($image !== '') { + return $image; + } + } + if ($cfg['default_image'] !== '') { + return $cfg['default_image']; + } + if (class_exists('AKSRT_Settings')) { + return (string) AKSRT_Settings::logo_url(); + } + return ''; + } + + /** + * dl token 页面 noindex。 + */ + public function render_robots(): void { + if (function_exists('get_query_var') && get_query_var('aksrt_dl_token')) { + echo '' . "\n"; + } + } + + /** + * 输出 head(favicon + canonical + description/keywords + OG/Twitter + JSON-LD)。 + */ + public function render(): void { + $cfg = $this->config(); + $payload = $this->resolve_payload(); + + echo "\n\n"; + + // Favicon(无站点图标时回退)。 + if (function_exists('aksrt_get_favicon_url')) { + $favicon = (string) aksrt_get_favicon_url(); + if ($favicon !== '' && !has_site_icon()) { + printf('' . "\n", esc_url($favicon)); + } + } + + // Canonical。 + if ($cfg['canonical'] && !is_404() && $payload['canonical'] !== '') { + printf('' . "\n", esc_url($payload['canonical'])); + } + + // Description / Keywords。 + if ($payload['description'] !== '') { + printf('' . "\n", esc_attr($payload['description'])); + } + if ($payload['keywords'] !== '') { + printf('' . "\n", esc_attr($payload['keywords'])); + } + + // OpenGraph + Twitter。 + if ($cfg['og_enabled']) { + $this->render_open_graph($payload); + } + + // JSON-LD。 + if ($cfg['jsonld']) { + $this->render_jsonld($payload, $cfg); + } + + echo "\n"; + } + + /** + * 输出 OpenGraph + Twitter Card。 + */ + private function render_open_graph(array $payload): void { + $site_name = get_bloginfo('name'); + $locale = str_replace('-', '_', get_locale()); + + $og = [ + ['property' => 'og:type', 'content' => $payload['og_type']], + ['property' => 'og:locale', 'content' => $locale], + ['property' => 'og:site_name', 'content' => $site_name], + ['property' => 'og:title', 'content' => $payload['title']], + ['property' => 'og:url', 'content' => $payload['canonical']], + ]; + if ($payload['description'] !== '') { + $og[] = ['property' => 'og:description', 'content' => $payload['description']]; + } + if ($payload['image'] !== '') { + $og[] = ['property' => 'og:image', 'content' => $payload['image']]; + } + + if ($payload['og_type'] === 'article' && $payload['object'] instanceof WP_Post) { + $post = $payload['object']; + $published = get_the_date('c', $post); + $modified = get_the_modified_date('c', $post); + $author = get_the_author_meta('display_name', (int) $post->post_author); + if ($published) $og[] = ['property' => 'article:published_time', 'content' => $published]; + if ($modified) $og[] = ['property' => 'article:modified_time', 'content' => $modified]; + if ($author) $og[] = ['property' => 'article:author', 'content' => $author]; + + $cats = get_the_category($post->ID); + if (!empty($cats)) { + $og[] = ['property' => 'article:section', 'content' => $cats[0]->name]; + } + $tags = get_the_terms($post, 'post_tag'); + if (is_array($tags)) { + foreach ($tags as $tag) { + $og[] = ['property' => 'article:tag', 'content' => $tag->name]; + } + } + } + + foreach ($og as $tag) { + printf('' . "\n", esc_attr($tag['property']), esc_attr($tag['content'])); + } + + $twitter = [ + ['name' => 'twitter:card', 'content' => $payload['image'] !== '' ? 'summary_large_image' : 'summary'], + ['name' => 'twitter:title', 'content' => $payload['title']], + ]; + if ($payload['description'] !== '') { + $twitter[] = ['name' => 'twitter:description', 'content' => $payload['description']]; + } + if ($payload['image'] !== '') { + $twitter[] = ['name' => 'twitter:image', 'content' => $payload['image']]; + } + foreach ($twitter as $tag) { + printf('' . "\n", esc_attr($tag['name']), esc_attr($tag['content'])); + } + } + + /** + * 输出 JSON-LD 结构化数据。 + */ + private function render_jsonld(array $payload, array $cfg): void { + $graph = []; + + // WebSite(全站)。 + $graph[] = [ + '@type' => 'WebSite', + '@id' => home_url('/#website'), + 'url' => home_url('/'), + 'name' => get_bloginfo('name'), + 'inLanguage' => get_locale(), + ]; + + if (is_singular('post') && $cfg['jsonld_single'] && $payload['object'] instanceof WP_Post) { + $post = $payload['object']; + $node = [ + '@type' => 'Article', + 'headline' => $payload['title'], + 'url' => $payload['canonical'], + 'datePublished' => get_the_date('c', $post), + 'dateModified' => get_the_modified_date('c', $post), + 'author' => [ + '@type' => 'Person', + 'name' => get_the_author_meta('display_name', (int) $post->post_author), + ], + 'publisher' => [ + '@type' => 'Organization', + 'name' => get_bloginfo('name'), + ], + ]; + if ($payload['description'] !== '') { + $node['description'] = $payload['description']; + } + if ($payload['image'] !== '') { + $node['image'] = $payload['image']; + } + $graph[] = $node; + } elseif (is_page() && $cfg['jsonld_page'] && $payload['object'] instanceof WP_Post) { + $graph[] = [ + '@type' => 'WebPage', + 'url' => $payload['canonical'], + 'name' => $payload['title'], + ]; + } + + if (empty($graph)) { + return; + } + + $doc = [ + '@context' => 'https://schema.org', + '@graph' => $graph, + ]; + + echo '' . "\n"; + } +} diff --git a/inc/classes/class-aksrt-settings-migration.php b/inc/classes/class-aksrt-settings-migration.php new file mode 100644 index 00000000..64b9d23d --- /dev/null +++ b/inc/classes/class-aksrt-settings-migration.php @@ -0,0 +1,262 @@ +=')) { + return; // Already up to date + } + + $log = $this->get_log(); + $schema_provider = AKSRT_Admin_Schema_Provider::get_instance(); + + // Run migrations in order + $this->migrate_v1_0_0($schema_provider, $log); + $this->migrate_v1_2_0($log); + $this->migrate_v1_3_0($log); + + // Update version + update_option(self::VERSION_KEY, self::MIGRATION_VERSION); + $log[] = [ + 'version' => self::MIGRATION_VERSION, + 'timestamp' => gmdate('Y-m-d H:i:s'), + 'action' => 'migration_complete', + ]; + update_option(self::LOG_KEY, $log); + } + + /** + * Get the migration log. + * + * @return array + */ + public function get_log(): array { + $log = get_option(self::LOG_KEY, []); + return is_array($log) ? $log : []; + } + + /** + * Get current migration version. + * + * @return string + */ + public function get_version(): string { + return get_option(self::VERSION_KEY, '0.0.0'); + } + + /* ------------------------------------------------------------------ */ + /* Migration: v1.0.0 */ + /* Maps old option keys to new schema-managed domains. */ + /* ------------------------------------------------------------------ */ + + private function migrate_v1_0_0(AKSRT_Admin_Schema_Provider $schema_provider, array &$log): void { + // 1. Migrate aksrt_basic_settings fields into the new global domain + $this->migrate_basic_to_global($schema_provider, $log); + + // 2. Migrate aksrt_seo_settings to the new seo domain (already a standalone key) + // No action needed — it's already stored under aksrt_seo_settings. + + // 3. Ensure all domain options exist with defaults + foreach ($schema_provider->get_domain_keys() as $domain) { + $option_key = $schema_provider->get_option_key($domain); + $existing = get_option($option_key); + if ($existing === false) { + $defaults = $schema_provider->get_defaults($domain); + if (!empty($defaults)) { + add_option($option_key, $defaults); + $log[] = [ + 'version' => '1.0.0', + 'action' => 'created_domain_defaults', + 'domain' => $domain, + ]; + } + } + } + } + + /** + * Migrate fields from aksrt_basic_settings to the new global domain. + * + * The old aksrt_basic_settings contains many fields from different domains + * (auth, comment, avatar, etc.). We selectively map them to the new structure + * without deleting the original option. + */ + private function migrate_basic_to_global(AKSRT_Admin_Schema_Provider $schema_provider, array &$log): void { + $old = get_option('aksrt_basic_settings', []); + if (!is_array($old) || empty($old)) return; + + // Map old keys to new global domain keys + $field_map = [ + 'comment_enabled' => 'comment_enabled', + 'comments_per_page' => 'comments_per_page', + 'comment_requires_registration' => 'comment_requires_registration', + 'lazy_load' => 'lazy_load', + 'avatar_source' => 'avatar_source', + 'icp_filing' => 'icp_filing', + 'footer_text' => 'footer_text', + 'copyright_year' => 'copyright_year', + 'registration_enabled' => 'registration_enabled', + 'email_verification' => 'email_verification', + ]; + + $new_data = []; + foreach ($field_map as $old_key => $new_key) { + if (isset($old[$old_key])) { + $new_data[$new_key] = $old[$old_key]; + } + } + + if (!empty($new_data)) { + $existing = get_option('aksrt_basic_settings', []); + if (!is_array($existing)) $existing = []; + // Merge: new mapped values take precedence only if not already set + $merged = wp_parse_args($existing, $new_data); + update_option('aksrt_basic_settings', $merged); + + $log[] = [ + 'version' => '1.0.0', + 'action' => 'migrated_basic_to_global', + 'fields' => array_keys($new_data), + 'migrated' => count($new_data), + ]; + } + } + + private function migrate_v1_2_0(array &$log): void { + $settings = get_option('aksrt_settings', []); + if (!is_array($settings) || empty($settings)) { + return; + } + + $map = [ + 'header_slider_search' => 'header_components_search_enabled', + 'header_slider_cards' => 'header_components_cards_enabled', + 'header_slider_nav_color' => 'header_components_nav_color', + 'header_slider_show' => 'header_components_show_on', + 'header_slider_show_type' => 'header_components_device_visibility', + 'header_slider_filter_blur' => 'header_components_filter_blur_devices', + 'header_slider_card_show' => 'header_components_card_show_devices', + 'header_card_shared_bg' => 'header_components_card_shared_bg', + 'header_card_blur' => 'header_components_card_blur', + 'header_card_icon_shape' => 'header_components_card_icon_shape', + 'header_icon_cards' => 'header_components_icon_cards', + 'header_slider_search_pc' => 'header_components_search_pc', + 'header_slider_search_mobile' => 'header_components_search_mobile', + 'header_slider_search_show_keywords' => 'header_components_search_show_keywords', + 'header_slider_search_popular_limit' => 'header_components_search_popular_limit', + 'header_slider_search_show_input_cat' => 'header_components_search_show_input_cat', + 'header_slider_search_show_type' => 'header_components_search_show_type', + ]; + + $migrated = []; + foreach ($map as $old_key => $new_key) { + if (array_key_exists($old_key, $settings) && !array_key_exists($new_key, $settings)) { + $settings[$new_key] = $settings[$old_key]; + $migrated[] = $old_key; + } + if (array_key_exists($old_key, $settings)) { + unset($settings[$old_key]); + } + } + + if (!empty($migrated)) { + update_option('aksrt_settings', $settings); + $log[] = [ + 'version' => '1.2.0', + 'action' => 'renamed_header_components_keys', + 'migrated' => $migrated, + ]; + } + } + + private function migrate_v1_3_0(array &$log): void { + $settings = get_option('aksrt_settings', []); + if (!is_array($settings) || empty($settings)) { + return; + } + + $map = [ + 'homepage_hero_enabled' => 'homepage_module_hero_enabled', + 'homepage_banner_enabled' => 'homepage_module_banner_enabled', + ]; + + $migrated = []; + foreach ($map as $old_key => $new_key) { + if (array_key_exists($old_key, $settings) && !array_key_exists($new_key, $settings)) { + $settings[$new_key] = $settings[$old_key]; + $migrated[] = $old_key; + } + if (array_key_exists($old_key, $settings)) { + unset($settings[$old_key]); + } + } + + if (!empty($migrated)) { + update_option('aksrt_settings', $settings); + $log[] = [ + 'version' => '1.3.0', + 'action' => 'renamed_homepage_module_toggle_keys', + 'migrated' => $migrated, + ]; + } + } + + public function map_legacy_key(string $option_key): ?array { + $map = [ + 'aksrt_basic_settings' => ['domain' => 'global', 'key' => 'aksrt_basic_settings'], + 'aksrt_seo_settings' => ['domain' => 'seo', 'key' => 'aksrt_seo_settings'], + 'aksrt_mail_smtp' => ['domain' => 'global', 'key' => 'aksrt_mail_smtp'], + 'aksrt_oauth' => ['domain' => 'users', 'key' => 'aksrt_oauth'], + 'aksrt_moderation_config' => ['domain' => 'permissions', 'key' => 'aksrt_moderation_config'], + 'aksrt_captcha_config' => ['domain' => 'permissions', 'key' => 'aksrt_captcha_config'], + 'aksrt_credit_settings' => ['domain' => 'users', 'key' => 'aksrt_credit_settings'], + 'aksrt_notification_settings' => ['domain' => 'users', 'key' => 'aksrt_notification_settings'], + 'aksrt_footer_settings' => ['domain' => 'page_display', 'key' => 'aksrt_footer_settings'], + 'aksrt_footer_tabbar' => ['domain' => 'page_display', 'key' => 'aksrt_footer_tabbar'], + 'aksrt_floating_buttons' => ['domain' => 'global', 'key' => 'aksrt_floating_buttons'], + 'aksrt_friend_links' => ['domain' => 'page_display', 'key' => 'aksrt_friend_links'], + 'aksrt_banners' => ['domain' => 'page_display', 'key' => 'aksrt_banners'], + 'aksrt_category_recommend' => ['domain' => 'page_display', 'key' => 'aksrt_category_recommend'], + ]; + + return $map[$option_key] ?? null; + } +} diff --git a/inc/classes/class-aksrt-settings.php b/inc/classes/class-aksrt-settings.php new file mode 100644 index 00000000..8c917b99 --- /dev/null +++ b/inc/classes/class-aksrt-settings.php @@ -0,0 +1,1326 @@ +post_type !== 'post') return; + self::flush_category_recommend_cache(); + } + + // ============================================ + // 设置读取方法 + // ============================================ + + /** 获取全部设置(per-request memo) */ + public static function get_all(): array { + if (self::$cached_all !== null) { + return self::$cached_all; + } + + $settings = get_option('aksrt_settings', []); + $seo = get_option('aksrt_seo_settings', []); + $basic = get_option('aksrt_basic_settings', []); + + $merged = array_merge( + is_array($settings) ? $settings : [], + is_array($seo) ? $seo : [], + is_array($basic) ? $basic : [] + ); + + if (!is_array($merged)) $merged = []; + self::$cached_all = $merged; + return $merged; + } + + /** + * 显式刷新缓存(外部直接修改 option 后可调用)。 + */ + public static function flush_settings_cache(): void { + self::$cached_all = null; + } + + // ============================================ + // Option Registry — domain/section → option key 映射 + // ============================================ + + /** + * 所有独立 option key(非 aksrt_settings)。 + * 用于导入导出完整性检查。 + */ + public const STANDALONE_OPTION_KEYS = [ + 'aksrt_seo_settings', + 'aksrt_mail_smtp', + 'aksrt_oauth', + 'aksrt_captcha_config', + 'aksrt_moderation_config', + 'aksrt_footer_settings', + 'aksrt_footer_tabbar', + 'aksrt_floating_buttons', + 'aksrt_credit_settings', + 'aksrt_notification_settings', + 'aksrt_friend_links', + 'aksrt_banners', + 'aksrt_category_recommend', + ]; + + /** + * 包含 secret 字段的 option key — 空值保留旧值策略。 + */ + public const SECRET_OPTION_KEYS = [ + 'aksrt_mail_smtp', + 'aksrt_oauth', + ]; + + /** + * 读取指定 domain/section 的配置(委托 Schema Provider)。 + * + * @param string $domain 域名 (global, page_display, post_list, ...) + * @param string $section 分区名 (可为空,读取整个 domain) + * @return array + */ + public static function read_domain(string $domain, string $section = ''): array { + $provider = AKSRT_Admin_Schema_Provider::get_instance(); + return $provider->read_settings($domain, $section); + } + + /** + * 写入指定 domain/section 的配置(委托 Schema Provider)。 + * + * 仅写入当前 section allowlist 中的字段,不会覆盖同 option 中其它 section 的字段。 + * 写入后自动刷新 memo cache 并触发 action。 + * + * @param string $domain 域名 + * @param string $section 分区名 + * @param array $data 要写入的数据 + * @return bool + */ + public static function write_domain(string $domain, string $section, array $data): bool { + $provider = AKSRT_Admin_Schema_Provider::get_instance(); + $result = $provider->write_settings($domain, $data, $section); + + if ($result) { + self::flush_settings_cache(); + $option_key = $provider->get_option_key($domain, $section); + do_action('aksrt_settings_domain_saved', $domain, $section, $option_key); + } + + return $result; + } + + /** + * 获取指定 domain/section 的默认值。 + */ + public static function get_domain_defaults(string $domain, string $section = ''): array { + $provider = AKSRT_Admin_Schema_Provider::get_instance(); + return $provider->get_defaults($domain, $section); + } + + /** + * 重置指定 domain/section 到默认值。 + */ + public static function reset_domain(string $domain, string $section = ''): bool { + $provider = AKSRT_Admin_Schema_Provider::get_instance(); + $result = $provider->reset_to_defaults($domain, $section); + + if ($result) { + self::flush_settings_cache(); + do_action('aksrt_settings_domain_reset', $domain, $section); + } + + return $result; + } + + public static function get_value(string $key, $default = null) { + $settings = self::get_all(); + return array_key_exists($key, $settings) ? $settings[$key] : $default; + } + + /** 获取导航 Logo 宽度 */ + public static function logo_width(): int { + $width = (int) self::get_value('logo_width', 150); + return max(50, min(400, $width)); + } + + /** 获取Favicon URL */ + public static function custom_favicon_url(): string { + $favicon = (string) self::get_value('site_favicon', ''); + if ($favicon !== '') { + return $favicon; + } + + // zibll 别名回退 + $zibll = trim((string) get_option('favicon', '')); + if ($zibll !== '') { + return $zibll; + } + + // zibll iconpng 别名回退 + $iconpng = trim((string) get_option('iconpng', '')); + if ($iconpng !== '') { + return $iconpng; + } + + $theme_mod = trim((string) get_theme_mod('aksrt_favicon', '')); + if ($theme_mod !== '') { + return $theme_mod; + } + + return ''; + } + + public static function favicon_url(): string { + $custom = self::custom_favicon_url(); + if ($custom !== '') { + return $custom; + } + + $site_icon = get_site_icon_url(512); + return $site_icon ? (string) $site_icon : self::default_asset_url('default-favicon.svg'); + } + + /** 获取About显示名 */ + /** + * 让 SEO 设置中的 Favicon 覆盖 WordPress 原生 site_icon 输出链路。 + * + * @param string $url + * @param int $size + * @param int $blog_id + * @return string + */ + public static function filter_site_icon_url($url, $size, $blog_id): string { + $custom = self::custom_favicon_url(); + return $custom !== '' ? $custom : (string) $url; + } + + /** + * 尝试把自定义 favicon 同步到 WordPress 原生 site_icon 选项。 + * + * 仅当 URL 能映射到媒体库附件时才同步;外链仍通过 get_site_icon_url 过滤覆盖。 + */ + public static function sync_native_site_icon(string $favicon_url): void { + $favicon_url = trim(esc_url_raw($favicon_url)); + + if ($favicon_url === '') { + return; + } + + $attachment_id = attachment_url_to_postid($favicon_url); + if ($attachment_id > 0 && (int) get_option('site_icon', 0) !== $attachment_id) { + update_option('site_icon', $attachment_id); + } + } + + /** 启动时对齐原生 site_icon,兼容历史已保存的 SEO favicon。 */ + public static function maybe_sync_native_site_icon(): void { + $custom = self::custom_favicon_url(); + if ($custom === '') { + return; + } + + self::sync_native_site_icon($custom); + } + + public static function about_display_name(): string { + $s = self::get_all(); + return !empty($s['about_display_name']) ? $s['about_display_name'] : get_bloginfo('name'); + } + + /** 获取About简介 */ + public static function about_bio(): string { + $s = self::get_all(); + return !empty($s['about_bio']) ? $s['about_bio'] : get_bloginfo('description'); + } + + /** 获取About头像URL */ + public static function about_avatar(): string { + $s = self::get_all(); + return $s['about_avatar'] ?? ($s['admin_avatar_url'] ?? ''); + } + + /** 获取头像镜像源 */ + public static function avatar_source(): string { + $source = sanitize_text_field((string) self::get_value('avatar_source', 'cn.cravatar.com')); + $allowed = ['cn.cravatar.com', 'cn.gravatar.com', 'gravatar.com']; + return in_array($source, $allowed, true) ? $source : 'cn.cravatar.com'; + } + + /** 获取SEO关键词 */ + public static function seo_keywords(): string { + $s = self::get_all(); + return $s['seo_keywords'] ?? ''; + } + + /** 获取自定义头部代码 */ + public static function custom_head_code(): string { + $code = (string) self::get_value('head_code', ''); + if ($code === '') { + $code = (string) self::get_value('custom_js_header', ''); + } + return $code; + } + + /** 获取自定义 CSS */ + public static function custom_css(): string { + return (string) self::get_value('custom_css', ''); + } + + /** 获取自定义底部代码 */ + public static function custom_footer_code(): string { + $code = (string) self::get_value('foot_code', ''); + if ($code === '') { + $code = (string) self::get_value('custom_js_footer', ''); + } + return $code; + } + + /** 获取统计代码 */ + public static function trackcode(): string { + return trim((string) self::get_value('trackcode', '')); + } + + /** 获取搜索框占位文案 */ + public static function search_placeholder(): string { + return AKSRT_Settings_Theme_Search::search_placeholder(); + } + + /** 获取搜索模式 */ + public static function search_mode(): string { + return AKSRT_Settings_Theme_Search::search_mode(); + } + + /** 是否启用搜索历史 */ + public static function search_history_enabled(): bool { + return AKSRT_Settings_Theme_Search::search_history_enabled(); + } + + /** 是否启用热门搜索 */ + public static function hot_search_enabled(): bool { + return AKSRT_Settings_Theme_Search::hot_search_enabled(); + } + + /** 获取热门搜索关键词 */ + public static function hot_search_keywords(): array { + return AKSRT_Settings_Theme_Search::hot_search_keywords(); + } + + /** 热门搜索标题(留空使用默认) */ + public static function search_popular_title(): string { + return AKSRT_Settings_Theme_Search::search_popular_title(); + } + + /** 置顶搜索词(始终显示在最前面) */ + public static function search_popular_sticky(): array { + return AKSRT_Settings_Theme_Search::search_popular_sticky(); + } + + /** 是否启用搜索建议 */ + public static function search_suggestions_enabled(): bool { + return AKSRT_Settings_Theme_Search::search_suggestions_enabled(); + } + + /** 是否在搜索面板显示热门文章 */ + public static function search_posts_enabled(): bool { + return AKSRT_Settings_Theme_Search::search_posts_enabled(); + } + + /** 搜索结果是否排除页面 */ + public static function search_no_page(): bool { + return AKSRT_Settings_Theme_Search::search_no_page(); + } + + /** 获取搜索排除分类 ID 列表 */ + public static function search_exclude_cats(): array { + return AKSRT_Settings_Theme_Search::search_exclude_cats(); + } + + /** 是否启用分类搜索选择器 */ + public static function search_cat_enabled(): bool { + return AKSRT_Settings_Theme_Search::search_cat_enabled(); + } + + /** 获取默认搜索分类 */ + public static function search_cat_in(): string { + return AKSRT_Settings_Theme_Search::search_cat_in(); + } + + /** 是否显示更多分类 */ + public static function search_more_cat_obj(): bool { + return AKSRT_Settings_Theme_Search::search_more_cat_obj(); + } + + /** 是否启用自定义搜索匹配范围 */ + public static function search_custom_key_enabled(): bool { + return AKSRT_Settings_Theme_Search::search_custom_key_enabled(); + } + + /** 获取自定义搜索匹配字段列表 */ + public static function search_custom_key(): array { + return AKSRT_Settings_Theme_Search::search_custom_key(); + } + + /** 是否启用搜索结果 taxonomy 筛选 */ + public static function search_facets_enabled(): bool { + return AKSRT_Settings_Theme_Search::search_facets_enabled(); + } + + /** 获取搜索结果筛选 taxonomy 排序列表 */ + public static function search_facets_order(): array { + return AKSRT_Settings_Theme_Search::search_facets_order(); + } + + /** + * Meilisearch integration configuration. + * + * @return array{enabled:bool,api_url:string,api_key:string,index_prefix:string,synonyms:array} + */ + public static function get_meilisearch_config(): array { + return AKSRT_Settings_Theme_Search::get_meilisearch_config(); + } + + /** + * pre_get_posts 过滤:应用搜索排除配置 + * - search_no_page: 排除页面 + * - search_exclude_cats: 排除指定分类 + */ + public static function filter_search_query($query): void { + AKSRT_Settings_Theme_Search::filter_search_query($query); + } + + /** + * pre_get_posts: apply global list_orderby on archive/tag/category/search pages. + * + * Only fires when no explicit orderby is set by URL params or other hooks. + */ + public static function apply_list_orderby($query): void { + AKSRT_Settings_Theme_Search::apply_list_orderby($query); + } + + /** + * 获取搜索内容类型配置 + * + * @return array{types: string[], default: string} + */ + public static function get_search_content_types(): array { + return AKSRT_Settings_Theme_Search::get_search_content_types(); + } + + /** 默认主题模式 (white-theme / dark-theme / time-auto) */ + public static function theme_mode(): string { + return AKSRT_Settings_Theme_Search::theme_mode(); + } + + /** 主题模式 → JS 可消费的简化值 (light / dark / time-auto) */ + public static function theme_mode_js_value(): string { + return AKSRT_Settings_Theme_Search::theme_mode_js_value(); + } + + /** time-auto:对齐 zibll 早晚 8 点自动切换语义。 */ + public static function time_auto_theme_mode(): string { + return AKSRT_Settings_Theme_Search::time_auto_theme_mode(); + } + + /** 首屏主题模式:优先消费 zibll 兼容 cookie,回退后台默认值。 */ + public static function initial_theme_mode(): string { + return AKSRT_Settings_Theme_Search::initial_theme_mode(); + } + + /** 获取主题色 */ + public static function primary_color(): string { + return AKSRT_Settings_Theme_Search::primary_color(); + } + + /** 获取主题皮肤预设 hex(解析 palette 选项为 #hex 格式) */ + public static function theme_skin(): string { + return AKSRT_Settings_Theme_Search::theme_skin(); + } + + /** 获取暗色模式独立主题色 */ + public static function dark_primary_color(): string { + return AKSRT_Settings_Theme_Search::dark_primary_color(); + } + + /** 获取主题切换按钮显示位置列表 */ + public static function get_theme_mode_button_positions(): array { + return AKSRT_Settings_Theme_Search::get_theme_mode_button_positions(); + } + + /** 获取最终生效的主题色(skin 优先于 custom primary_color) */ + public static function resolved_primary_color(): string { + return AKSRT_Settings_Theme_Search::resolved_primary_color(); + } + + /** 获取全局圆角 */ + public static function border_radius(): int { + return AKSRT_Settings_Theme_Search::border_radius(); + } + + /** 获取卡片圆角值(独立设置优先,否则 border_radius + 4) */ + public static function card_radius(): int { + return AKSRT_Settings_Theme_Search::card_radius(); + } + + /** 全站加载动画开关 */ + public static function is_qj_loading_enabled(): bool { + return AKSRT_Settings_Theme_Search::is_qj_loading_enabled(); + } + + /** 加载动画样式 (no1–no10) */ + public static function get_qj_loading_style(): string { + return AKSRT_Settings_Theme_Search::get_qj_loading_style(); + } + + /** 获取内容最大宽度 */ + public static function container_max_width(): int { + return AKSRT_Settings_Theme_Search::container_max_width(); + } + + /** 获取全局字体方案 */ + public static function font_family(): string { + return AKSRT_Settings_Theme_Search::font_family(); + } + + /** + * 获取代码高亮配置 + * + * @return array + */ + public static function get_code_highlight_config(): array { + return AKSRT_Settings_Theme_Search::get_code_highlight_config(); + } + + /** + * 获取系统公告配置 + * + * @return array + */ + public static function get_system_notice_config(): array { + return AKSRT_Settings_Theme_Search::get_system_notice_config(); + } + + /** + * 获取侧边栏配置 + * + * @return array + */ + public static function get_sidebar_config(): array { + return AKSRT_Settings_Theme_Search::get_sidebar_config(); + } + + /** + * 获取多语言翻译配置 + * + * @return array + */ + public static function get_i18n_config(): array { + return AKSRT_Settings_Theme_Search::get_i18n_config(); + } + + /** 获取 ICP 备案号 */ + public static function icp_filing(): string { + return trim((string) self::get_value('icp_filing', '')); + } + + /** 获取公安备案号 */ + public static function police_filing(): string { + return trim((string) self::get_value('police_number', '')); + } + + /** + * 获取备案配置(统一 page-display footer schema 和全局设置) + * @return array {icp_enabled, icp_number, police_enabled, police_number} + */ + public static function get_filing_settings(): array { + $s = self::get_all(); + $icp_enabled = !isset($s['footer_icp']) || !empty($s['footer_icp']); + $police_enabled = !isset($s['footer_police']) || !empty($s['footer_police']); + // 优先读 footer schema 的 icp_number,回退到全局 icp_filing + $icp_number = trim((string) ($s['icp_number'] ?? $s['icp_filing'] ?? '')); + $police_number = trim((string) ($s['police_number'] ?? '')); + return [ + 'icp_enabled' => $icp_enabled, + 'icp_number' => $icp_number, + 'police_enabled' => $police_enabled, + 'police_number' => $police_number, + ]; + } + + /** 获取页脚附加文字 */ + public static function footer_text(): string { + return trim((string) self::get_value('footer_text', '')); + } + + /** + * Header navigation display settings. + * + * @return array{fixed:bool,search_enabled:bool,user_menu:bool,publish_btn:bool,mobile_drawer:bool,nav_style:string,max_width:int,scroll_down_hide:bool,layout:string,mobile_layout:string,mobile_align:string} + */ + public static function get_header_nav_settings(): array { + $settings = self::get_all(); + $nav_style = sanitize_key((string) ($settings['header_nav_style'] ?? 'default')); + $max_width = (int) ($settings['header_max_width'] ?? 1200); + $layout = sanitize_key((string) ($settings['header_layout'] ?? '2')); + $ml = sanitize_key((string) ($settings['mobile_header_layout'] ?? 'center')); + $ma = sanitize_key((string) ($settings['mobile_navbar_align'] ?? 'left')); + + if (!in_array($nav_style, ['default', 'transparent', 'blur'], true)) { + $nav_style = 'default'; + } + + if (!in_array($max_width, [0, 1080, 1200, 1320], true)) { + $max_width = 1200; + } + + return [ + 'fixed' => !isset($settings['header_fixed']) || !empty($settings['header_fixed']), + 'search_enabled' => !isset($settings['header_search_enabled']) || !empty($settings['header_search_enabled']), + 'user_menu' => !isset($settings['header_user_menu']) || !empty($settings['header_user_menu']), + 'publish_btn' => !isset($settings['header_publish_btn']) || !empty($settings['header_publish_btn']), + 'mobile_drawer' => !isset($settings['header_mobile_drawer']) || !empty($settings['header_mobile_drawer']), + 'nav_style' => $nav_style, + 'max_width' => $max_width, + 'scroll_down_hide'=> !empty($settings['header_scroll_down_hide']), + 'layout' => in_array($layout, ['1', '2', '3'], true) ? $layout : '2', + 'mobile_layout' => in_array($ml, ['center', 'left'], true) ? $ml : 'center', + 'mobile_align' => in_array($ma, ['top', 'left', 'right'], true) ? $ma : 'left', + ]; + } + + /** + * Header publish button configuration. + * + * @return array{enabled:bool,text:string,radius:int,class:string} + */ + public static function get_header_newposts_config(): array { + $s = self::get_all(); + + return [ + 'enabled' => !empty($s['header_newposts_enabled']), + 'text' => trim((string) ($s['header_newposts_text'] ?? ' 发布')), + 'radius' => max(0, min(30, (int) ($s['header_newposts_radius'] ?? 20))), + 'class' => sanitize_html_class((string) ($s['header_newposts_class'] ?? '')), + ]; + } + + /** + * Header theme color configuration. + * + * @return array{custom:bool,bg_custom:string,bg:string,color_custom:string} + */ + public static function get_header_theme_config(): array { + $s = self::get_all(); + + return [ + 'custom' => !empty($s['header_theme_custom']), + 'bg_custom' => sanitize_hex_color((string) ($s['header_theme_bg_custom'] ?? '')) ?: '', + 'bg' => sanitize_hex_color((string) ($s['header_theme_bg'] ?? '')) ?: '', + 'color_custom' => sanitize_hex_color((string) ($s['header_theme_color_custom'] ?? '')) ?: '', + ]; + } + + /** + * Category archive display settings. + * + * @return array + */ + public static function get_category_page_settings(): array { + $settings = self::get_all(); + $header_style = sanitize_key((string) ($settings['category_header_style'] ?? 'cover')); + $list_mode = sanitize_key((string) ($settings['category_list_mode'] ?? 'grid')); + $per_page = (int) ($settings['category_per_page'] ?? 12); + $orderby = sanitize_key((string) ($settings['category_orderby'] ?? 'modified')); + + if (!in_array($header_style, ['cover', 'simple', 'none'], true)) { + $header_style = 'cover'; + } + + if (!in_array($list_mode, ['grid', 'list', 'compact'], true)) { + $list_mode = 'grid'; + } + + if (!in_array($orderby, ['modified', 'date', 'views', 'comments', 'likes'], true)) { + $orderby = 'modified'; + } + + return [ + 'header_style' => $header_style, + 'cover_enabled' => !isset($settings['category_cover_enabled']) || !empty($settings['category_cover_enabled']), + 'desc_enabled' => !isset($settings['category_desc_enabled']) || !empty($settings['category_desc_enabled']), + 'default_cover' => esc_url_raw((string) ($settings['category_default_cover'] ?? '')), + 'list_mode' => $list_mode, + 'filter_bar' => !isset($settings['category_filter_bar']) || !empty($settings['category_filter_bar']), + 'sidebar' => !isset($settings['category_sidebar']) || !empty($settings['category_sidebar']), + 'per_page' => max(4, min(48, $per_page)), + 'post_count' => !empty($settings['category_post_count']), + 'orderby' => $orderby, + ]; + } + + /** + * Tag archive display settings. + * + * @return array + */ + public static function get_tag_page_settings(): array { + $settings = self::get_all(); + $header_style = sanitize_key((string) ($settings['tag_header_style'] ?? 'simple')); + $list_mode = sanitize_key((string) ($settings['tag_list_mode'] ?? 'grid')); + $per_page = (int) ($settings['tag_per_page'] ?? 12); + $orderby = sanitize_key((string) ($settings['tag_orderby'] ?? 'count')); + + if (!in_array($header_style, ['cover', 'simple', 'none'], true)) { + $header_style = 'simple'; + } + + if (!in_array($list_mode, ['grid', 'list'], true)) { + $list_mode = 'grid'; + } + + if (!in_array($orderby, ['count', 'name', 'modified'], true)) { + $orderby = 'count'; + } + + return [ + 'header_style' => $header_style, + 'default_cover' => esc_url_raw((string) ($settings['tag_default_cover'] ?? '')), + 'list_mode' => $list_mode, + 'sidebar' => !isset($settings['tag_sidebar']) || !empty($settings['tag_sidebar']), + 'per_page' => max(4, min(48, $per_page)), + 'post_count' => !empty($settings['tag_post_count']), + 'orderby' => $orderby, + ]; + } + + /** + * Public user-home display settings. + * + * @return array + */ + public static function get_user_home_settings(): array { + return AKSRT_Settings_User_Runtime::get_user_home_settings(); + } + + /** + * Private user-center display settings. + * + * @return array{tabs:string[],avatar_upload:bool,cover_upload:bool,social_links:bool} + */ + public static function get_user_center_settings(): array { + return AKSRT_Settings_User_Runtime::get_user_center_settings(); + } + + /** + * User identity, level, badge, and report governance settings. + * + * @return array{ + * verify:array{enabled:bool,public_display:bool,badge_text:string,accept_email_verified:bool,require_manual_review:bool}, + * level:array{enabled:bool,public_display:bool,source:string,label_prefix:string,thresholds:array}, + * badge:array{enabled:bool,public_display:bool,limit:int,dictionary:array}, + * report:array{enabled:bool,public_entry:bool,public_notice:bool,ban_notice_text:string,categories:array} + * } + */ + public static function get_user_identity_settings(): array { + return AKSRT_Settings_User_Runtime::get_user_identity_settings(); + } + + /** + * Misc page display settings (404, search, author). + * + * @return array + */ + public static function get_other_page_settings(): array { + $settings = self::get_all(); + $message = trim((string) ($settings['page_404_message'] ?? '')); + $search_per_page = max(4, min(48, (int) ($settings['page_search_per_page'] ?? 12))); + $author_per_page = max(4, min(48, (int) ($settings['page_author_posts_per_page'] ?? 12))); + + $header_style = sanitize_key((string) ($settings['page_header_style'] ?? '1')); + if (!in_array($header_style, ['', '1', '2', '3'], true)) { + $header_style = '1'; + } + + return [ + 'header_style' => $header_style, + 'header_cover_img' => esc_url_raw((string) ($settings['page_header_cover_img'] ?? '')), + 'page_404_message' => $message !== '' ? $message : __('页面未找到', 'aksrt'), + 'page_404_search' => !isset($settings['page_404_search']) || !empty($settings['page_404_search']), + 'page_404_popular' => !isset($settings['page_404_popular']) || !empty($settings['page_404_popular']), + 'page_404_back' => !isset($settings['page_404_back']) || !empty($settings['page_404_back']), + 'page_search_sidebar' => !isset($settings['page_search_sidebar']) || !empty($settings['page_search_sidebar']), + 'page_search_per_page' => $search_per_page, + 'page_author_cover' => !isset($settings['page_author_cover']) || !empty($settings['page_author_cover']), + 'page_author_sidebar' => !isset($settings['page_author_sidebar']) || !empty($settings['page_author_sidebar']), + 'page_author_posts_per_page' => $author_per_page, + ]; + } + + /** + * Special/topic page display settings. + * + * @return array + */ + public static function get_special_page_settings(): array { + $s = self::get_all(); + $layout = sanitize_key((string) ($s['special_layout'] ?? 'grid')); + $source_type = sanitize_key((string) ($s['special_source_type'] ?? 'category')); + $subpage_display = sanitize_key((string) ($s['special_subpage_display'] ?? 'cards')); + $pagination = sanitize_key((string) ($s['special_pagination'] ?? 'numeric')); + $orderby = sanitize_key((string) ($s['special_orderby'] ?? 'date')); + + if (!in_array($layout, ['grid', 'list', 'masonry'], true)) { + $layout = 'grid'; + } + if (!in_array($source_type, ['category', 'tag', 'custom'], true)) { + $source_type = 'category'; + } + if (!in_array($subpage_display, ['cards', 'list', 'grid'], true)) { + $subpage_display = 'cards'; + } + if (!in_array($pagination, ['numeric', 'load_more', 'infinite'], true)) { + $pagination = 'numeric'; + } + if (!in_array($orderby, ['date', 'modified', 'views', 'comments'], true)) { + $orderby = 'date'; + } + + return [ + 'cover_enabled' => !empty($s['special_cover_enabled']), + 'summary_enabled' => !empty($s['special_summary_enabled']), + 'summary_length' => max(50, min(500, (int) ($s['special_summary_length'] ?? 200))), + 'default_cover' => esc_url_raw((string) ($s['special_default_cover'] ?? '')), + 'layout' => $layout, + 'columns' => max(1, min(4, (int) ($s['special_columns'] ?? 2))), + 'source_type' => $source_type, + 'source_ids' => trim((string) ($s['special_source_ids'] ?? '')), + 'subpage_display' => $subpage_display, + 'subpage_count' => max(1, min(20, (int) ($s['special_subpage_count'] ?? 6))), + 'sidebar' => !empty($s['special_sidebar']), + 'pagination' => $pagination, + 'per_page' => max(4, min(48, (int) ($s['special_per_page'] ?? 12))), + 'post_count' => !empty($s['special_post_count']), + 'orderby' => $orderby, + ]; + } + + /** + * Post-list card style and meta display settings. + * + * @return array{card_style:string,render_mode:string,show_author:bool,show_date:bool,show_views:bool,show_category:bool,show_comments:bool,show_likes:bool,excerpt_length:int} + */ + public static function get_post_list_style_settings(): array { + return AKSRT_Settings_Post_Display::get_post_list_style_settings(); + } + + /** + * Post-list thumbnail policy settings. + * + * @return array{priority:string[],fallback:string,aspect_ratio:string,multi_enabled:bool,multi_count:int,lazy:bool} + */ + public static function get_post_thumb_settings(): array { + return AKSRT_Settings_Post_Display::get_post_thumb_settings(); + } + + /** + * Post-list filter toolbar settings. + * + * @return array{enabled:bool,categories:bool,tags:bool,sort:bool} + */ + public static function get_post_filter_settings(): array { + return AKSRT_Settings_Post_Display::get_post_filter_settings(); + } + + /** + * Get custom filter definitions for a specific category. + * Finds the matching cat_custom_filter binding, falls back to global custom_filter. + * + * @param int $cat_id Category term ID. + * @return array{dimensions: array, show_cat: bool, show_tag: bool, show_topics: bool, show_orderby: bool} + */ + public static function get_filter_config_for_category(int $cat_id): array { + return AKSRT_Settings_Post_Display::get_filter_config_for_category($cat_id); + } + + /** + * Single post/page display settings. + * + * @return array{toc_enabled:bool,toc_depth:int,reading_progress:bool,lightbox:bool,related_enabled:bool,related_count:int,like_enabled:bool,favorite_enabled:bool,share_enabled:bool,copyright_enabled:bool,print_enabled:bool,pdf_export_enabled:bool,word_count_enabled:bool,reading_time_enabled:bool} + */ + public static function get_post_page_settings(): array { + $settings = self::get_all(); + + $related_type = sanitize_key((string) ($settings['post_related_type'] ?? 'category')); + if (!in_array($related_type, ['category', 'tag', 'random'], true)) { + $related_type = 'category'; + } + $related_orderby = sanitize_key((string) ($settings['post_related_orderby'] ?? 'rand')); + if (!in_array($related_orderby, ['rand', 'relevance', 'date'], true)) { + $related_orderby = 'rand'; + } + + return [ + 'toc_enabled' => !isset($settings['post_toc_enabled']) || !empty($settings['post_toc_enabled']), + 'toc_depth' => max(1, min(6, (int) ($settings['post_toc_depth'] ?? 3))), + 'reading_progress' => !isset($settings['post_reading_progress']) || !empty($settings['post_reading_progress']), + 'breadcrumbs' => !isset($settings['breadcrumbs_single_s']) || !empty($settings['breadcrumbs_single_s']), + 'breadcrumbs_home' => trim((string) ($settings['breadcrumbs_home_text'] ?? '')) ?: __('首页', 'aksrt'), + 'prevnext' => !isset($settings['post_prevnext_s']) || !empty($settings['post_prevnext_s']), + 'authordesc' => !isset($settings['post_authordesc_s']) || !empty($settings['post_authordesc_s']), + 'hide_time' => !empty($settings['post_single_hide_time_s']), + 'lightbox' => !isset($settings['post_lightbox']) || !empty($settings['post_lightbox']), + 'related_enabled' => !isset($settings['post_related_enabled']) || !empty($settings['post_related_enabled']), + 'related_count' => max(1, min(20, (int) ($settings['post_related_count'] ?? 6))), + 'related_type' => $related_type, + 'related_orderby' => $related_orderby, + 'related_title' => sanitize_text_field((string) ($settings['post_related_title'] ?? '')), + 'like_enabled' => !isset($settings['post_like_enabled']) || !empty($settings['post_like_enabled']), + 'favorite_enabled' => !isset($settings['post_favorite_enabled']) || !empty($settings['post_favorite_enabled']), + 'share_enabled' => !isset($settings['share_s']) || !empty($settings['share_s']) || !empty($settings['post_share_enabled']), + 'rewards_enabled' => !empty($settings['post_rewards_s']) || !empty($settings['post_rewards_enabled']), + 'rewards_text' => trim((string) ($settings['post_rewards_text'] ?? '')) ?: __('赞赏支持', 'aksrt'), + 'rewards_image' => esc_url_raw((string) ($settings['post_rewards_img'] ?? '')), + 'rewards_btn_text' => trim((string) ($settings['post_rewards_btn_text'] ?? '')) ?: __('赞赏', 'aksrt'), + 'copyright_enabled' => !isset($settings['post_copyright_enabled']) || !empty($settings['post_copyright_enabled']), + 'copyright_text' => trim((string) ($settings['post_copyright_text'] ?? '')), + 'front_content' => trim((string) ($settings['post_front_content'] ?? '')), + 'after_content' => trim((string) ($settings['post_after_content'] ?? '')), + 'maxheight_enabled' => !empty($settings['post_maxheight_kg']), + 'maxheight' => max(0, (int) ($settings['post_maxheight'] ?? 1000)), + 'yiyan_header' => !empty($settings['post_yiyan_header']), + 'yiyan_footer' => !empty($settings['post_yiyan_footer']), + 'yiyan_box' => !empty($settings['post_yiyan_box']), + 'default_views_min' => max(0, (int) ($settings['post_default_views_min'] ?? 20)), + 'default_views_max' => max(1, (int) ($settings['post_default_views_max'] ?? 50)), + 'default_likes_min' => max(0, (int) ($settings['post_default_likes_min'] ?? 5)), + 'default_likes_max' => max(1, (int) ($settings['post_default_likes_max'] ?? 15)), + 'print_enabled' => !isset($settings['feature_print']) || !empty($settings['feature_print']), + 'pdf_export_enabled' => !empty($settings['feature_pdf_export']), + 'word_count_enabled' => !isset($settings['feature_word_count']) || !empty($settings['feature_word_count']), + 'reading_time_enabled' => !isset($settings['feature_reading_time']) || !empty($settings['feature_reading_time']), + 'article_image_cover' => !isset($settings['post_article_image_cover']) || !empty($settings['post_article_image_cover']), + 'article_image_cover_scale' => max(10, min(100, (int) ($settings['post_article_image_cover_scale'] ?? 35))), + 'article_slide_cover' => !isset($settings['post_article_slide_cover']) || !empty($settings['post_article_slide_cover']), + 'article_video_cover' => !isset($settings['post_article_video_cover']) || !empty($settings['post_article_video_cover']), + 'article_video_cover_scale' => max(20, min(100, (int) ($settings['post_article_video_cover_scale'] ?? 56))), + 'button_toptext' => trim((string) ($settings['post_button_toptext'] ?? '')) ?: __('喜欢就支持一下吧', 'aksrt'), + 'lightbox_thumbs' => !isset($settings['post_lightbox_thumbs']) || !empty($settings['post_lightbox_thumbs']), + 'lightbox_zoom' => !isset($settings['post_lightbox_zoom']) || !empty($settings['post_lightbox_zoom']), + 'lightbox_full' => !isset($settings['post_lightbox_full']) || !empty($settings['post_lightbox_full']), + 'lightbox_down' => !empty($settings['post_lightbox_down']), + 'lightbox_play' => !empty($settings['post_lightbox_play']), + 'mobile_nav_s' => !isset($settings['article_nav_mobile_nav_s']) || !empty($settings['article_nav_mobile_nav_s']), + ]; + } + + /** + * Share and poster settings. + * + * @return array{wechat_enabled:bool,wechat_appid:string,wechat_app_secret:string,only_logo:bool,compatible_s:bool,poster_img:string,poster_logo:string,share_img:string,share_desc:string} + */ + public static function get_share_settings(): array { + $settings = self::get_all(); + + return [ + 'wechat_enabled' => !empty($settings['share_wechat_s']), + 'wechat_appid' => sanitize_text_field((string) ($settings['share_wechat_appid'] ?? '')), + 'wechat_app_secret' => sanitize_text_field((string) ($settings['share_wechat_app_secret'] ?? '')), + 'only_logo' => !empty($settings['share_only_logo']), + 'compatible_s' => !empty($settings['share_img_compatible_s']), + 'poster_img' => esc_url_raw((string) ($settings['share_img_byimg'] ?? '')), + 'poster_logo' => esc_url_raw((string) ($settings['share_logo'] ?? '')), + 'share_img' => esc_url_raw((string) ($settings['share_img'] ?? '')), + 'share_desc' => sanitize_textarea_field((string) ($settings['share_desc'] ?? '')) ?: __('扫一扫 立即查看', 'aksrt'), + ]; + } + + /** + * Front submit settings. + * + * @return array{enabled:bool,btn_text:string,title_min:int,title_max:int} + */ + public static function get_submit_settings(): array { + return AKSRT_Settings_Post_Display::get_submit_settings(); + } + + /** + * Comment display and behavior settings. + * + * @return array{order:string,pagination_mode:string,nested_depth:int,require_login:bool,allow_reply:bool,avatar_size:int,empty_text:string} + */ + public static function get_comment_display_settings(): array { + return AKSRT_Settings_Post_Display::get_comment_display_settings(); + } + + /** + * pre_get_posts: apply post_list_per_page on archive/tag/category/search/home pages. + * + * Overrides WP Settings > Reading "posts per page" for list views. + */ + public static function apply_list_per_page($query): void { + AKSRT_Settings_Post_Display::apply_list_per_page($query); + } + + /** 获取Banner列表 */ + public static function get_banners(string $position = ''): array { + return AKSRT_Settings_Display_Runtime::get_banners($position); + } + + /** 保存Banner */ + public static function save_banner(array $data): int { + return AKSRT_Settings_Display_Runtime::save_banner($data); + } + + /** 删除Banner */ + public static function delete_banner(string $id): void { + AKSRT_Settings_Display_Runtime::delete_banner($id); + } + + /** 获取友链列表 */ + public static function get_friend_links(): array { + return AKSRT_Settings_Display_Runtime::get_friend_links(); + } + + /** 保存友链 */ + public static function save_friend_links(array $links): void { + AKSRT_Settings_Display_Runtime::save_friend_links($links); + } + + /** 获取SMTP配置 */ + public static function get_smtp(): array { + return get_option('aksrt_mail_smtp', [ + 'enabled' => false, + 'host' => '', + 'port' => 465, + 'username' => '', + 'password' => '', + 'encryption' => 'ssl', + 'from_name' => '', + 'from_address' => '', + ]); + } + + /** 获取邮件品牌设置(发件人昵称/标题前缀/底部内容) */ + public static function get_mail_branding(): array { + $s = self::get_all(); + return [ + 'showname' => trim((string) ($s['mail_showname'] ?? '')), + 'title_prefix' => trim((string) ($s['mail_title_prefix'] ?? '')), + 'description' => trim((string) ($s['mail_description'] ?? '')), + 'more_content' => trim((string) ($s['mail_more_content'] ?? '')), + ]; + } + + /** 私信邮件接收组限制 (all/vip/vip2/admin) */ + public static function get_email_private_receive_limit(): string { + return AKSRT_Settings_Integration::get_email_private_receive_limit(); + } + + /** 获取OAuth配置 */ + public static function get_oauth(): array { + return AKSRT_Settings_Integration::get_oauth(); + } + + /** 检查OAuth平台是否已配置 */ + public static function oauth_configured(string $provider): bool { + return AKSRT_Settings_Integration::oauth_configured($provider); + } + + // ============================================ + // OAuth 代理登录 & 自动创建用户 + // ============================================ + + /** OAuth 代理登录模式: 'off' | 'server' | 'client' (schema 存 'close'→映射为 'off') */ + public static function oauth_proxy_mode(): string { + return AKSRT_Settings_Integration::oauth_proxy_mode(); + } + + /** OAuth 代理登录 HMAC 密钥 */ + public static function oauth_proxy_secret(): string { + return AKSRT_Settings_Integration::oauth_proxy_secret(); + } + + /** OAuth 代理登录服务端地址(客户端模式使用) */ + public static function oauth_proxy_server_url(): string { + return AKSRT_Settings_Integration::oauth_proxy_server_url(); + } + + /** OAuth 代理登录允许的平台列表(服务端模式使用) */ + public static function oauth_proxy_allowed_providers(): array { + return AKSRT_Settings_Integration::oauth_proxy_allowed_providers(); + } + + /** OAuth 代理登录启用的平台列表(客户端模式使用) */ + public static function oauth_proxy_client_providers(): array { + return AKSRT_Settings_Integration::oauth_proxy_client_providers(); + } + + /** + * 获取微信公众号登录完整配置 + * + * @return array{gzh_type:string,token:string,token_nocheck:bool,encoding_aes_key:string,appid:string,appsecret:string,code_qrcode:string,code_keyword:string,code_reply:string,code_subscribe_reply:string,subscribe_msg:string,new_subscribe_msg:string,scan_msg:string,auto_reply_text:string,auto_reply_search:string,auto_reply_image:string,auto_reply_voice:string,auto_reply_default:string,gzh_menu:string,priority:bool,auto:bool,auto_expires:int} + */ + public static function get_weixingzh_settings(): array { + return AKSRT_Settings_Integration::get_weixingzh_settings(); + } + + /** + * 获取 OAuth 代理登录完整配置(aksrt_settings 优先,BC fallback aksrt_oauth) + * + * @return array{mode:string,secret:string,server_url:string,server_providers:array,client_providers:array,gzh_type:string} + */ + public static function get_oauth_proxy_settings(): array { + return AKSRT_Settings_Integration::get_oauth_proxy_settings(); + } + + // ============================================ + // 底部信息配置 + // ============================================ + + /** + * 获取底部信息完整配置(统一 aksrt_footer_settings + aksrt_settings page-display footer 字段) + * @return array + */ + public static function get_footer_settings(): array { + return AKSRT_Settings_Home_Cms::get_footer_settings(); + } + + /** 获取联系图标列表(仅返回启用的) */ + public static function get_footer_contacts(): array { + return AKSRT_Settings_Home_Cms::get_footer_contacts(); + } + + /** 获取快速链接列表(仅返回启用的,按排序) */ + public static function get_quick_links(): array { + return AKSRT_Settings_Home_Cms::get_quick_links(); + } + + /** 获取更多链接列表(仅返回启用的,按排序) */ + public static function get_more_links(): array { + return AKSRT_Settings_Home_Cms::get_more_links(); + } + + /** 获取热门分类显示数量 */ + public static function get_hot_categories_count(): int { + return AKSRT_Settings_Home_Cms::get_hot_categories_count(); + } + + /** 获取版权文字 */ + public static function get_copyright_text(): string { + return AKSRT_Settings_Home_Cms::get_copyright_text(); + } + + /** + * 获取页脚品牌列(第一列)配置 + * @return array {m_s, img, img_dark, title, custom_code} + */ + public static function get_footer_brand_settings(): array { + return AKSRT_Settings_Home_Cms::get_footer_brand_settings(); + } + + /** + * 获取页脚扩展联系方式配置 + * @return array {m_s, wechat_img, qq, weibo, email, more, code_1, code_2} + */ + public static function get_footer_contact_extended_settings(): array { + return AKSRT_Settings_Home_Cms::get_footer_contact_extended_settings(); + } + + /** + * 获取页脚二维码图片组(第三列) + * @return array {m_s, items: [{text, img}]} + */ + public static function get_footer_mini_img_settings(): array { + return AKSRT_Settings_Home_Cms::get_footer_mini_img_settings(); + } + + /** + * 获取页脚底部自定义代码 + */ + public static function get_footer_customize_code(): string { + return AKSRT_Settings_Home_Cms::get_footer_customize_code(); + } + + /** 获取审核配置 */ + public static function get_moderation(): array { + return AKSRT_Settings_Extension_Runtime::get_moderation(); + } + + // ============================================ + // 文章布局配置 + // ============================================ + + /** + * 获取归档布局模式。 + * + * 从 post_list.list schema 的 render_mode 字段读取。 + * 后台可在 /post-list/list 页面配置。 + * + * @return string grid-cms|list|compact + */ + public static function get_archive_layout(): string { + return AKSRT_Settings_Post_Display::get_archive_layout(); + } + + /** + * 获取文章列表分页模式 + * @return string numbered|load_more|infinite + */ + public static function get_post_pagination_mode(): string { + return AKSRT_Settings_Post_Display::get_post_pagination_mode(); + } + + /** + * 获取评论列表分页模式 + * @return string numbered|load_more|infinite + */ + public static function get_comment_pagination_mode(): string { + return AKSRT_Settings_Post_Display::get_comment_pagination_mode(); + } + + /** + * 获取懒加载配置 + * + * 返回 schema 定义的全部字段:全局开关、动画、占位图、模糊预览、 + * 以及 8 项粒度范围开关。 + * + * @return array {enabled, animation, placeholder_url, blur, avatar, content, comment, private, slider, cover, other, widget} + */ + public static function get_lazyload_config(): array { + $s = self::get_all(); + + // placeholder 兼容:schema 存 lazyload_placeholder,REST/Vue 存 lazyload_placeholder_url + $placeholder = ''; + if (!empty($s['lazyload_placeholder'])) { + $placeholder = $s['lazyload_placeholder']; + } elseif (!empty($s['lazyload_placeholder_url'])) { + $placeholder = $s['lazyload_placeholder_url']; + } + + return [ + 'enabled' => !isset($s['lazyload_enabled']) || !empty($s['lazyload_enabled']), + 'animation' => $s['lazyload_animation'] ?? 'blur', + 'placeholder_url' => (string) $placeholder, + 'blur' => !isset($s['lazyload_blur']) || !empty($s['lazyload_blur']), + 'avatar' => !isset($s['lazyload_avatar']) || !empty($s['lazyload_avatar']), + 'content' => !empty($s['lazyload_content']), + 'comment' => !isset($s['lazyload_comment']) || !empty($s['lazyload_comment']), + 'private' => !isset($s['lazyload_private']) || !empty($s['lazyload_private']), + 'slider' => !isset($s['lazyload_slider']) || !empty($s['lazyload_slider']), + 'cover' => !isset($s['lazyload_cover']) || !empty($s['lazyload_cover']), + 'other' => !isset($s['lazyload_other']) || !empty($s['lazyload_other']), + 'widget' => !isset($s['lazyload_widget']) || !empty($s['lazyload_widget']), + ]; + } + + // ============================================ + // CMS 配置 + // ============================================ + + /** + * 获取站点全局风格。 + * + * v4 起主题统一为 CMS / 资源站形态,恒返回 'cms'。 + * 保留方法签名以兼容旧调用。 + * + * @return string 始终为 'cms' + */ + public static function get_site_profile(): string { + return AKSRT_Settings_Home_Cms::get_site_profile(); + } + + /** + * CMS 功能开关(合并默认值)。 + * @return array + */ + public static function get_cms_features(): array { + return AKSRT_Settings_Home_Cms::get_cms_features(); + } + + /** + * 单项 CMS 功能开关读取。 + */ + public static function cms_feature(string $key, $default = null) { + return AKSRT_Settings_Home_Cms::cms_feature($key, $default); + } + + /** 所有邮件事件类型常量 */ + public const EMAIL_EVENTS = AKSRT_Settings_Mail::EMAIL_EVENTS; +} + +return AKSRT_Settings::get_instance(); diff --git a/inc/classes/class-aksrt-setup.php b/inc/classes/class-aksrt-setup.php new file mode 100644 index 00000000..bfd10fa9 --- /dev/null +++ b/inc/classes/class-aksrt-setup.php @@ -0,0 +1,383 @@ + 120, + 'width' => 300, + 'flex-height' => true, + 'flex-width' => true, + 'header-text' => true, + ]); + add_theme_support('customize-selective-refresh-widgets'); + add_theme_support('align-wide'); + add_theme_support('wp-block-styles'); + add_theme_support('responsive-embeds'); + + // 文章格式 + add_theme_support('post-formats', ['aside', 'image', 'video', 'quote', 'link', 'gallery']); + + // 图片尺寸 + add_image_size('aksrt-cover', 1200, 675, true); + add_image_size('aksrt-card', 400, 260, true); + add_image_size('aksrt-thumb', 150, 150, true); + add_image_size('aksrt-banner', 1920, 500, true); + + // 内容宽度(用于区块编辑器) + if (!isset($GLOBALS['content_width'])) { + $GLOBALS['content_width'] = 900; + } + } + + public function register_nav_menus(): void { + register_nav_menus([ + 'primary' => __('主导航', 'aksrt'), + 'mobile' => __('移动端菜单', 'aksrt'), + 'footer' => __('页脚菜单', 'aksrt'), + 'social' => __('社交链接', 'aksrt'), + 'header_actions' => __('顶部右侧入口', 'aksrt'), + ]); + } + + /** + * 注册自定义文章类型 + * + * v3.4: 移除 aksrt_resource CPT,资源元数据统一挂在 post 上, + * 通过 _aksrt_is_resource meta 标记单篇文章是否为“资源”。 + */ + public function register_post_types(): void { + // Shop rewrite settings (safe fallback if settings not yet loaded) + $shop_rewrite = []; + if (class_exists('AKSRT_Settings')) { + $shop = AKSRT_Settings::get_shop_settings(); + $shop_rewrite = $shop['rewrite'] ?? []; + } + $product_slug = sanitize_key((string) ($shop_rewrite['product_slug'] ?? 'product')); + $suffix_html = !empty($shop_rewrite['suffix_html']); + + // 商品 CPT + register_post_type('aksrt_product', [ + 'labels' => [ + 'name' => __('商品', 'aksrt'), + 'singular_name' => __('商品', 'aksrt'), + 'add_new' => __('新增商品', 'aksrt'), + 'add_new_item' => __('新增商品', 'aksrt'), + 'edit_item' => __('编辑商品', 'aksrt'), + 'new_item' => __('新商品', 'aksrt'), + 'view_item' => __('查看商品', 'aksrt'), + 'search_items' => __('搜索商品', 'aksrt'), + 'not_found' => __('未找到商品', 'aksrt'), + 'not_found_in_trash' => __('回收站中无商品', 'aksrt'), + 'menu_name' => __('商品', 'aksrt'), + ], + 'public' => true, + 'publicly_queryable' => true, + 'show_ui' => true, + 'show_in_menu' => false, + 'show_in_rest' => true, + 'has_archive' => true, + 'hierarchical' => false, + 'supports' => ['title', 'editor', 'excerpt', 'thumbnail', 'author', 'revisions'], + 'rewrite' => ['slug' => $product_slug, 'with_front' => false], + 'capability_type' => 'post', + 'map_meta_cap' => true, + ]); + + // 商品分类 taxonomy(独立于文章 category) + register_taxonomy('aksrt_product_cat', 'aksrt_product', [ + 'labels' => [ + 'name' => __('商品分类', 'aksrt'), + 'singular_name' => __('商品分类', 'aksrt'), + 'search_items' => __('搜索商品分类', 'aksrt'), + 'all_items' => __('所有商品分类', 'aksrt'), + 'parent_item' => __('父级分类', 'aksrt'), + 'edit_item' => __('编辑分类', 'aksrt'), + 'add_new_item' => __('新增分类', 'aksrt'), + 'menu_name' => __('商品分类', 'aksrt'), + ], + 'public' => true, + 'hierarchical' => true, + 'show_ui' => false, + 'show_in_rest' => true, + 'show_admin_column' => false, + 'rewrite' => ['slug' => 'product-cat', 'with_front' => false], + ]); + + // 商品标签 taxonomy(独立于文章 post_tag) + register_taxonomy('aksrt_product_tag', 'aksrt_product', [ + 'labels' => [ + 'name' => __('商品标签', 'aksrt'), + 'singular_name' => __('商品标签', 'aksrt'), + 'search_items' => __('搜索商品标签', 'aksrt'), + 'all_items' => __('所有商品标签', 'aksrt'), + 'edit_item' => __('编辑标签', 'aksrt'), + 'add_new_item' => __('新增标签', 'aksrt'), + 'menu_name' => __('商品标签', 'aksrt'), + ], + 'public' => true, + 'hierarchical' => false, + 'show_ui' => false, + 'show_in_rest' => true, + 'show_admin_column' => false, + 'rewrite' => ['slug' => 'product-tag', 'with_front' => false, + ], + ]); + + // 项目展示 + register_post_type('project', [ + 'labels' => [ + 'name' => __('项目展示', 'aksrt'), + 'singular_name' => __('项目', 'aksrt'), + 'add_new' => __('新建项目', 'aksrt'), + 'add_new_item' => __('新建项目', 'aksrt'), + 'edit_item' => __('编辑项目', 'aksrt'), + 'new_item' => __('新项目', 'aksrt'), + 'view_item' => __('查看项目', 'aksrt'), + 'search_items' => __('搜索项目', 'aksrt'), + 'not_found' => __('未找到项目', 'aksrt'), + 'not_found_in_trash' => __('回收站中无项目', 'aksrt'), + 'menu_name' => __('项目展示', 'aksrt'), + ], + 'public' => true, + 'publicly_queryable' => true, + 'show_ui' => true, + 'show_in_menu' => false, + 'show_in_nav_menus' => false, + 'has_archive' => false, + 'hierarchical' => false, + 'menu_position' => 25, + 'menu_icon' => 'dashicons-portfolio', + 'supports' => ['title', 'editor', 'excerpt', 'thumbnail', 'author', 'revisions'], + 'rewrite' => ['slug' => 'project'], + 'capability_type' => 'post', + 'map_meta_cap' => true, + ]); + } + + /** + * 注册资源 post meta + 分类 term meta(aksrt_flow) + * + * 全部 show_in_rest,便于 Block Editor + Vue SPA 读写。 + * + * v3.4: 资源元数据全部挂在 post 上,配合 _aksrt_is_resource 开关使用。 + */ + public function register_meta_fields(): void { + $post_edit_auth = static function () { + return current_user_can('edit_posts'); + }; + + // Core switch: mark a post as resource. + register_post_meta('post', AKSRT_Domain_Model::RESOURCE_MARKER_KEY, [ + 'type' => 'boolean', + 'description' => 'Whether this post is a resource post.', + 'single' => true, + 'default' => false, + 'show_in_rest' => true, + 'sanitize_callback' => static function ($v) { return (bool) $v; }, + 'auth_callback' => $post_edit_auth, + ]); + + register_post_meta('post', AKSRT_Domain_Model::PAY_META_KEY, [ + 'type' => 'object', + 'description' => 'Canonical paid content payload.', + 'single' => true, + 'default' => AKSRT_Domain_Model::default_pay_meta(), + 'show_in_rest' => [ + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'pay_type' => [ + 'type' => 'string', + 'enum' => array_keys(AKSRT_Domain_Model::pay_type_labels()), + ], + 'price' => ['type' => 'number'], + 'original_price' => ['type' => 'number'], + 'vip1_price' => ['type' => 'number'], + 'vip2_price' => ['type' => 'number'], + 'version' => ['type' => 'string'], + 'size' => ['type' => 'string'], + 'license' => ['type' => 'string'], + 'compat' => ['type' => 'string'], + 'demo_url' => ['type' => 'string', 'format' => 'uri'], + 'video_url' => ['type' => 'string', 'format' => 'uri'], + 'downloads' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'label' => ['type' => 'string'], + 'url' => ['type' => 'string', 'format' => 'uri'], + 'type' => ['type' => 'string'], + 'extract_code' => ['type' => 'string'], + ], + ], + ], + 'attributes' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'key' => ['type' => 'string'], + 'value' => ['type' => 'string'], + ], + ], + ], + 'gallery' => [ + 'type' => 'array', + 'items' => ['type' => 'string', 'format' => 'uri'], + ], + 'require_login' => ['type' => 'boolean'], + 'require_captcha' => ['type' => 'boolean'], + ], + ], + ], + 'sanitize_callback' => [AKSRT_Domain_Model::class, 'sanitize_pay_meta'], + 'auth_callback' => $post_edit_auth, + ]); + + register_post_meta('post', AKSRT_Domain_Model::PAY_TYPE_KEY, [ + 'type' => 'string', + 'description' => 'Query-friendly paid content type.', + 'single' => true, + 'default' => AKSRT_Domain_Model::PAY_TYPE_NO, + 'show_in_rest' => true, + 'sanitize_callback' => [AKSRT_Domain_Model::class, 'normalize_pay_type'], + 'auth_callback' => $post_edit_auth, + ]); + + register_post_meta('post', AKSRT_Domain_Model::PAY_PRICE_KEY, [ + 'type' => 'number', + 'description' => 'Query-friendly paid content price.', + 'single' => true, + 'default' => 0, + 'show_in_rest' => true, + 'sanitize_callback' => static function ($value) { + return max(0, (float) $value); + }, + 'auth_callback' => $post_edit_auth, + ]); + + foreach ([ + AKSRT_Domain_Model::RESOURCE_DOWNLOAD_COUNT_KEY => 'Resource download count', + AKSRT_Domain_Model::RESOURCE_VIEW_COUNT_KEY => 'Resource view count', + AKSRT_Domain_Model::POST_VIEW_COUNT_KEY => 'Generic post view count', + ] as $meta_key => $description) { + register_post_meta('post', $meta_key, [ + 'type' => 'integer', + 'description' => $description, + 'single' => true, + 'default' => 0, + 'show_in_rest' => true, + 'sanitize_callback' => 'absint', + 'auth_callback' => $post_edit_auth, + ]); + } + + register_term_meta('category', 'aksrt_flow', [ + 'type' => 'string', + 'description' => 'Category flow mode: inherit/blog/cms', + 'single' => true, + 'default' => 'inherit', + 'show_in_rest' => true, + 'sanitize_callback' => function ($v) { + $v = is_string($v) ? $v : 'inherit'; + return in_array($v, ['inherit', 'blog', 'cms'], true) ? $v : 'inherit'; + }, + 'auth_callback' => function () { + return current_user_can('manage_categories'); + }, + ]); + } + public static function sanitize_downloads_meta($value): array { + return AKSRT_Domain_Model::sanitize_downloads($value); + } + + /** + * 为 Customizer 小工具实时预览补齐编辑态包裹。 + * + * @param array $params Dynamic sidebar params. + * @return array + */ + public function filter_sidebar_widget_params(array $params): array { + if (!is_customize_preview() || empty($params[0]['before_widget']) || empty($params[0]['after_widget'])) { + return $params; + } + + $params[0]['before_widget'] = '
' . $params[0]['before_widget']; + $params[0]['after_widget'] = $params[0]['after_widget'] . '
'; + + return $params; + } + + /** + * 注册侧边栏 + */ +} + +return AKSRT_Setup::get_instance(); diff --git a/inc/classes/class-aksrt-shipping.php b/inc/classes/class-aksrt-shipping.php new file mode 100644 index 00000000..869fb2f6 --- /dev/null +++ b/inc/classes/class-aksrt-shipping.php @@ -0,0 +1,373 @@ +insert($this->table(), [ + 'order_number' => $order_number, + 'carrier' => $carrier, + 'tracking_no' => $tracking_no, + 'status' => self::STATUS_SHIPPED, + 'note' => $note, + 'operator_id' => $operator_id ?: null, + 'created_at' => current_time('mysql'), + ]); + + $insert_id = $result ? (int) $wpdb->insert_id : 0; + + if ($insert_id > 0) { + $this->sync_order_status($order_number, self::STATUS_SHIPPED, sprintf( + __('发货记录已创建:%1$s %2$s', 'aksrt'), + $this->get_carrier_label($carrier), + $tracking_no + )); + + // R68: Email notification on shipping creation (§6.6) + $this->send_shipping_notification($order_number, $carrier, $tracking_no, $note); + } + + return $insert_id; + } + + /** + * 获取发货记录列表 + */ + public function get_list(array $args = []): array { + global $wpdb; + + $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); + $offset = max(0, (int) ($args['offset'] ?? 0)); + $include_order_context = !empty($args['include_order_context']); + $status = sanitize_key((string) ($args['status'] ?? '')); + $order_number = sanitize_text_field((string) ($args['order_number'] ?? '')); + $date_from = sanitize_text_field((string) ($args['date_from'] ?? '')); + $date_to = sanitize_text_field((string) ($args['date_to'] ?? '')); + $tracking_no = sanitize_text_field((string) ($args['tracking_no'] ?? '')); + + $where = []; + $params = []; + + if ($status !== '') { + $where[] = 'status = %s'; + $params[] = $status; + } + if ($order_number !== '') { + $where[] = 'order_number = %s'; + $params[] = $order_number; + } + if ($tracking_no !== '') { + $where[] = 'tracking_no LIKE %s'; + $params[] = '%' . $wpdb->esc_like($tracking_no) . '%'; + } + if ($date_from !== '') { + $where[] = 'created_at >= %s'; + $params[] = $date_from . ' 00:00:00'; + } + if ($date_to !== '') { + $where[] = 'created_at <= %s'; + $params[] = $date_to . ' 23:59:59'; + } + + $where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; + + $count_sql = "SELECT COUNT(*) FROM {$this->table()} {$where_sql}"; + $total = $params ? (int) $wpdb->get_var($wpdb->prepare($count_sql, $params)) : (int) $wpdb->get_var($count_sql); + + $sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d"; + $params[] = $limit; + $params[] = $offset; + + $items = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A); + $items = array_map([$this, 'decorate_record'], (array) $items); + + if ($include_order_context && !empty($items) && class_exists('AKSRT_Order_Repository')) { + $contexts = AKSRT_Order_Repository::get_instance()->get_admin_order_context_map(array_column($items, 'order_number')); + foreach ($items as &$item) { + $item['order_context'] = $contexts[(string) ($item['order_number'] ?? '')] ?? []; + } + unset($item); + } + + return [ + 'items' => $items ?: [], + 'total' => $total, + 'page' => floor($offset / $limit) + 1, + 'max_pages' => ceil($total / $limit), + ]; + } + + /** + * 获取单个发货记录 + */ + 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 $row ? $this->decorate_record($row) : null; + } + + /** + * 获取订单的发货记录 + */ + public function get_by_order(string $order_number): array { + global $wpdb; + $items = $wpdb->get_results($wpdb->prepare( + "SELECT * FROM {$this->table()} WHERE order_number = %s ORDER BY created_at DESC", + $order_number + ), ARRAY_A); + return array_map([$this, 'decorate_record'], (array) $items); + } + + /** + * 获取单个发货记录并附带订单上下文。 + */ + public function get_with_order_context(int $id): ?array { + $record = $this->get($id); + if (!$record) { + return null; + } + + $record['order_context'] = class_exists('AKSRT_Order_Repository') + ? AKSRT_Order_Repository::get_instance()->get_admin_order_context((string) ($record['order_number'] ?? '')) + : []; + + return $record; + } + + /** + * @param array $order_numbers + * @return array> + */ + public function get_summary_map(array $order_numbers): array { + global $wpdb; + + $order_numbers = array_values(array_unique(array_filter(array_map( + static function ($order_number): string { + return sanitize_text_field((string) $order_number); + }, + $order_numbers + )))); + + if (empty($order_numbers)) { + return []; + } + + $map = []; + foreach ($order_numbers as $order_number) { + $map[$order_number] = [ + 'count' => 0, + 'latest' => [], + ]; + } + + $placeholders = implode(', ', array_fill(0, count($order_numbers), '%s')); + $sql = "SELECT * FROM {$this->table()} WHERE order_number IN ({$placeholders}) ORDER BY created_at DESC, id DESC"; + $rows = $wpdb->get_results($wpdb->prepare($sql, $order_numbers), ARRAY_A); + + foreach ((array) $rows as $row) { + $record = $this->decorate_record($row); + $order_number = (string) ($record['order_number'] ?? ''); + + if (!isset($map[$order_number])) { + continue; + } + + $map[$order_number]['count']++; + if (empty($map[$order_number]['latest'])) { + $map[$order_number]['latest'] = $this->build_summary_item($record); + } + } + + return $map; + } + + public function batch_update_status(array $ids, string $new_status, string $note = '', int $operator_id = 0): array { + $ids = array_values(array_unique(array_filter(array_map('intval', $ids)))); + $result = [ + 'updated_ids' => [], + 'failed' => [], + ]; + + foreach ($ids as $id) { + $record = $this->get($id); + if (!$record) { + $result['failed'][] = [ + 'id' => $id, + 'reason' => __('发货记录不存在', 'aksrt'), + ]; + continue; + } + + if (!$this->can_transition_status((string) ($record['status'] ?? ''), $new_status)) { + $result['failed'][] = [ + 'id' => $id, + 'reason' => __('当前状态不允许批量更新到目标状态', 'aksrt'), + ]; + continue; + } + + if ($this->update_status($id, $new_status, $note, $operator_id)) { + $result['updated_ids'][] = $id; + } else { + $result['failed'][] = [ + 'id' => $id, + 'reason' => __('状态更新失败', 'aksrt'), + ]; + } + } + + return $result; + } + + /** + * 更新发货状态 + */ + public function update_status(int $id, string $new_status, string $note = '', int $operator_id = 0): bool { + global $wpdb; + + $record = $this->get($id); + if (!$record) { + return false; + } + + $valid = [self::STATUS_SHIPPED, self::STATUS_IN_TRANSIT, self::STATUS_DELIVERED, self::STATUS_RETURNED]; + if (!in_array($new_status, $valid, true)) return false; + if (!$this->can_transition_status((string) ($record['status'] ?? ''), $new_status)) { + return false; + } + + $update = [ + 'status' => $new_status, + 'updated_at' => current_time('mysql'), + ]; + + if ($note !== '') { + $update['note'] = sanitize_textarea_field($note); + } + if ($operator_id > 0) { + $update['operator_id'] = $operator_id; + } + if ($new_status === self::STATUS_DELIVERED) { + $update['delivered_at'] = current_time('mysql'); + } + + $updated = (bool) $wpdb->update($this->table(), $update, ['id' => $id]); + if ($updated) { + $this->sync_order_status( + (string) ($record['order_number'] ?? ''), + $new_status, + $note !== '' ? $note : sprintf(__('物流状态更新为:%s', 'aksrt'), $this->get_status_label($new_status)) + ); + } + + return $updated; + } + + /** + * 获取统计 + */ + public function get_stats(): array { + global $wpdb; + $table = $this->table(); + + $counts = $wpdb->get_results( + "SELECT status, COUNT(*) as cnt FROM {$table} GROUP BY status", + ARRAY_A + ); + $counts = array_column($counts, 'cnt', 'status'); + + $stats = [ + 'total' => 0, + 'shipped' => 0, + 'in_transit' => 0, + 'delivered' => 0, + 'returned' => 0, + ]; + + foreach ($counts as $status => $cnt) { + if (isset($stats[$status])) { + $stats[$status] = (int) $cnt; + } + $stats['total'] += (int) $cnt; + } + + return $stats; + } + + /** + * 获取常用快递公司列表 + */ + public static function get_carrier_options(): array { + return [ + ['value' => 'sf', 'label' => '顺丰速运'], + ['value' => 'yd', 'label' => '韵达快递'], + ['value' => 'zt', 'label' => '中通快递'], + ['value' => 'yt', 'label' => '圆通速递'], + ['value' => 'sto', 'label' => '申通快递'], + ['value' => 'jd', 'label' => '京东物流'], + ['value' => 'ems', 'label' => 'EMS'], + ['value' => 'db', 'label' => '德邦快递'], + ['value' => 'yunda', 'label' => '韵达快递'], + ['value' => 'other', 'label' => '其他'], + ]; + } + public static function get_status_options(): array { + return [ + ['value' => self::STATUS_SHIPPED, 'label' => __('已发货', 'aksrt')], + ['value' => self::STATUS_IN_TRANSIT, 'label' => __('运输中', 'aksrt')], + ['value' => self::STATUS_DELIVERED, 'label' => __('已签收', 'aksrt')], + ['value' => self::STATUS_RETURNED, 'label' => __('已退回', 'aksrt')], + ]; + } + + /** + * Send email notification to order customer when shipping record is created. + */ +} diff --git a/inc/classes/class-aksrt-shop-term-meta.php b/inc/classes/class-aksrt-shop-term-meta.php new file mode 100644 index 00000000..f14b028e --- /dev/null +++ b/inc/classes/class-aksrt-shop-term-meta.php @@ -0,0 +1,402 @@ + [ + 'name' => __('优惠活动', 'aksrt'), + 'singular_name' => __('优惠活动', 'aksrt'), + 'search_items' => __('搜索优惠活动', 'aksrt'), + 'all_items' => __('所有优惠活动', 'aksrt'), + 'edit_item' => __('编辑优惠活动', 'aksrt'), + 'add_new_item' => __('新增优惠活动', 'aksrt'), + 'menu_name' => __('优惠活动', 'aksrt'), + ], + 'public' => true, + 'hierarchical' => false, + 'show_ui' => false, + 'show_in_rest' => true, + 'show_admin_column' => false, + 'rewrite' => ['slug' => 'shop-discount', 'with_front' => false], + ]); + } + + // ─── Term Meta Registration ────────────────────────────────── + + /** + * Register all term meta fields for REST API exposure. + */ + public function register_term_meta_fields(): void { + $auth = function () { + return current_user_can('manage_categories'); + }; + + // ── aksrt_product_cat ── + $cat_fields = $this->get_cat_field_definitions(); + foreach ($cat_fields as $key => $def) { + register_term_meta('aksrt_product_cat', $key, [ + 'type' => $def['type'], + 'description' => $def['description'] ?? $key, + 'single' => true, + 'default' => $def['default'] ?? '', + 'show_in_rest' => true, + 'sanitize_callback' => $def['sanitize'] ?? 'sanitize_text_field', + 'auth_callback' => $auth, + ]); + } + + // ── aksrt_product_tag ── + $tag_fields = $this->get_tag_field_definitions(); + foreach ($tag_fields as $key => $def) { + register_term_meta('aksrt_product_tag', $key, [ + 'type' => $def['type'], + 'description' => $def['description'] ?? $key, + 'single' => true, + 'default' => $def['default'] ?? '', + 'show_in_rest' => true, + 'sanitize_callback' => $def['sanitize'] ?? 'sanitize_text_field', + 'auth_callback' => $auth, + ]); + } + + // ── shop_discount ── + $discount_fields = $this->get_discount_field_definitions(); + foreach ($discount_fields as $key => $def) { + register_term_meta('shop_discount', $key, [ + 'type' => $def['type'], + 'description' => $def['description'] ?? $key, + 'single' => true, + 'default' => $def['default'] ?? '', + 'show_in_rest' => true, + 'sanitize_callback' => $def['sanitize'] ?? 'sanitize_text_field', + 'auth_callback' => $auth, + ]); + } + } + + // ─── Field Definitions ─────────────────────────────────────── + + /** + * Product category term meta field definitions. + * + * @return array + */ + private function get_cat_field_definitions(): array { + return [ + 'shop_cat_limit_buy' => [ + 'type' => 'integer', 'description' => '分类级限购 (-1 不限)', 'default' => -1, + 'sanitize' => function ($v) { return max(-1, min(9999, (int) $v)); }, + ], + 'shop_cat_shipping_fee' => [ + 'type' => 'string', 'description' => '分类级运费类型', 'default' => '', + 'sanitize' => function ($v) { + $v = sanitize_key((string) $v); + return in_array($v, ['', 'free', 'fixed', 'amount'], true) ? $v : ''; + }, + ], + 'shop_cat_guest_buy' => [ + 'type' => 'string', 'description' => '分类级免登录购买', 'default' => '', + ], + 'shop_cat_email_fill' => [ + 'type' => 'string', 'description' => '分类级邮箱必留', 'default' => '', + ], + 'shop_cat_after_sale' => [ + 'type' => 'string', 'description' => '分类级售后政策', 'default' => '', + 'sanitize' => function ($v) { + $v = sanitize_key((string) $v); + return in_array($v, ['', 'refund', 'refund_return', 'none'], true) ? $v : ''; + }, + ], + 'shop_cat_rebate' => [ + 'type' => 'string', 'description' => '分类级返佣覆盖', 'default' => '', + ], + 'shop_cat_service' => [ + 'type' => 'string', 'description' => '分类级服务保障', 'default' => '', + 'sanitize' => 'sanitize_textarea_field', + ], + 'shop_cat_single_tab' => [ + 'type' => 'string', 'description' => '分类级自定义 TAB', 'default' => '', + 'sanitize' => 'sanitize_textarea_field', + ], + 'shop_cat_content_after' => [ + 'type' => 'string', 'description' => '分类级详情页底部内容', 'default' => '', + 'sanitize' => 'wp_kses_post', + ], + 'shop_cat_content_layout' => [ + 'type' => 'string', 'description' => '分类级内容布局', 'default' => '', + 'sanitize' => function ($v) { + $v = sanitize_key((string) $v); + return in_array($v, ['', 'adapt', 'full', 'sidebar'], true) ? $v : ''; + }, + ], + 'shop_cat_content_show_bg' => [ + 'type' => 'string', 'description' => '分类级详情背景盒子', 'default' => '', + ], + 'shop_cat_list_style' => [ + 'type' => 'string', 'description' => '分类页列表样式', 'default' => '', + 'sanitize' => function ($v) { + $v = sanitize_key((string) $v); + return in_array($v, ['', 'default', 'card-lg', 'card-sm'], true) ? $v : ''; + }, + ], + ]; + } + + /** + * Product tag term meta field definitions. + * + * @return array + */ + private function get_tag_field_definitions(): array { + return [ + 'shop_tag_priority' => [ + 'type' => 'integer', 'description' => '标签优先级(影响显示顺序)', 'default' => 0, + 'sanitize' => function ($v) { return max(0, min(9999, (int) $v)); }, + ], + 'shop_tag_class' => [ + 'type' => 'string', 'description' => '标签颜色 class', 'default' => '', + 'sanitize' => 'sanitize_html_class', + ], + 'shop_tag_is_important' => [ + 'type' => 'string', 'description' => '重点标签(商品价格位着重显示)', 'default' => '', + ], + ]; + } + + /** + * Shop discount term meta field definitions. + * + * @return array + */ + private function get_discount_field_definitions(): array { + return [ + 'shop_discount_small_badge' => [ + 'type' => 'string', 'description' => '小标签文案', 'default' => '', + ], + 'shop_discount_priority' => [ + 'type' => 'integer', 'description' => '优先级(数字越大越优先)', 'default' => 0, + 'sanitize' => function ($v) { return max(0, min(9999, (int) $v)); }, + ], + 'shop_discount_is_important' => [ + 'type' => 'string', 'description' => '重点活动', 'default' => '', + ], + 'shop_discount_important_class' => [ + 'type' => 'string', 'description' => '重点活动背景色 class', 'default' => '', + 'sanitize' => 'sanitize_html_class', + ], + 'shop_discount_scope' => [ + 'type' => 'string', 'description' => '优惠范围', 'default' => 'item', + 'sanitize' => function ($v) { + $v = sanitize_key((string) $v); + return in_array($v, ['item', 'product', 'author', 'order'], true) ? $v : 'item'; + }, + ], + 'shop_discount_type' => [ + 'type' => 'string', 'description' => '优惠类型', 'default' => 'reduction', + 'sanitize' => function ($v) { + $v = sanitize_key((string) $v); + return in_array($v, ['reduction', 'discount', 'gift'], true) ? $v : 'reduction'; + }, + ], + 'shop_discount_value' => [ + 'type' => 'string', 'description' => '优惠数值(立减金额/折扣 0.01-9.99)', 'default' => '0', + 'sanitize' => function ($v) { return sanitize_text_field((string) $v); }, + ], + 'shop_discount_gift_config' => [ + 'type' => 'string', 'description' => '赠品配置 JSON', 'default' => '', + 'sanitize' => 'sanitize_textarea_field', + ], + 'shop_discount_price_limit' => [ + 'type' => 'string', 'description' => '金额门槛', 'default' => '0', + 'sanitize' => function ($v) { return max(0, (float) $v); }, + ], + 'shop_discount_user_limit' => [ + 'type' => 'string', 'description' => '用户身份限制', 'default' => '', + 'sanitize' => function ($v) { + $v = sanitize_key((string) $v); + return in_array($v, ['', 'vip', 'vip_2', 'auth'], true) ? $v : ''; + }, + ], + 'shop_discount_start' => [ + 'type' => 'string', 'description' => '活动开始时间', 'default' => '', + 'sanitize' => 'sanitize_text_field', + ], + 'shop_discount_end' => [ + 'type' => 'string', 'description' => '活动结束时间', 'default' => '', + 'sanitize' => 'sanitize_text_field', + ], + 'shop_discount_countdown' => [ + 'type' => 'string', 'description' => '倒计时显示', 'default' => '', + ], + ]; + } + + // ─── Getters ───────────────────────────────────────────────── + + /** + * Get all shop term meta for a product category. + * + * @param int $term_id + * @return array + */ + public static function get_cat_meta(int $term_id): array { + $defaults = [ + 'limit_buy' => -1, + 'shipping_fee' => '', + 'guest_buy' => '', + 'email_fill' => '', + 'after_sale' => '', + 'rebate' => '', + 'service' => '', + 'single_tab' => '', + 'content_after' => '', + 'content_layout' => '', + 'content_show_bg' => '', + 'list_style' => '', + ]; + + $result = []; + foreach ($defaults as $key => $default) { + $meta_key = "shop_cat_{$key}"; + $val = get_term_meta($term_id, $meta_key, true); + $result[$key] = ($val !== '' && $val !== false) ? $val : $default; + } + + return $result; + } + + /** + * Get all shop term meta for a product tag. + * + * @param int $term_id + * @return array + */ + public static function get_tag_meta(int $term_id): array { + $priority = get_term_meta($term_id, 'shop_tag_priority', true); + $class = get_term_meta($term_id, 'shop_tag_class', true); + $is_important = get_term_meta($term_id, 'shop_tag_is_important', true); + + return [ + 'priority' => $priority !== '' ? (int) $priority : 0, + 'class' => is_string($class) ? $class : '', + 'is_important' => !empty($is_important), + ]; + } + + /** + * Get all shop discount term meta. + * + * @param int $term_id + * @return array + */ + public static function get_discount_meta(int $term_id): array { + $defaults = [ + 'small_badge' => '', + 'priority' => 0, + 'is_important' => false, + 'important_class' => '', + 'scope' => 'item', + 'type' => 'reduction', + 'value' => '0', + 'gift_config' => '', + 'price_limit' => '0', + 'user_limit' => '', + 'start' => '', + 'end' => '', + 'countdown' => '', + ]; + + $result = []; + foreach ($defaults as $key => $default) { + $meta_key = "shop_discount_{$key}"; + $val = get_term_meta($term_id, $meta_key, true); + $result[$key] = ($val !== '' && $val !== false) ? $val : $default; + } + + $result['priority'] = (int) $result['priority']; + $result['is_important'] = !empty($result['is_important']); + $result['price_limit'] = (float) $result['price_limit']; + + return $result; + } + + /** + * Resolve parameter inheritance: product → category → theme settings. + * + * Returns the effective value for a given parameter key by checking: + * 1. Product-level override (from post meta) + * 2. Category-level override (from term meta) + * 3. Theme-level default (from AKSRT_Settings::get_shop_settings()) + * + * @param int $product_id Product post ID + * @param string $param_key Parameter key (e.g. 'limit_buy', 'shipping_fee') + * @param mixed $product_value Product-level value (null if not set) + * @return mixed + */ + public static function resolve_inheritance(int $product_id, string $param_key, $product_value = null) { + // 1. Product-level override + if ($product_value !== null && $product_value !== '' && $product_value !== -1) { + return $product_value; + } + + // 2. Category-level override + $terms = get_the_terms($product_id, 'aksrt_product_cat'); + if (is_array($terms) && !empty($terms)) { + $cat_meta_key = "shop_cat_{$param_key}"; + foreach ($terms as $term) { + $val = get_term_meta($term->term_id, $cat_meta_key, true); + if ($val !== '' && $val !== false && $val !== -1) { + return $val; + } + } + } + + // 3. Theme-level default + $shop = AKSRT_Settings::get_shop_settings(); + $defaults = $shop['product_defaults'] ?? []; + return $defaults[$param_key] ?? $product_value; + } +} diff --git a/inc/classes/class-aksrt-shop.php b/inc/classes/class-aksrt-shop.php new file mode 100644 index 00000000..f5f2756a --- /dev/null +++ b/inc/classes/class-aksrt-shop.php @@ -0,0 +1,417 @@ +cart_svc) { + $this->cart_svc = new AKSRT_Shop_Cart_Service($this); + } + return $this->cart_svc; + } + + private function checkout_service(): AKSRT_Shop_Checkout_Service { + if (!$this->checkout_svc) { + $this->checkout_svc = new AKSRT_Shop_Checkout_Service($this); + } + return $this->checkout_svc; + } + + private function address_service(): AKSRT_Shop_Address_Service { + if (!$this->address_svc) { + $this->address_svc = new AKSRT_Shop_Address_Service($this); + } + return $this->address_svc; + } + + private function product_service(): AKSRT_Shop_Product_Service { + if ($this->product_svc === null) { + $this->product_svc = new AKSRT_Shop_Product_Service($this); + } + return $this->product_svc; + } + + public function get_config(): array { + if (class_exists('AKSRT_Settings')) { + return AKSRT_Settings::get_shop_settings(); + } + + return ['enabled' => false]; + } + + public function get_products(array $args = []): array { + $limit = max(1, (int) ($args['limit'] ?? 20)); + $offset = max(0, (int) ($args['offset'] ?? 0)); + $search = sanitize_text_field((string) ($args['search'] ?? '')); + $status = sanitize_key((string) ($args['status'] ?? '')); + + $query_args = [ + 'post_type' => 'aksrt_product', + 'posts_per_page' => $limit, + 'offset' => $offset, + 'post_status' => $status !== '' ? $status : ['publish', 'draft', 'pending'], + 'orderby' => 'date', + 'order' => 'DESC', + ]; + + if ($search !== '') { + $query_args['s'] = $search; + } + + $query = new WP_Query($query_args); + $products = []; + + while ($query->have_posts()) { + $query->the_post(); + $products[] = $this->build_product_card_view(get_the_ID()); + } + + wp_reset_postdata(); + + return $products; + } + + public function get_product_detail_view(int $product_id): array { + $product = get_post($product_id); + if (!$product || $product->post_type !== 'aksrt_product') { + return []; + } + + $detail = $this->build_product_card_view($product_id); + $detail['content'] = (string) $product->post_content; + $detail['excerpt'] = (string) $product->post_excerpt; + $detail['permalink'] = get_permalink($product_id) ?: ''; + $detail['status'] = (string) get_post_status($product_id); + $detail['gallery'] = $this->product_service()->get_product_gallery($product_id); + $detail['categories'] = wp_get_post_terms($product_id, 'aksrt_product_cat', ['fields' => 'all']) ?: []; + $detail['tags'] = wp_get_post_terms($product_id, 'aksrt_product_tag', ['fields' => 'all']) ?: []; + $detail['shipping'] = $this->product_service()->get_product_shipping_profile($product_id); + $detail['after_sale'] = $this->product_service()->get_product_after_sale_profile($product_id); + $detail['marketing'] = $this->product_service()->get_product_marketing_profile($product_id); + $detail['checkout_hint'] = $this->get_checkout_hint($detail); + $detail['purchase_enabled'] = ($detail['is_unlimited'] || $detail['stock'] > 0) && $detail['status'] === 'publish'; + $detail['default_quantity'] = 1; + $detail['options'] = $this->get_product_options($product_id); + $detail['has_options'] = $this->has_options($product_id); + $detail['price_range'] = $this->get_price_range($product_id); + $detail['pay_modo'] = sanitize_key((string) get_post_meta($product_id, '_aksrt_pay_modo', true)) ?: '0'; + $detail['start_price'] = (float) get_post_meta($product_id, '_aksrt_start_price', true); + $detail['shipping_type'] = sanitize_key((string) get_post_meta($product_id, '_aksrt_shipping_type', true)) ?: 'express'; + $detail['auto_delivery'] = $this->get_auto_delivery_config($product_id); + $detail['limit_buy'] = $this->get_limit_buy_config($product_id); + $detail['services'] = $this->get_product_services($product_id); + $detail['params'] = $this->get_product_params($product_id); + + return $detail; + } + + public function get_product_edit_view(int $user_id, int $product_id = 0): array { + $view = [ + 'allowed' => true, + 'is_edit' => false, + 'product' => [ + 'id' => 0, + 'title' => '', + 'price' => 0, + 'stock' => 0, + 'excerpt' => '', + 'content' => '', + 'image' => '', + 'gallery' => [], + 'categories' => [], + 'tags' => [], + 'shipping_mode' => 'express', + 'shipping_fee' => 0, + 'after_sale_policy' => '', + 'support_refund' => true, + 'status' => 'draft', + ], + 'taxonomy' => [ + 'categories' => $this->product_service()->get_term_options('aksrt_product_cat'), + 'tags' => $this->product_service()->get_term_options('aksrt_product_tag'), + ], + 'shipping_modes' => $this->get_shipping_method_options(), + 'status_options' => [ + ['value' => 'draft', 'label' => __('草稿', 'aksrt')], + ['value' => 'publish', 'label' => __('发布', 'aksrt')], + ['value' => 'pending', 'label' => __('待审核', 'aksrt')], + ], + ]; + + if ($product_id <= 0) { + return $view; + } + + $product = get_post($product_id); + if (!$product || $product->post_type !== 'aksrt_product') { + return $view; + } + + $is_owner = (int) $product->post_author === $user_id || current_user_can('manage_options'); + if (!$is_owner) { + $view['allowed'] = false; + return $view; + } + + $view['is_edit'] = true; + $view['product'] = [ + 'id' => $product_id, + 'title' => (string) $product->post_title, + 'price' => (float) get_post_meta($product_id, '_aksrt_price', true), + 'stock' => (int) get_post_meta($product_id, '_aksrt_stock', true), + 'excerpt' => (string) $product->post_excerpt, + 'content' => (string) $product->post_content, + 'image' => $this->resolve_main_image($product_id), + 'gallery' => $this->product_service()->get_product_gallery($product_id), + 'categories' => wp_get_post_terms($product_id, 'aksrt_product_cat', ['fields' => 'ids']) ?: [], + 'tags' => wp_get_post_terms($product_id, 'aksrt_product_tag', ['fields' => 'names']) ?: [], + 'shipping_mode' => sanitize_key((string) get_post_meta($product_id, '_aksrt_shipping_mode', true)) ?: 'express', + 'shipping_fee' => (float) get_post_meta($product_id, '_aksrt_shipping_fee', true), + 'after_sale_policy' => (string) get_post_meta($product_id, '_aksrt_after_sale_policy', true), + 'support_refund' => get_post_meta($product_id, '_aksrt_support_refund', true) !== '0', + 'status' => (string) get_post_status($product_id), + ]; + + return $view; + } + + public function get_cart_page_view(int $user_id): array { + $pending_orders = $this->get_order_center_view($user_id, [ + 'page' => 1, + 'per_page' => 5, + 'status' => AKSRT_Order::STATUS_PENDING, + ]); + + return [ + 'payment_methods' => $this->get_payment_method_options(), + 'shipping_methods' => $this->get_shipping_method_options(), + 'address_book' => $this->get_user_address_book($user_id), + 'coupon_enabled' => class_exists('AKSRT_Coupon'), + 'currency_symbol' => '¥', + 'pending_orders' => $pending_orders['items'] ?? [], + 'cart_items' => $this->get_cart($user_id), + 'cart_count' => $this->get_cart_count($user_id), + ]; + } + + public function get_cart(int $user_id): array { + return $this->cart_service()->get_cart($user_id); + } + + public function get_cart_count(int $user_id): int { + return $this->cart_service()->get_cart_count($user_id); + } + + public function add_to_cart(int $user_id, int $product_id, string $options_string = '', int $count = 1): array { + return $this->cart_service()->add_to_cart($user_id, $product_id, $options_string, $count); + } + + public function reduce_from_cart(int $user_id, int $product_id, string $options_string = '', int $count = 1): array { + return $this->cart_service()->reduce_from_cart($user_id, $product_id, $options_string, $count); + } + + public function remove_from_cart(int $user_id, int $product_id, string $options_string = ''): array { + return $this->cart_service()->remove_from_cart($user_id, $product_id, $options_string); + } + + public function remove_multi_from_cart(int $user_id, array $items): array { + return $this->cart_service()->remove_multi_from_cart($user_id, $items); + } + + public function empty_cart(int $user_id): array { + return $this->cart_service()->empty_cart($user_id); + } + + public function validate_cart_item(int $product_id, string $options_string = '') { + return $this->cart_service()->validate_cart_item($product_id, $options_string); + } + + public function get_order_center_view(int $user_id, array $args = []): array { + if ($user_id <= 0 || !class_exists('AKSRT_Order_Repository')) { + return ['items' => [], 'total' => 0, 'total_pages' => 1, 'page' => 1]; + } + + $page = max(1, (int) ($args['page'] ?? 1)); + $per_page = max(1, min(50, (int) ($args['per_page'] ?? 20))); + $status = sanitize_key((string) ($args['status'] ?? '')); + + $repo_args = [ + 'limit' => $per_page, + 'offset' => ($page - 1) * $per_page, + ]; + + if ($status !== '') { + $repo_args['status'] = $status; + } + + $repo = AKSRT_Order_Repository::get_instance(); + $orders = $repo->get_user_orders($user_id, $repo_args); + $total = $repo->count_user_orders($user_id, $repo_args); + + $items = []; + foreach ($orders as $order) { + $items[] = $this->decorate_order_view($order); + } + + return [ + 'items' => $items, + 'total' => $total, + 'total_pages' => max(1, (int) ceil($total / $per_page)), + 'page' => $page, + ]; + } + + public function get_order_detail_view(int $user_id, string $order_number): ?array { + if ($user_id <= 0 || $order_number === '' || !class_exists('AKSRT_Order_Repository')) { + return null; + } + + $order = AKSRT_Order_Repository::get_instance()->get_user_order($user_id, $order_number); + if (!$order) { + return null; + } + + return $this->decorate_order_view($order, true); + } + + public function preview_checkout(int $user_id, array $payload): array { + return $this->checkout_service()->preview_checkout($user_id, $payload); + } + + public function create_checkout_order(int $user_id, array $payload): array { + return $this->checkout_service()->create_checkout_order($user_id, $payload); + } + + public function pay_order(int $user_id, string $order_number, string $payment_method): array { + return $this->checkout_service()->pay_order($user_id, $order_number, $payment_method); + } + + public function get_user_favorites(int $user_id, int $page = 1, int $per_page = 20): array { + if ($user_id <= 0) { + return ['items' => [], 'total' => 0]; + } + $fav_ids = get_user_meta($user_id, 'aksrt_product_favorites', true); + if (!is_array($fav_ids) || empty($fav_ids)) { + return ['items' => [], 'total' => 0]; + } + $total = count($fav_ids); + $paged = array_slice(array_reverse($fav_ids), ($page - 1) * $per_page, $per_page); + $items = []; + foreach ($paged as $pid) { + $card = $this->build_product_card_view((int) $pid); + if (!empty($card)) { + $items[] = $card; + } + } + return ['items' => $items, 'total' => $total]; + } + + public function toggle_favorite(int $user_id, int $product_id): array { + if ($user_id <= 0 || $product_id <= 0) { + return ['success' => false]; + } + $fav_ids = get_user_meta($user_id, 'aksrt_product_favorites', true); + if (!is_array($fav_ids)) { + $fav_ids = []; + } + $key = array_search($product_id, $fav_ids, true); + if ($key !== false) { + unset($fav_ids[$key]); + $fav_ids = array_values($fav_ids); + $is_fav = false; + } else { + $fav_ids[] = $product_id; + $is_fav = true; + } + update_user_meta($user_id, 'aksrt_product_favorites', $fav_ids); + $count = (int) get_post_meta($product_id, '_aksrt_favorite_count', true); + update_post_meta($product_id, '_aksrt_favorite_count', max(0, $count + ($is_fav ? 1 : -1))); + if ($is_fav) { + do_action('aksrt_product_favorited', $user_id, $product_id); + } else { + do_action('aksrt_product_unfavorited', $user_id, $product_id); + } + return ['success' => true, 'is_favorite' => $is_fav, 'count' => count($fav_ids)]; + } + + public function is_favorite(int $user_id, int $product_id): bool { + if ($user_id <= 0) return false; + $fav_ids = get_user_meta($user_id, 'aksrt_product_favorites', true); + return is_array($fav_ids) && in_array($product_id, $fav_ids, true); + } + + public function re_purchase(int $user_id, string $order_number): array { + $items = $this->get_order_items($order_number); + if (empty($items)) { + return ['success' => false, 'message' => __('订单无商品记录', 'aksrt')]; + } + $added = 0; + foreach ($items as $item) { + $pid = (int) $item['product_id']; + $qty = max(1, (int) $item['quantity']); + $opt_key = (string) $item['options_key']; + $result = $this->add_to_cart($user_id, $pid, $opt_key, $qty); + if (!empty($result['success'])) { + $added++; + } + } + return ['success' => $added > 0, 'added' => $added, 'cart_count' => $this->get_cart_count($user_id)]; + } + + public function cancel_order(int $user_id, string $order_number): array { + if ($user_id <= 0 || $order_number === '' || !class_exists('AKSRT_Order_Repository')) { + return ['success' => false, 'message' => __('订单不存在。', 'aksrt')]; + } + + $repo = AKSRT_Order_Repository::get_instance(); + $order = $repo->get_user_order($user_id, $order_number); + if (!$order) { + return ['success' => false, 'message' => __('订单不存在。', 'aksrt')]; + } + + $status = (string) ($order['status'] ?? ''); + if (!in_array($status, [AKSRT_Order::STATUS_PENDING, AKSRT_Order::STATUS_CREATED, AKSRT_Order::STATUS_EXPIRED], true)) { + return ['success' => false, 'message' => __('当前订单不可取消。', 'aksrt')]; + } + + $repo->update_status($order_number, AKSRT_Order::STATUS_CANCELLED, [ + 'force' => true, + 'note' => __('用户在前台取消订单。', 'aksrt'), + ]); + + return [ + 'success' => true, + 'data' => [ + 'order' => $this->get_order_detail_view($user_id, $order_number), + ], + ]; + } + +} diff --git a/inc/classes/class-aksrt-signin.php b/inc/classes/class-aksrt-signin.php new file mode 100644 index 00000000..1a849460 --- /dev/null +++ b/inc/classes/class-aksrt-signin.php @@ -0,0 +1,244 @@ + 'GET', + 'callback' => [$this, 'rest_today'], + 'permission_callback' => function () { + return is_user_logged_in(); + }, + ]); + register_rest_route('aksrt/v1', '/signin', [ + 'methods' => 'POST', + 'callback' => [$this, 'rest_signin'], + 'permission_callback' => function () { + return is_user_logged_in(); + }, + ]); + } + + /** GET /signin/today */ + public function rest_today(WP_REST_Request $request) { + $user_id = get_current_user_id(); + $signin_cfg = class_exists('AKSRT_Settings') + ? AKSRT_Settings::get_signin_reward_settings() + : [ + 'enabled' => false, + 'base' => 0, + 'streak' => [], + ]; + + $today_str = $this->today_date(); + $rec = $this->get_record($user_id, $today_str); + + $current_streak = $this->compute_current_streak($user_id, $today_str); + $next_streak = $current_streak + ($rec ? 0 : 1); + + return new WP_REST_Response([ + 'enabled' => !empty($signin_cfg['enabled']), + 'signed' => (bool) $rec, + 'streak' => $current_streak, + 'reward_today' => $rec ? (int) $rec['reward'] : 0, + 'reward_if_signin' => $rec ? 0 : $this->calc_reward($next_streak, $signin_cfg), + 'date' => $today_str, + ], 200); + } + + /** POST /signin */ + public function rest_signin(WP_REST_Request $request) { + global $wpdb; + + $user_id = get_current_user_id(); + $signin_cfg = class_exists('AKSRT_Settings') + ? AKSRT_Settings::get_signin_reward_settings() + : [ + 'enabled' => false, + 'base' => 0, + 'streak' => [], + ]; + + if (empty($signin_cfg['enabled'])) { + return new WP_Error('aksrt_signin_disabled', __('签到功能未启用', 'aksrt'), ['status' => 403]); + } + + $today_str = $this->today_date(); + if ($this->get_record($user_id, $today_str)) { + return new WP_Error('aksrt_signin_dup', __('今日已签到', 'aksrt'), ['status' => 409]); + } + + // 人机验证(若启用) + if (function_exists('aksrt_is_captcha_enabled_for') && aksrt_is_captcha_enabled_for('signin')) { + $body = $request->get_json_params(); + $cap_payload = isset($body['captcha']) && is_array($body['captcha']) ? $body['captcha'] : []; + if (function_exists('aksrt_verify_captcha')) { + $verify = aksrt_verify_captcha($cap_payload); + if ($verify !== true) { + $msg = is_string($verify) ? $verify : __('人机验证失败', 'aksrt'); + return new WP_Error('aksrt_captcha_failed', $msg, ['status' => 400]); + } + } + } + + $current_streak = $this->compute_current_streak($user_id, $today_str); + $new_streak = $current_streak + 1; + $reward = $this->calc_reward($new_streak, $signin_cfg); + + $table = AKSRT_DB_Migrator::table('signin_log'); + if (!$table) { + return new WP_Error('aksrt_signin_db', __('数据表不存在', 'aksrt'), ['status' => 500]); + } + + $inserted = $wpdb->insert( + $table, + [ + 'user_id' => $user_id, + 'signin_date' => $today_str, + 'streak' => $new_streak, + 'reward' => $reward, + ], + ['%d', '%s', '%d', '%d'] + ); + if ($inserted === false) { + return new WP_Error('aksrt_signin_dup', __('签到失败', 'aksrt'), ['status' => 409]); + } + + if ($reward > 0 && class_exists('AKSRT_Credits')) { + AKSRT_Credits::add($user_id, $reward, 'signin', ['type' => 'user', 'id' => $user_id]); + } + + return new WP_REST_Response([ + 'success' => true, + 'streak' => $new_streak, + 'reward' => $reward, + 'balance' => class_exists('AKSRT_Credits') ? AKSRT_Credits::balance($user_id) : 0, + ], 200); + } + + // ---------- 工具方法 ---------- + + /** 站点时区下的今日日期 'Y-m-d' */ + private function today_date(): string { + return (new DateTimeImmutable('today', wp_timezone()))->format('Y-m-d'); + } + + /** 取签到记录 */ + private function get_record(int $user_id, string $date): ?array { + global $wpdb; + $table = AKSRT_DB_Migrator::table('signin_log'); + if (!$table) return null; + $row = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM {$table} WHERE user_id = %d AND signin_date = %s", + $user_id, + $date + ), ARRAY_A); + return $row ?: null; + } + + /** + * 获取最近 N 天的签到记录 + * + * @param int $user_id + * @param int $days + * @return array + */ + public function get_recent_signins(int $user_id, int $days = 30): array { + global $wpdb; + $table = AKSRT_DB_Migrator::table('signin_log'); + if (!$table) return []; + + $from_date = (new DateTimeImmutable("-{$days} days", wp_timezone()))->format('Y-m-d'); + + $rows = $wpdb->get_results($wpdb->prepare( + "SELECT signin_date FROM {$table} WHERE user_id = %d AND signin_date >= %s ORDER BY signin_date ASC", + $user_id, + $from_date + ), ARRAY_A); + + return $rows ?: []; + } + + /** + * 算"今天之前"的连续签到天数。 + */ + private function compute_current_streak(int $user_id, string $today_str): int { + global $wpdb; + $table = AKSRT_DB_Migrator::table('signin_log'); + if (!$table) return 0; + + // 拿最近 60 天的签到记录降序 + $rows = $wpdb->get_results($wpdb->prepare( + "SELECT signin_date FROM {$table} WHERE user_id = %d AND signin_date <= %s ORDER BY signin_date DESC LIMIT 60", + $user_id, + $today_str + ), ARRAY_A); + if (empty($rows)) return 0; + + $dates = array_map(fn($r) => $r['signin_date'], $rows); + $today = new DateTimeImmutable($today_str); + $tz = wp_timezone(); + + // 起点:若今天已签则从今天回看,否则从昨天回看 + $cursor = in_array($today_str, $dates, true) + ? $today + : $today->sub(new DateInterval('P1D')); + + $count = 0; + foreach ($dates as $d) { + if ($d === $cursor->format('Y-m-d')) { + $count++; + $cursor = $cursor->sub(new DateInterval('P1D')); + } else { + break; + } + } + return $count; + } + + /** + * 根据连续天数计算奖励 = base + streak 段额外奖励。 + */ + private function calc_reward(int $streak, array $cfg): int { + $base = (int) ($cfg['base'] ?? 1); + $extra = 0; + $streak_table = is_array($cfg['streak'] ?? null) ? $cfg['streak'] : []; + + // streak_table 是 { N: extra } —— 当 streak 恰好命中 N 时给 extra + // 对 array_keys 不稳定的存储格式做容错 + $normalized = []; + foreach ($streak_table as $k => $v) { + $normalized[(int) $k] = (int) $v; + } + if (isset($normalized[$streak])) { + $extra = $normalized[$streak]; + } + return max(0, $base + $extra); + } +} diff --git a/inc/classes/class-aksrt-site-notice.php b/inc/classes/class-aksrt-site-notice.php new file mode 100644 index 00000000..6a16b738 --- /dev/null +++ b/inc/classes/class-aksrt-site-notice.php @@ -0,0 +1,151 @@ + 0 && isset($_COOKIE['aksrt_notice_seen'])) { + $seen_ts = absint($_COOKIE['aksrt_notice_seen']); + if ($seen_ts > 0 && (time() - $seen_ts) < ($expires_hours * 3600)) { + return false; + } + } + + return true; + } + + /** + * Render the notice popup in footer + */ + public function render_notice(): void { + if (!$this->is_active()) return; + + $cfg = AKSRT_Settings::get_system_notice_config(); + $content = wp_kses_post($cfg['content']); + $title = esc_html($cfg['title'] ?: __('站点公告', 'aksrt')); + $extra_cls = $cfg['class'] ? ' ' . esc_attr($cfg['class']) : ''; + $radius = absint($cfg['radius']); + $size_cls = $cfg['size']; + + // 标题图标 / 样式 + $title_icon = $cfg['title_icon']; + $title_cls = $cfg['title_class'] ? ' ' . esc_attr($cfg['title_class']) : ''; + $title_style = $cfg['title_style'] ? ' style="' . esc_attr($cfg['title_style']) . '"' : ''; + + // 弹窗尺寸 class + $dialog_cls = 'aksrt-site-notice__dialog'; + if ($size_cls !== '') { + $dialog_cls .= ' ' . esc_attr($size_cls); + } + + // 内联样式:圆角 + $dialog_style = 'display:none;'; + if ($radius > 0) { + $dialog_style .= 'border-radius:' . $radius . 'px;'; + } + + $buttons = $cfg['buttons']; + ?> +
+ $phone, + 'SignName' => $sign_name, + 'TemplateCode' => $template_code, + 'TemplateParam' => wp_json_encode(['code' => $code]), + ]; + + $result = $this->request($access_key, $access_secret, $region_id, $params); + + if (is_wp_error($result)) { + $this->log('error', $phone, $result->get_error_message()); + return ['success' => false, 'message' => $result->get_error_message(), 'transport' => 'signed_post']; + } + + // 对齐 zibll 成功判断:检查 BizId 或 Code === 'OK' + if (!empty($result['BizId']) || (isset($result['Code']) && $result['Code'] === 'OK')) { + $this->log('sent', $phone, 'OK'); + return ['success' => true, 'message' => __('验证码已发送', 'aksrt'), 'transport' => 'signed_post']; + } + + $err_code = $result['Code'] ?? 'Unknown'; + $err_msg = $result['Message'] ?? __('未知错误', 'aksrt'); + $this->log('error', $phone, "{$err_code}: {$err_msg}"); + return ['success' => false, 'message' => sprintf('阿里云: %s (%s)', $err_msg, $err_code), 'transport' => 'signed_post']; + } + + public function get_status(array $config): array { + $missing = $this->validate_config($config); + return [ + 'provider' => $this->get_key(), + 'configured' => empty($missing), + 'available' => !empty($config['sms_enabled']) && empty($missing), + 'verified' => !empty($config['sms_aliyun_verified']), + 'transport' => 'signed_post', + 'missing_fields' => $missing, + ]; + } + + /** + * 发送阿里云短信 API 请求(POST 方式) + * + * 对齐 zibll 的 SignatureHelper 实现: + * - POST 到 https://dysmsapi.aliyuncs.com/ + * - body 为 Signature=xxx&Key=Value&... 格式 + * - 使用 x-sdk-client header + * + * @return array|WP_Error + */ + private function request(string $access_key, string $access_secret, string $region_id, array $biz_params) { + if (!class_exists('AKSRT_SMS_Signature_Helper')) { + return new WP_Error('sms_helper_missing', '签名助手类未加载'); + } + + $helper = new AKSRT_SMS_Signature_Helper(); + + return $helper->request( + $access_key, + $access_secret, + 'dysmsapi.aliyuncs.com', + array_merge($biz_params, [ + 'RegionId' => $region_id, + 'Action' => 'SendSms', + 'Version' => '2017-05-25', + ]), + true // HTTPS + ); + } + + private function log(string $level, string $phone, string $message): void { + if (!defined('WP_DEBUG') || !WP_DEBUG) return; + $masked = substr($phone, 0, 3) . '****' . substr($phone, -4); + error_log(sprintf('[AKSRT SMS] [aliyun] [%s] Phone: %s — %s', $level, $masked, $message)); + } +} diff --git a/inc/classes/class-aksrt-sms-provider-custom.php b/inc/classes/class-aksrt-sms-provider-custom.php new file mode 100644 index 00000000..d44baaca --- /dev/null +++ b/inc/classes/class-aksrt-sms-provider-custom.php @@ -0,0 +1,169 @@ + false, 'message' => __('自定义接口 URL 未配置', 'aksrt'), 'transport' => 'custom_http']; + } + + // 替换模板变量 + $replacements = [ + '{phone}' => $phone, + '{code}' => $code, + '{time}' => (string) time(), + ]; + + $body = str_replace(array_keys($replacements), array_values($replacements), $body_tpl); + + // 解析自定义 headers + $headers = ['Accept' => 'application/json']; + if ($headers_raw !== '') { + foreach (explode("\n", $headers_raw) as $line) { + $line = trim($line); + if ($line === '') continue; + $parts = explode(':', $line, 2); + if (count($parts) === 2) { + $headers[trim($parts[0])] = trim(str_replace(array_keys($replacements), array_values($replacements), $parts[1])); + } + } + } + + // 鉴权 + if ($auth_key !== '' && $auth_value !== '') { + $replaced_value = str_replace(array_keys($replacements), array_values($replacements), $auth_value); + if ($auth_type === 'header') { + $headers[$auth_key] = $replaced_value; + } + } + + $args = [ + 'timeout' => 10, + 'headers' => $headers, + ]; + + if (in_array($method, ['POST', 'PUT', 'PATCH'], true)) { + $args['body'] = $body; + $result = wp_remote_request($url, array_merge($args, ['method' => $method])); + } else { + // GET — append body as query params + $url_with_params = add_query_arg(json_decode($body, true) ?: [], $url); + $result = wp_remote_get($url_with_params, $args); + } + + if (is_wp_error($result)) { + $this->log('error', $phone, $result->get_error_message()); + return ['success' => false, 'message' => $result->get_error_message(), 'transport' => 'custom_http']; + } + + $status_code = wp_remote_retrieve_response_code($result); + $response_body = wp_remote_retrieve_body($result); + + // 判定成功规则 + if ($success_rule !== '') { + $matched = $this->check_success_rule($success_rule, $status_code, $response_body); + if ($matched) { + $this->log('sent', $phone, "status={$status_code}"); + return ['success' => true, 'message' => __('验证码已发送', 'aksrt'), 'transport' => 'custom_http']; + } + $this->log('error', $phone, "status={$status_code}, rule not matched"); + return ['success' => false, 'message' => sprintf(__('自定义接口返回异常 (HTTP %d)', 'aksrt'), $status_code), 'transport' => 'custom_http']; + } + + // 默认:HTTP 2xx 视为成功 + if ($status_code >= 200 && $status_code < 300) { + $this->log('sent', $phone, "status={$status_code}"); + return ['success' => true, 'message' => __('验证码已发送', 'aksrt'), 'transport' => 'custom_http']; + } + + $this->log('error', $phone, "status={$status_code}"); + return ['success' => false, 'message' => sprintf(__('自定义接口返回 HTTP %d', 'aksrt'), $status_code), 'transport' => 'custom_http']; + } + + public function get_status(array $config): array { + $missing = $this->validate_config($config); + return [ + 'provider' => $this->get_key(), + 'configured' => empty($missing), + 'available' => !empty($config['sms_enabled']) && empty($missing), + 'verified' => !empty($config['sms_custom_verified']), + 'transport' => 'custom_http', + 'missing_fields' => $missing, + ]; + } + + /** + * 校验成功规则 + * + * 支持格式: + * - "status:200" — HTTP 状态码匹配 + * - "json:code=0" — JSON 响应中字段匹配 + * - "contains:success" — 响应体包含字符串 + */ + private function check_success_rule(string $rule, int $status_code, string $body): bool { + $parts = explode(':', $rule, 2); + if (count($parts) < 2) return false; + + $type = trim($parts[0]); + $value = trim($parts[1]); + + switch ($type) { + case 'status': + return (string) $status_code === $value; + + case 'json': + $json = json_decode($body, true); + if (!is_array($json)) return false; + $kv = explode('=', $value, 2); + if (count($kv) !== 2) return false; + $key = trim($kv[0]); + $expected = trim($kv[1]); + return (string) ($json[$key] ?? '') === $expected; + + case 'contains': + return strpos($body, $value) !== false; + + default: + return false; + } + } + + private function log(string $level, string $phone, string $message): void { + if (!defined('WP_DEBUG') || !WP_DEBUG) return; + $masked = substr($phone, 0, 3) . '****' . substr($phone, -4); + error_log(sprintf('[AKSRT SMS] [custom] [%s] Phone: %s — %s', $level, $masked, $message)); + } +} diff --git a/inc/classes/class-aksrt-sms-provider-factory.php b/inc/classes/class-aksrt-sms-provider-factory.php new file mode 100644 index 00000000..35568060 --- /dev/null +++ b/inc/classes/class-aksrt-sms-provider-factory.php @@ -0,0 +1,69 @@ +request($app_key, $app_secret, $sender, $template_id, $signature, $endpoint, $phone, $code); + + if (is_wp_error($result)) { + $this->log('error', $phone, $result->get_error_message()); + return ['success' => false, 'message' => $result->get_error_message(), 'transport' => 'hw_http']; + } + + $body = json_decode(wp_remote_retrieve_body($result), true); + $code_field = $body['code'] ?? ''; + + if ($code_field === '000000') { + $this->log('sent', $phone, '000000'); + return ['success' => true, 'message' => __('验证码已发送', 'aksrt'), 'transport' => 'hw_http']; + } + + $err_msg = $body['description'] ?? $body['code'] ?? __('未知错误', 'aksrt'); + $this->log('error', $phone, "{$code_field}: {$err_msg}"); + return ['success' => false, 'message' => sprintf('华为云: %s', $err_msg), 'transport' => 'hw_http']; + } + + public function get_status(array $config): array { + $missing = $this->validate_config($config); + return [ + 'provider' => $this->get_key(), + 'configured' => empty($missing), + 'available' => !empty($config['sms_enabled']) && empty($missing), + 'verified' => !empty($config['sms_huawei_verified']), + 'transport' => 'hw_http', + 'missing_fields' => $missing, + ]; + } + + /** + * 华为云 AK/SK 签名请求 + */ + private function request(string $app_key, string $app_secret, string $sender, string $template_id, string $signature, string $endpoint, string $phone, string $code) { + $endpoint = rtrim($endpoint, '/'); + $url = "{$endpoint}/sms/batchSendSms"; + + $body = wp_json_encode([ + 'from' => $sender, + 'to' => [$phone], + 'templateId' => $template_id, + 'templateParas' => [$code], + 'signature' => $signature, + ]); + + $timestamp = gmdate('Y-m-d\TH:i:s\Z'); + $nonce = wp_generate_uuid4(); + + // 简化签名:使用 X-Sms-AppKey 鉴权方式 + return wp_remote_post($url, [ + 'timeout' => 10, + 'headers' => [ + 'Content-Type' => 'application/json', + 'X-Sms-AppKey' => $app_key, + 'X-Sms-AppSecret' => $app_secret, + ], + 'body' => $body, + ]); + } + + private function log(string $level, string $phone, string $message): void { + if (!defined('WP_DEBUG') || !WP_DEBUG) return; + $masked = substr($phone, 0, 3) . '****' . substr($phone, -4); + error_log(sprintf('[AKSRT SMS] [huawei] [%s] Phone: %s — %s', $level, $masked, $message)); + } +} diff --git a/inc/classes/class-aksrt-sms-provider-tencent.php b/inc/classes/class-aksrt-sms-provider-tencent.php new file mode 100644 index 00000000..1fdc2d88 --- /dev/null +++ b/inc/classes/class-aksrt-sms-provider-tencent.php @@ -0,0 +1,141 @@ +request($secret_id, $secret_key, $app_id, $sign_name, $template_id, $region, $phone, $code); + + if (is_wp_error($result)) { + $this->log('error', $phone, $result->get_error_message()); + return ['success' => false, 'message' => $result->get_error_message(), 'transport' => 'tc3_http']; + } + + $body = json_decode(wp_remote_retrieve_body($result), true); + $response = $body['Response'] ?? []; + + if (isset($response['SendStatusSet'][0]['Code']) && $response['SendStatusSet'][0]['Code'] === 'Ok') { + $this->log('sent', $phone, 'Ok'); + return ['success' => true, 'message' => __('验证码已发送', 'aksrt'), 'transport' => 'tc3_http']; + } + + $err_code = $response['Error']['Code'] ?? ($response['SendStatusSet'][0]['Code'] ?? 'Unknown'); + $err_msg = $response['Error']['Message'] ?? ($response['SendStatusSet'][0]['Message'] ?? __('未知错误', 'aksrt')); + $this->log('error', $phone, "{$err_code}: {$err_msg}"); + return ['success' => false, 'message' => sprintf('腾讯云: %s', $err_msg), 'transport' => 'tc3_http']; + } + + public function get_status(array $config): array { + $missing = $this->validate_config($config); + return [ + 'provider' => $this->get_key(), + 'configured' => empty($missing), + 'available' => !empty($config['sms_enabled']) && empty($missing), + 'verified' => !empty($config['sms_tencent_verified']), + 'transport' => 'tc3_http', + 'missing_fields' => $missing, + ]; + } + + /** + * TC3-HMAC-SHA256 签名请求 + */ + private function request(string $secret_id, string $secret_key, string $app_id, string $sign_name, string $template_id, string $region, string $phone, string $code) { + $service = 'sms'; + $host = "{$service}.tencentcloudapi.com"; + $action = 'SendSms'; + $version = '2021-01-11'; + $endpoint = "https://{$host}"; + + $payload = wp_json_encode([ + 'PhoneNumberSet' => [$phone], + 'SmsSdkAppId' => $app_id, + 'SignName' => $sign_name, + 'TemplateId' => $template_id, + 'TemplateParamSet' => [$code], + ]); + + $timestamp = time(); + $date = gmdate('Y-m-d', $timestamp); + + // Step 1: CanonicalRequest + $http_request_method = 'POST'; + $canonical_uri = '/'; + $canonical_querystring = ''; + $content_type = 'application/json; charset=utf-8'; + $canonical_headers = "content-type:{$content_type}\nhost:{$host}\nx-tc-action:" . strtolower($action) . "\n"; + $signed_headers = 'content-type;host;x-tc-action'; + $hashed_payload = hash('sha256', $payload); + $canonical_request = "{$http_request_method}\n{$canonical_uri}\n{$canonical_querystring}\n{$canonical_headers}\n{$signed_headers}\n{$hashed_payload}"; + + // Step 2: StringToSign + $algorithm = 'TC3-HMAC-SHA256'; + $credential_scope = "{$date}/{$service}/tc3_request"; + $hashed_request = hash('sha256', $canonical_request); + $string_to_sign = "{$algorithm}\n{$timestamp}\n{$credential_scope}\n{$hashed_request}"; + + // Step 3: Signature + $secret_date = hash_hmac('sha256', $date, "TC3{$secret_key}", true); + $secret_service = hash_hmac('sha256', $service, $secret_date, true); + $secret_signing = hash_hmac('sha256', 'tc3_request', $secret_service, true); + $signature = hash_hmac('sha256', $string_to_sign, $secret_signing); + + // Step 4: Authorization header + $authorization = "{$algorithm} Credential={$secret_id}/{$credential_scope}, SignedHeaders={$signed_headers}, Signature={$signature}"; + + return wp_remote_post($endpoint, [ + 'timeout' => 10, + 'headers' => [ + 'Authorization' => $authorization, + 'Content-Type' => $content_type, + 'Host' => $host, + 'X-TC-Action' => $action, + 'X-TC-Version' => $version, + 'X-TC-Timestamp' => (string) $timestamp, + 'X-TC-Region' => $region, + ], + 'body' => $payload, + ]); + } + + private function log(string $level, string $phone, string $message): void { + if (!defined('WP_DEBUG') || !WP_DEBUG) return; + $masked = substr($phone, 0, 3) . '****' . substr($phone, -4); + error_log(sprintf('[AKSRT SMS] [tencent] [%s] Phone: %s — %s', $level, $masked, $message)); + } +} diff --git a/inc/classes/class-aksrt-sms-provider.php b/inc/classes/class-aksrt-sms-provider.php new file mode 100644 index 00000000..68009e11 --- /dev/null +++ b/inc/classes/class-aksrt-sms-provider.php @@ -0,0 +1,49 @@ + 'HMAC-SHA1', + 'SignatureNonce' => uniqid((string) mt_rand(0, 0xffff), true), + 'SignatureVersion' => '1.0', + 'AccessKeyId' => $accessKeyId, + 'Timestamp' => gmdate('Y-m-d\TH:i:s\Z'), + 'Format' => 'JSON', + ], $params); + + ksort($apiParams); + + $sortedQueryString = ''; + foreach ($apiParams as $key => $value) { + $sortedQueryString .= '&' . $this->encode($key) . '=' . $this->encode((string) $value); + } + + $stringToSign = 'POST&%2F&' . $this->encode(substr($sortedQueryString, 1)); + $sign = base64_encode(hash_hmac('sha1', $stringToSign, $accessKeySecret . '&', true)); + $signature = $this->encode($sign); + + $url = ($https ? 'https' : 'http') . "://{$domain}/"; + $body = "Signature={$signature}{$sortedQueryString}"; + + return $this->fetchContent($url, $body); + } + + /** + * URL 编码(对齐阿里云规范) + */ + private function encode(string $str): string { + $res = rawurlencode($str); + $res = str_replace('+', '%20', $res); + $res = str_replace('*', '%2A', $res); + $res = str_replace('%7E', '~', $res); + return $res; + } + + /** + * 发送 HTTP POST 请求 + * + * @return array|WP_Error + */ + private function fetchContent(string $url, string $body) { + $response = wp_remote_post($url, [ + 'timeout' => 10, + 'headers' => [ + 'Content-Type' => 'application/x-www-form-urlencoded', + 'x-sdk-client' => 'php/2.0.0', + ], + 'body' => $body, + ]); + + if (is_wp_error($response)) { + return $response; + } + + $content = wp_remote_retrieve_body($response); + $decoded = json_decode($content, true); + + return is_array($decoded) ? $decoded : ['raw' => $content]; + } +} diff --git a/inc/classes/class-aksrt-sms.php b/inc/classes/class-aksrt-sms.php new file mode 100644 index 00000000..3ecc1705 --- /dev/null +++ b/inc/classes/class-aksrt-sms.php @@ -0,0 +1,411 @@ + false, 'sms_provider' => '']; + } + + /** + * 获取当前 provider adapter + */ + public function get_provider(): ?AKSRT_SMS_Provider { + $config = $this->get_provider_config(); + $key = sanitize_key((string) ($config['sms_provider'] ?? '')); + return AKSRT_SMS_Provider_Factory::get($key); + } + + /** + * 发送短信验证码 + * + * @param string $phone 手机号 + * @return array{success: bool, message: string, remaining_time?: int} + */ + public function send_code(string $phone): array { + $config = $this->get_provider_config(); + + if (empty($config['sms_enabled'])) { + return ['success' => false, 'message' => __('短信服务未启用', 'aksrt')]; + } + + $provider_key = sanitize_key((string) ($config['sms_provider'] ?? '')); + if ($provider_key === '') { + return ['success' => false, 'message' => __('未配置短信供应商', 'aksrt')]; + } + + $provider = AKSRT_SMS_Provider_Factory::get($provider_key); + if ($provider === null) { + return ['success' => false, 'message' => sprintf(__('短信供应商 "%s" 不可用', 'aksrt'), $provider_key)]; + } + + // 校验配置完整性 + $missing = $provider->validate_config($config); + if (!empty($missing)) { + return ['success' => false, 'message' => sprintf(__('短信配置不完整,缺少: %s', 'aksrt'), implode(', ', $missing))]; + } + + // 频率限制:间隔限制(对齐 zibll 60 秒冷却 + 返回剩余时间) + $rate_limit = max(10, (int) ($config['sms_rate_limit'] ?? 60)); + $rate_key = 'aksrt_sms_rate_' . md5($phone); + $rate_set = get_transient($rate_key); + if ($rate_set !== false) { + // 计算剩余等待时间 + $rate_ts_key = 'aksrt_sms_rate_ts_' . md5($phone); + $sent_time = (int) get_transient($rate_ts_key); + $elapsed = $sent_time > 0 ? time() - $sent_time : $rate_limit; + $remaining = max(1, $rate_limit - $elapsed); + return [ + 'success' => false, + 'message' => sprintf(__('请 %d 秒后再试', 'aksrt'), $remaining), + 'remaining_time' => $remaining, + ]; + } + + // 每日限制 + $daily_limit = max(1, (int) ($config['sms_daily_limit'] ?? 10)); + $daily_key = 'aksrt_sms_daily_' . md5($phone) . '_' . date('Ymd'); + $daily_count = (int) get_transient($daily_key); + if ($daily_count >= $daily_limit) { + return ['success' => false, 'message' => __('今日发送次数已达上限', 'aksrt')]; + } + + $code_length = max(4, min(8, (int) ($config['sms_code_length'] ?? 6))); + $code = $this->generate_code($code_length); + $ttl = max(60, min(600, (int) ($config['sms_code_expire'] ?? 300))); + + // 存储验证码(绑定手机号) + set_transient('aksrt_sms_code_' . md5($phone), $code, $ttl); + set_transient('aksrt_sms_phone_' . md5($phone), $phone, $ttl); + set_transient($rate_key, '1', $rate_limit); + set_transient('aksrt_sms_rate_ts_' . md5($phone), time(), $rate_limit); + set_transient($daily_key, $daily_count + 1, DAY_IN_SECONDS); + + // 委托 provider 发送 + $result = $provider->send_code($phone, $code, $config); + + if (!empty($result['success'])) { + return [ + 'success' => true, + 'message' => $result['message'] ?? __('验证码已发送', 'aksrt'), + 'remaining_time' => $rate_limit, + ]; + } + + // 发送失败:删除已存储的验证码,避免无效等待 + delete_transient('aksrt_sms_code_' . md5($phone)); + delete_transient('aksrt_sms_phone_' . md5($phone)); + return ['success' => false, 'message' => $result['message'] ?? __('发送失败,请稍后重试', 'aksrt')]; + } + + /** + * 验证短信验证码(对齐 zibll:同时校验验证码和目标手机号) + * + * @param string $phone 手机号 + * @param string $code 验证码 + * @return array{success: bool, message: string} + */ + public function verify_code(string $phone, string $code): array { + // 错误频率限制检查 + $error_check = $this->check_error_frequency('sms_verify_' . md5($phone)); + if (!$error_check['allowed']) { + return [ + 'success' => false, + 'message' => sprintf(__('验证失败次数过多,请 %d 秒后再试', 'aksrt'), $error_check['remaining']), + 'remaining_time' => $error_check['remaining'], + ]; + } + + $code_key = 'aksrt_sms_code_' . md5($phone); + $phone_key = 'aksrt_sms_phone_' . md5($phone); + + $stored_code = get_transient($code_key); + $stored_phone = get_transient($phone_key); + + // 验证码不存在或已过期 + if ($stored_code === false) { + $this->record_error('sms_verify_' . md5($phone)); + return ['success' => false, 'message' => __('验证码已过期或不存在', 'aksrt')]; + } + + // 验证码不匹配 + if (!hash_equals((string) $stored_code, $code)) { + $this->record_error('sms_verify_' . md5($phone)); + return ['success' => false, 'message' => __('验证码不正确', 'aksrt')]; + } + + // 目标手机号绑定校验 + if ($stored_phone !== false && (string) $stored_phone !== $phone) { + $this->record_error('sms_verify_' . md5($phone)); + return ['success' => false, 'message' => __('手机号不匹配', 'aksrt')]; + } + + // 验证成功:清除验证码和错误计数 + delete_transient($code_key); + delete_transient($phone_key); + $this->reset_error_frequency('sms_verify_' . md5($phone)); + + return ['success' => true, 'message' => __('验证成功', 'aksrt')]; + } + + /** + * 兼容旧接口:verify_code 返回 bool + */ + public function verify_code_bool(string $phone, string $code): bool { + $result = $this->verify_code($phone, $code); + return !empty($result['success']); + } + + /** + * 生成验证码 + */ + private function generate_code(int $length = 6): string { + $max = (int) str_pad('9', $length, '9'); + return str_pad((string) random_int(0, $max), $length, '0', STR_PAD_LEFT); + } + + /* ------------------------------------------------------------------ + * 错误频率限制(对齐 zibll 的 error_frequency_limit) + * ----------------------------------------------------------------*/ + + /** + * 检查是否被频率限制 + * + * @return array{allowed: bool, remaining: int} + */ + private function check_error_frequency(string $key): array { + $count_key = 'aksrt_sms_err_' . $key; + $time_key = 'aksrt_sms_err_ts_' . $key; + + $count = (int) get_transient($count_key); + $last = (int) get_transient($time_key); + + if ($count <= 0) { + return ['allowed' => true, 'remaining' => 0]; + } + + // 超过 10 次错误:3 分钟冷却 + if ($count >= 10) { + $cooldown = 180; + $elapsed = time() - $last; + if ($elapsed < $cooldown) { + return ['allowed' => false, 'remaining' => $cooldown - $elapsed]; + } + // 冷却结束,重置 + $this->reset_error_frequency($key); + return ['allowed' => true, 'remaining' => 0]; + } + + // 超过 5 次错误:30 秒冷却 + if ($count >= 5) { + $cooldown = 30; + $elapsed = time() - $last; + if ($elapsed < $cooldown) { + return ['allowed' => false, 'remaining' => $cooldown - $elapsed]; + } + } + + return ['allowed' => true, 'remaining' => 0]; + } + + /** + * 记录一次错误 + */ + private function record_error(string $key): void { + $count_key = 'aksrt_sms_err_' . $key; + $time_key = 'aksrt_sms_err_ts_' . $key; + + $count = (int) get_transient($count_key); + set_transient($count_key, $count + 1, HOUR_IN_SECONDS); + set_transient($time_key, time(), HOUR_IN_SECONDS); + } + + /** + * 重置错误计数 + */ + private function reset_error_frequency(string $key): void { + delete_transient('aksrt_sms_err_' . $key); + delete_transient('aksrt_sms_err_ts_' . $key); + } + + /* ------------------------------------------------------------------ + * AJAX Handlers + * ----------------------------------------------------------------*/ + + /** + * 手机号格式校验(对齐 zibll:支持中国号和国际号) + */ + private function validate_phone(string $phone): bool { + $config = $this->get_provider_config(); + $limit_ch = !isset($config['sms_number_limit_ch']) || !empty($config['sms_number_limit_ch']); + + if ($limit_ch) { + // 仅中国大陆手机号 + return (bool) preg_match('/^1[3-9]\d{9}$/', $phone); + } + + // 国际手机号:5 位以上数字,可选 + 前缀 + return (bool) preg_match('/^(?:\d{5,}|\+\d{6,})$/', $phone); + } + + /** + * AJAX 发送验证码 + */ + public function ajax_send_code(): void { + check_ajax_referer('aksrt_sms', '_nonce'); + + $phone = sanitize_text_field((string) ($_POST['phone'] ?? '')); + if (!$this->validate_phone($phone)) { + wp_send_json_error(['message' => __('手机号格式不正确', 'aksrt')]); + } + + // 人机验证钩子(对齐 zibll 的 man_machine_verification) + $captcha_result = apply_filters('aksrt_sms_captcha_verify', true, $phone); + if ($captcha_result !== true) { + wp_send_json_error(['message' => $captcha_result ?: __('人机验证失败', 'aksrt')]); + } + + $result = $this->send_code($phone); + + if ($result['success']) { + wp_send_json_success([ + 'message' => $result['message'], + 'remaining_time' => $result['remaining_time'] ?? 60, + ]); + } + + $error_data = ['message' => $result['message']]; + if (isset($result['remaining_time'])) { + $error_data['remaining_time'] = $result['remaining_time']; + } + wp_send_json_error($error_data); + } + + /** + * AJAX: Test SMS provider (admin only). + */ + public function ajax_test_provider(): void { + if (!current_user_can('manage_options')) { + wp_send_json_error(['message' => __('权限不足', 'aksrt')]); + } + + $phone = sanitize_text_field((string) ($_POST['phone'] ?? '')); + if (!preg_match('/^1[3-9]\d{9}$/', $phone)) { + wp_send_json_error(['message' => __('手机号格式不正确', 'aksrt')]); + } + + $result = $this->test_provider($phone); + wp_send_json($result); + } + + /** + * AJAX: Get SMS provider status (admin only). + */ + public function ajax_provider_status(): void { + if (!current_user_can('manage_options')) { + wp_send_json_error(['message' => __('权限不足', 'aksrt')]); + } + + wp_send_json_success($this->get_provider_status()); + } + + /** + * 获取 provider 真实运行时状态 + */ + public function get_provider_status(): array { + $config = $this->get_provider_config(); + $provider_key = sanitize_key((string) ($config['sms_provider'] ?? '')); + + $status = [ + 'enabled' => !empty($config['sms_enabled']), + 'provider' => $provider_key ?: 'none', + 'configured' => false, + 'available' => false, + 'verified' => false, + 'transport' => '', + 'missing_fields' => [], + 'code_length' => max(4, min(8, (int) ($config['sms_code_length'] ?? 6))), + 'code_expire' => max(60, min(600, (int) ($config['sms_code_expire'] ?? 300))), + 'rate_limit' => max(10, (int) ($config['sms_rate_limit'] ?? 60)), + 'daily_limit' => max(1, (int) ($config['sms_daily_limit'] ?? 10)), + 'notify_bind' => !empty($config['sms_notify_bind']), + 'notify_login' => !empty($config['sms_notify_login']), + ]; + + $provider = AKSRT_SMS_Provider_Factory::get($provider_key); + if ($provider !== null) { + $provider_status = $provider->get_status($config); + $status['configured'] = !empty($provider_status['configured']); + $status['available'] = !empty($provider_status['available']); + $status['verified'] = !empty($provider_status['verified']); + $status['transport'] = (string) ($provider_status['transport'] ?? ''); + $status['missing_fields'] = (array) ($provider_status['missing_fields'] ?? []); + } + + return $status; + } + + /** + * Test provider connectivity (admin only). + */ + public function test_provider(string $phone): array { + $config = $this->get_provider_config(); + if (empty($config['sms_enabled'])) { + return ['success' => false, 'message' => __('短信服务未启用', 'aksrt')]; + } + + $provider_key = sanitize_key((string) ($config['sms_provider'] ?? '')); + if ($provider_key === '') { + return ['success' => false, 'message' => __('未配置短信供应商', 'aksrt')]; + } + + $provider = AKSRT_SMS_Provider_Factory::get($provider_key); + if ($provider === null) { + return ['success' => false, 'message' => sprintf(__('短信供应商 "%s" 不可用', 'aksrt'), $provider_key)]; + } + + $missing = $provider->validate_config($config); + if (!empty($missing)) { + return ['success' => false, 'message' => sprintf(__('缺少配置项: %s', 'aksrt'), implode(', ', $missing))]; + } + + return $this->send_code($phone); + } +} diff --git a/inc/classes/class-aksrt-stats-tracker.php b/inc/classes/class-aksrt-stats-tracker.php new file mode 100644 index 00000000..5501fbb0 --- /dev/null +++ b/inc/classes/class-aksrt-stats-tracker.php @@ -0,0 +1,146 @@ + 0, 'pageviews' => 0]); + + if (!is_array($stats)) { + $stats = ['visitors' => 0, 'pageviews' => 0]; + } + + // 更新统计 + $stats['pageviews']++; + if ($is_unique) { + $stats['visitors']++; + setcookie($visitor_cookie, '1', time() + DAY_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN); + } + + update_option($option_key, $stats, false); + + // 每月1日清理旧数据 + if ((int) date('j') === 1 && (int) date('H') === 0 && (int) date('i') < 5) { + $this->cleanup_old_stats(); + } + } + + /** + * 清理30天前的统计数据 + */ + private function cleanup_old_stats(): void { + global $wpdb; + + $threshold = date('Y-m-d', strtotime('-30 days')); + $wpdb->query($wpdb->prepare( + "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s AND option_name < %s", + $wpdb->esc_like('aksrt_daily_stats_') . '%', + 'aksrt_daily_stats_' . $threshold + )); + } + + /** + * 获取今日统计 + */ + public static function get_today_stats(): array { + $today = current_time('Y-m-d'); + $stats = get_option('aksrt_daily_stats_' . $today, ['visitors' => 0, 'pageviews' => 0]); + + if (!is_array($stats)) { + return ['visitors' => 0, 'pageviews' => 0]; + } + + return [ + 'visitors' => absint($stats['visitors'] ?? 0), + 'pageviews' => absint($stats['pageviews'] ?? 0), + ]; + } + + /** + * 获取指定日期统计 + */ + public static function get_daily_stats(string $date): array { + $stats = get_option('aksrt_daily_stats_' . $date, ['visitors' => 0, 'pageviews' => 0]); + + if (!is_array($stats)) { + return ['visitors' => 0, 'pageviews' => 0]; + } + + return [ + 'visitors' => absint($stats['visitors'] ?? 0), + 'pageviews' => absint($stats['pageviews'] ?? 0), + ]; + } + + /** + * 获取最近N天统计 + */ + public static function get_period_stats(int $days = 30): array { + $total = ['visitors' => 0, 'pageviews' => 0]; + for ($i = 0; $i < $days; $i++) { + $date = date('Y-m-d', strtotime("-{$i} days")); + $stats = self::get_daily_stats($date); + $total['visitors'] += $stats['visitors']; + $total['pageviews'] += $stats['pageviews']; + } + return $total; + } + + /** + * 获取总浏览量(所有文章) + */ + public static function get_total_views(): int { + global $wpdb; + $total = $wpdb->get_var( + "SELECT SUM(CAST(meta_value AS UNSIGNED)) FROM {$wpdb->postmeta} WHERE meta_key = 'aksrt_post_views'" + ); + return max(0, (int) $total); + } +} diff --git a/inc/classes/class-aksrt-templates.php b/inc/classes/class-aksrt-templates.php new file mode 100644 index 00000000..685bee29 --- /dev/null +++ b/inc/classes/class-aksrt-templates.php @@ -0,0 +1,145 @@ + __('AKSRT - 登录/注册', 'aksrt'), + 'templates/pages/page-profile.php' => __('AKSRT - 个人中心', 'aksrt'), + 'templates/pages/page-user-home.php' => __('AKSRT - 用户主页', 'aksrt'), + 'templates/pages/page-archives.php' => __('AKSRT - 文章归档', 'aksrt'), + 'templates/pages/page-categories.php' => __('AKSRT - 分类汇总', 'aksrt'), + 'templates/pages/page-newposts.php' => __('AKSRT - 最新文章', 'aksrt'), + 'templates/pages/page-posts-navs.php' => __('AKSRT - 文章导航', 'aksrt'), + 'templates/pages/page-special.php' => __('AKSRT - 专题聚合', 'aksrt'), + 'templates/pages/page-documentnav.php' => __('AKSRT - 文档导航', 'aksrt'), + 'templates/pages/page-friends.php' => __('AKSRT - 友情链接', 'aksrt'), + 'templates/pages/page-messages.php' => __('AKSRT - 消息中心', 'aksrt'), + 'templates/pages/page-download.php' => __('AKSRT - 下载中心', 'aksrt'), + 'templates/pages/page-submit.php' => __('AKSRT - 前台投稿', 'aksrt'), + 'templates/pages/page-orders.php' => __('AKSRT - 订单中心', 'aksrt'), + 'templates/pages/page-revenue.php' => __('AKSRT - 收益中心', 'aksrt'), + 'templates/pages/page-cart.php' => __('AKSRT - 购物车', 'aksrt'), + 'templates/pages/page-product.php' => __('AKSRT - 商品详情', 'aksrt'), + 'templates/pages/page-product-edit.php' => __('AKSRT - 商品编辑', 'aksrt'), + 'templates/pages/page-forums.php' => __('AKSRT - 论坛', 'aksrt'), + 'templates/forum/forum-compose.php' => __('AKSRT - 发帖', 'aksrt'), + ]; + } + + /** + * Register selectable page templates in wp-admin. + */ + public function register_page_templates(array $templates): array { + foreach ($this->page_template_map() as $template_file => $label) { + $templates[$template_file] = $label; + } + + return $templates; + } + + /** + * Resolve theme page templates through the shared page-template router. + */ + public function load_page_template(string $template): string { + global $post; + + if (!$post) { + return $template; + } + + $page_template_meta = get_post_meta($post->ID, '_wp_page_template', true); + $resolved_template = function_exists('aksrt_resolve_page_template_file') + ? aksrt_resolve_page_template_file( + is_string($page_template_meta) ? $page_template_meta : '', + (string) $post->post_name + ) + : ''; + + if ($resolved_template !== '' && function_exists('aksrt_locate_template_file')) { + $resolved_path = aksrt_locate_template_file($resolved_template); + if ($resolved_path) { + return $resolved_path; + } + } + + return $template; + } + + public function template_hierarchy(array $templates): array { + return $templates; + } + + /** + * Load dedicated product taxonomy templates. + */ + public function load_taxonomy_template(string $template): string { + if (is_tax('aksrt_product_cat')) { + $cat_template = AKSRT_DIR . '/templates/shop/product-cat.php'; + if (file_exists($cat_template)) { + return $cat_template; + } + } + + if (is_tax('aksrt_product_tag')) { + $tag_template = AKSRT_DIR . '/templates/shop/product-tag.php'; + if (file_exists($tag_template)) { + return $tag_template; + } + } + + return $template; + } + + public function render_archive_shortcode(array $attrs = [], string $content = ''): string { + $template = AKSRT_DIR . '/templates/shortcodes/archive.php'; + if (!file_exists($template)) { + return ''; + } + ob_start(); + include $template; + return (string) ob_get_clean(); + } + + public function render_profile_shortcode(array $attrs = [], string $content = ''): string { + $template = AKSRT_DIR . '/templates/shortcodes/profile.php'; + if (!file_exists($template)) { + return ''; + } + ob_start(); + include $template; + return (string) ob_get_clean(); + } +} + +return AKSRT_Templates::get_instance(); diff --git a/inc/classes/class-aksrt-term-meta.php b/inc/classes/class-aksrt-term-meta.php new file mode 100644 index 00000000..a626b793 --- /dev/null +++ b/inc/classes/class-aksrt-term-meta.php @@ -0,0 +1,288 @@ +taxonomies as $tax) { + add_action("{$tax}_add_form_fields", [$this, 'render_add_fields']); + add_action("{$tax}_edit_form_fields", [$this, 'render_edit_fields']); + add_action("created_{$tax}", [$this, 'save_term_meta']); + add_action("edited_{$tax}", [$this, 'save_term_meta']); + } + + add_action('pre_get_posts', [$this, 'filter_archive_query']); + } + + /** + * Get term meta with defaults. + */ + public static function get_meta(int $term_id): array { + $defaults = AKSRT_Domain_Model::default_term_meta(); + $saved = get_term_meta($term_id, self::META_KEY, true); + if (!is_array($saved)) $saved = []; + return array_merge($defaults, $saved); + } + + /** + * Get a single term meta value. + */ + public static function get_meta_value(int $term_id, string $key, $default = '') { + $meta = self::get_meta($term_id); + return $meta[$key] ?? $default; + } + + /** + * Resolve global page-display settings with term-level overrides. + * + * @param WP_Term $term Archive term. + * @return array + */ + public static function resolve_archive_settings(WP_Term $term): array { + $taxonomy = $term->taxonomy === 'post_tag' ? 'post_tag' : 'category'; + $global = $taxonomy === 'post_tag' && class_exists('AKSRT_Settings') + ? AKSRT_Settings::get_tag_page_settings() + : (class_exists('AKSRT_Settings') ? AKSRT_Settings::get_category_page_settings() : [ + 'header_style' => 'cover', + 'cover_enabled' => true, + 'desc_enabled' => true, + 'list_mode' => 'grid', + 'filter_bar' => true, + 'sidebar' => true, + 'per_page' => 12, + ]); + + $saved = get_term_meta($term->term_id, self::META_KEY, true); + if (!is_array($saved)) { + $saved = []; + } + + $layout_mode = isset($saved['layout_mode']) && $saved['layout_mode'] !== '' + ? sanitize_key((string) $saved['layout_mode']) + : (string) ($global['list_mode'] ?? 'grid'); + + $layout_map = [ + 'grid' => ['mode' => 'card', 'card_style' => 'standard'], + 'card' => ['mode' => 'card', 'card_style' => 'standard'], + 'list' => ['mode' => 'list', 'card_style' => 'standard'], + 'compact' => ['mode' => 'list', 'card_style' => 'compact'], + 'mini' => ['mode' => 'mini', 'card_style' => 'standard'], + 'grid-cms' => ['mode' => 'grid-cms', 'card_style' => 'standard'], + ]; + $resolved_layout = $layout_map[$layout_mode] ?? ['mode' => 'card', 'card_style' => 'standard']; + + $header_style = (string) ($global['header_style'] ?? 'simple'); + if (!in_array($header_style, ['cover', 'simple', 'none'], true)) { + $header_style = 'simple'; + } + + if (array_key_exists('show_header', $saved)) { + $header_style = !empty($saved['show_header']) + ? ($header_style === 'none' ? 'simple' : $header_style) + : 'none'; + } + + $sidebar_enabled = (bool) ($global['sidebar'] ?? true); + if (($saved['show_sidebar'] ?? '') === '1') { + $sidebar_enabled = true; + } elseif (($saved['show_sidebar'] ?? '') === '0') { + $sidebar_enabled = false; + } + + $cover_url = isset($saved['cover_url']) ? esc_url_raw((string) $saved['cover_url']) : ''; + if ($cover_url === '' && $taxonomy === 'category' && !empty($global['cover_enabled']) && class_exists('AKSRT_Settings')) { + $cover_url = AKSRT_Settings::get_category_cover_url($term->term_id); + } + + $description = isset($saved['description']) ? sanitize_textarea_field((string) $saved['description']) : ''; + if ($description === '') { + $description = (string) $term->description; + } + + return [ + 'taxonomy' => $taxonomy, + 'layout_mode' => $resolved_layout['mode'], + 'card_style' => $resolved_layout['card_style'], + 'layout_preset' => $layout_mode, + 'header_style' => $header_style, + 'show_sidebar' => $sidebar_enabled, + 'show_cover' => $header_style === 'cover' && !empty($global['cover_enabled']) && $cover_url !== '', + 'show_desc' => $taxonomy === 'post_tag' ? $description !== '' : (!empty($global['desc_enabled']) && $description !== ''), + 'filter_bar' => $taxonomy === 'category' && !empty($global['filter_bar']), + 'per_page' => max(4, min(48, (int) ($global['per_page'] ?? 12))), + 'cover_url' => $cover_url, + 'description' => $description, + ]; + } + + /** + * Apply archive per-page settings to the main query. + */ + public function filter_archive_query(WP_Query $query): void { + if (is_admin() || !$query->is_main_query()) { + return; + } + + $term = null; + if ($query->is_category()) { + $cat_id = absint($query->get('cat')); + if ($cat_id > 0) { + $term = get_term($cat_id, 'category'); + } else { + $slug = sanitize_title((string) $query->get('category_name')); + if ($slug !== '') { + $term = get_term_by('slug', $slug, 'category'); + } + } + } elseif ($query->is_tag()) { + $tag_id = absint($query->get('tag_id')); + if ($tag_id > 0) { + $term = get_term($tag_id, 'post_tag'); + } else { + $slug = sanitize_title((string) $query->get('tag')); + if ($slug !== '') { + $term = get_term_by('slug', $slug, 'post_tag'); + } + } + } + + if (!$term instanceof WP_Term) { + return; + } + + $settings = self::resolve_archive_settings($term); + if (!empty($settings['per_page'])) { + $query->set('posts_per_page', (int) $settings['per_page']); + } + } + + /** + * Render fields for "Add new term" form. + */ + public function render_add_fields(string $taxonomy): void { + ?> +
+ + +

+
+
+ + +
+ term_id); + ?> + + + + +

+ + + + + + +

+ + + + + + + + + + + +
+
+ + + + isset($raw['cover_url']) ? esc_url_raw($raw['cover_url']) : '', + 'description' => isset($raw['description']) ? sanitize_textarea_field($raw['description']) : '', + 'layout_mode' => isset($raw['layout_mode']) ? sanitize_key($raw['layout_mode']) : '', + 'show_sidebar' => isset($raw['show_sidebar']) ? '1' : '', + 'show_header' => isset($raw['show_header']) ? true : false, + 'show_featured' => isset($raw['show_featured']) ? true : false, + ]; + + // Only allow valid layout modes + $valid_modes = ['', 'card', 'list', 'mini', 'grid-cms']; + if (!in_array($sanitized['layout_mode'], $valid_modes, true)) { + $sanitized['layout_mode'] = ''; + } + + update_term_meta($term_id, self::META_KEY, $sanitized); + } +} diff --git a/inc/classes/class-aksrt-update-checker.php b/inc/classes/class-aksrt-update-checker.php new file mode 100644 index 00000000..bd75c1bf --- /dev/null +++ b/inc/classes/class-aksrt-update-checker.php @@ -0,0 +1,182 @@ +get('Version') ?: '0.0.0'; + } + + /** + * Get update endpoint URL + */ + private function get_endpoint(): string { + if (class_exists('AKSRT_Settings')) { + $endpoint = AKSRT_Settings::get_value('update_endpoint', ''); + if (!empty($endpoint)) return $endpoint; + } + return 'https://api.aksrt.com/version.json'; + } + + /** + * Maybe check for updates (once per day) + */ + public function maybe_check(): void { + $stored = get_option(self::OPTION_KEY, []); + $last_check = $stored['last_check'] ?? 0; + + if ((time() - $last_check) < self::CHECK_INTERVAL) { + return; + } + + $this->do_check(); + } + + /** + * Force check for updates + */ + public function do_check(): void { + $endpoint = $this->get_endpoint(); + + $response = wp_remote_get($endpoint, [ + 'timeout' => 5, + 'headers' => [ + 'Accept' => 'application/json', + ], + ]); + + if (is_wp_error($response)) { + update_option(self::OPTION_KEY, [ + 'last_check' => time(), + 'error' => $response->get_error_message(), + ]); + return; + } + + $body = wp_remote_retrieve_body($response); + $data = json_decode($body, true); + + if (!is_array($data) || empty($data['version'])) { + return; + } + + $current = $this->get_current_version(); + $remote_version = $data['version']; + $update_available = version_compare($remote_version, $current, '>'); + + update_option(self::OPTION_KEY, [ + 'last_check' => time(), + 'current_version' => $current, + 'remote_version' => $remote_version, + 'update_available' => $update_available, + 'download_url' => $data['download_url'] ?? '', + 'changelog' => $data['changelog'] ?? '', + 'notify' => !empty($data['notify']), + ]); + } + + /** + * Show admin notice if update is available + */ + public function show_update_notice(): void { + if (!current_user_can('update_themes')) return; + + $stored = get_option(self::OPTION_KEY, []); + if (empty($stored['update_available'])) return; + + $remote = $stored['remote_version'] ?? ''; + $current = $stored['current_version'] ?? ''; + $download_url = $stored['download_url'] ?? ''; + $changelog = $stored['changelog'] ?? ''; + + // User dismissed this version? + $dismissed = get_user_meta(get_current_user_id(), 'aksrt_dismissed_update', true); + if ($dismissed === $remote) return; + + $dismiss_url = wp_nonce_url( + add_query_arg('aksrt_dismiss_update', $remote, admin_url()), + 'aksrt_dismiss_update_' . $remote + ); + + echo '
'; + echo '

' . esc_html__('AKSRT 主题更新', 'aksrt') . '

'; + echo '

' . sprintf( + esc_html__('新版本 %s 可用(当前版本 %s)。', 'aksrt'), + '' . esc_html($remote) . '', + esc_html($current) + ) . '

'; + + if (!empty($changelog)) { + echo '

' . esc_html($changelog) . '

'; + } + + echo '

'; + if (!empty($download_url)) { + echo '' + . esc_html__('下载更新', 'aksrt') . ' '; + } + echo '' + . esc_html__('忽略此版本', 'aksrt') . ''; + echo '

'; + echo '
'; + } + + /** + * Handle dismiss action + */ + public static function handle_dismiss(): void { + if (!isset($_GET['aksrt_dismiss_update'])) return; + $version = sanitize_text_field($_GET['aksrt_dismiss_update']); + if (!wp_verify_nonce($_GET['_wp_nonce'] ?? '', 'aksrt_dismiss_update_' . $version)) return; + + update_user_meta(get_current_user_id(), 'aksrt_dismissed_update', $version); + wp_safe_redirect(remove_query_arg(['aksrt_dismiss_update', '_wp_nonce'])); + exit; + } + + /** + * Get stored update info + */ + public function get_update_info(): array { + return get_option(self::OPTION_KEY, []); + } +} + +// Handle dismiss on admin_init +add_action('admin_init', function () { + if (isset($_GET['aksrt_dismiss_update'])) { + AKSRT_Update_Checker::handle_dismiss(); + } +}); diff --git a/inc/classes/class-aksrt-user-center.php b/inc/classes/class-aksrt-user-center.php new file mode 100644 index 00000000..2aab5f81 --- /dev/null +++ b/inc/classes/class-aksrt-user-center.php @@ -0,0 +1,140 @@ +register_default_panels(); + } + + /** + * 注册默认面板 + */ + private function register_default_panels(): void { + $settings = class_exists('AKSRT_Settings') + ? AKSRT_Settings::get_user_center_settings() + : [ + 'tabs' => ['profile', 'posts', 'comments', 'favorites'], + 'avatar_upload' => true, + 'cover_upload' => true, + 'social_links' => true, + ]; + + $definitions = [ + 'profile' => [ + 'label' => __('个人信息', 'aksrt'), + 'icon' => 'user', + 'priority' => 10, + 'type' => 'panel', + ], + 'posts' => [ + 'label' => __('我的文章', 'aksrt'), + 'icon' => 'file-alt', + 'priority' => 20, + 'type' => 'panel', + ], + 'comments' => [ + 'label' => __('我的评论', 'aksrt'), + 'icon' => 'comment', + 'priority' => 30, + 'type' => 'panel', + ], + 'favorites' => [ + 'label' => __('我的收藏', 'aksrt'), + 'icon' => 'star', + 'priority' => 40, + 'type' => 'panel', + ], + 'credits' => [ + 'label' => class_exists('AKSRT_Settings') + ? sprintf(__('我的%s', 'aksrt'), AKSRT_Settings::get_currency_name()) + : __('我的积分', 'aksrt'), + 'icon' => 'wallet', + 'priority' => 50, + 'type' => 'panel', + ], + 'notif' => [ + 'label' => __('消息通知', 'aksrt'), + 'icon' => 'bell', + 'priority' => 60, + 'type' => 'link', + ], + 'settings' => [ + 'label' => __('账号设置', 'aksrt'), + 'icon' => 'cog', + 'priority' => 70, + 'type' => 'panel', + ], + ]; + + $this->panels = []; + + foreach ((array) ($settings['tabs'] ?? []) as $key) { + if ($key === 'credits' && (!class_exists('AKSRT_Settings') || !AKSRT_Settings::credits_enabled())) { + continue; + } + + if (isset($definitions[$key])) { + $this->panels[$key] = $definitions[$key]; + } + } + + if (!isset($this->panels['settings'])) { + $this->panels['settings'] = $definitions['settings']; + } + + $this->panels['notif'] = $definitions['notif']; + + /** + * 允许插件/子主题注册额外面板 + * + * @param array $panels 面板注册表 + */ + $this->panels = apply_filters('aksrt_user_center_panels', $this->panels); + } + + /** + * 获取面板注册表 + */ + public function get_panels(): array { + return $this->panels; + } + + /** + * 获取可切换的本地面板(不含外链入口)。 + */ + public function get_dashboard_tabs(): array { + return array_filter($this->panels, static function (array $panel): bool { + return ($panel['type'] ?? 'panel') === 'panel'; + }); + } + + /** + * 判断当前页面是否为本人中心 + */ + public static function is_page(): bool { + return function_exists('aksrt_is_user_center_page') && aksrt_is_user_center_page(); + } +} diff --git a/inc/classes/class-aksrt-user-credits-admin.php b/inc/classes/class-aksrt-user-credits-admin.php new file mode 100644 index 00000000..f28f2887 --- /dev/null +++ b/inc/classes/class-aksrt-user-credits-admin.php @@ -0,0 +1,265 @@ +' . esc_html((string) $balance) . ''; + } + + public function make_balance_sortable(array $columns): array { + $columns['aksrt_credits_balance'] = 'aksrt_credits_balance'; + return $columns; + } + + public function sort_users_by_credits($query): void { + if (!is_admin()) { + return; + } + if ($query->get('orderby') !== 'aksrt_credits_balance') { + return; + } + $query->set('meta_key', 'aksrt_credits'); + $query->set('orderby', 'meta_value_num'); + } + + // ========== 用户列表:行操作 ========== + + public function add_row_actions(array $actions, WP_User $user): array { + if (!$this->can_manage_credits()) { + return $actions; + } + + $edit_url = get_edit_user_link($user->ID); + if ($edit_url) { + $currency_name = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_currency_name() : __('积分', 'aksrt'); + $actions['aksrt_edit_credits'] = sprintf( + '%s', + esc_url($edit_url), + esc_html(sprintf(__('调整%s', 'aksrt'), $currency_name)) + ); + } + + return $actions; + } + + // ========== 用户编辑页:积分管理区块 ========== + + public function render_user_credits_section(WP_User $user): void { + if (!class_exists('AKSRT_Credits')) { + return; + } + + $balance = AKSRT_Credits::balance((int) $user->ID); + $can_manage = $this->can_manage_credits(); + $currency_name = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_currency_name() : __('积分', 'aksrt'); + ?> + +

+ + + + + + + + + + + + + + + + (int) $user->ID, 'page' => 1, 'per_page' => 20]); + $items = $log['items'] ?? []; + $reason_labels = [ + 'register' => __('注册赠送', 'aksrt'), + 'signin' => __('每日签到', 'aksrt'), + 'comment' => __('评论奖励', 'aksrt'), + 'invite' => __('邀请奖励', 'aksrt'), + 'invitee' => __('被邀请奖励', 'aksrt'), + 'download' => __('下载消费', 'aksrt'), + 'admin' => __('管理员调整', 'aksrt'), + ]; + ?> +

+ + + + + + + + + + + + + + + + + + + + + + +
+ 0 ? '+' : '') . $delta); ?> +
+ +

+ can_manage_credits() || !class_exists('AKSRT_Credits')) { + return; + } + if (!current_user_can('edit_user', $user_id)) { + return; + } + if (!isset($_POST['_aksrt_credits_nonce'])) { + return; + } + + $nonce = sanitize_text_field((string) $_POST['_aksrt_credits_nonce']); + if (!wp_verify_nonce($nonce, 'aksrt_user_credits_adjust_' . $user_id)) { + return; + } + + $delta = isset($_POST['aksrt_credits_delta']) ? (int) $_POST['aksrt_credits_delta'] : 0; + $reason = isset($_POST['aksrt_credits_reason']) ? sanitize_text_field((string) wp_unslash($_POST['aksrt_credits_reason'])) : ''; + + if ($delta === 0) { + return; + } + $currency_name = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_currency_name() : __('积分', 'aksrt'); + if ($reason === '') { + set_transient('aksrt_admin_notice_' . get_current_user_id(), [[ + 'type' => 'error', + 'msg' => sprintf(__('调整%s时请填写原因。', 'aksrt'), $currency_name), + ]], 30); + return; + } + + if ($delta > 0) { + $result = AKSRT_Credits::add($user_id, $delta, 'admin', ['type' => 'user', 'id' => get_current_user_id()], $reason); + } else { + $result = AKSRT_Credits::deduct($user_id, abs($delta), 'admin', ['type' => 'user', 'id' => get_current_user_id()], $reason); + } + + if (is_wp_error($result)) { + set_transient('aksrt_admin_notice_' . get_current_user_id(), [[ + 'type' => 'error', + 'msg' => $result->get_error_message(), + ]], 30); + return; + } + + set_transient('aksrt_admin_notice_' . get_current_user_id(), [[ + 'type' => 'success', + 'msg' => sprintf(__('%s调整成功,当前余额:%d', 'aksrt'), $currency_name, (int) $result), + ]], 30); + } +} diff --git a/inc/classes/class-aksrt-user-home.php b/inc/classes/class-aksrt-user-home.php new file mode 100644 index 00000000..a5befaa9 --- /dev/null +++ b/inc/classes/class-aksrt-user-home.php @@ -0,0 +1,277 @@ +register_default_tabs(); + } + + /** + * 注册默认公开 Tab + */ + private function register_default_tabs(): void { + $this->tabs = [ + 'posts' => [ + 'label' => __('文章', 'aksrt'), + 'icon' => 'file-alt', + 'priority' => 10, + ], + 'comments' => [ + 'label' => __('评论', 'aksrt'), + 'icon' => 'comment', + 'priority' => 20, + ], + 'favorites' => [ + 'label' => __('收藏', 'aksrt'), + 'icon' => 'star', + 'priority' => 30, + ], + 'credits' => [ + 'label' => class_exists('AKSRT_Settings') + ? sprintf(__('我的%s', 'aksrt'), AKSRT_Settings::get_currency_name()) + : __('我的积分', 'aksrt'), + 'icon' => 'wallet', + 'priority' => 40, + ], + 'product' => [ + 'label' => __('商品', 'aksrt'), + 'icon' => 'shopping-bag', + 'priority' => 50, + ], + 'plate' => [ + 'label' => __('圈子', 'aksrt'), + 'icon' => 'users', + 'priority' => 60, + ], + 'forum' => [ + 'label' => __('论坛', 'aksrt'), + 'icon' => 'comments', + 'priority' => 70, + ], + 'follow' => [ + 'label' => __('关注', 'aksrt'), + 'icon' => 'user-plus', + 'priority' => 80, + ], + ]; + + /** + * 允许插件/子主题注册额外公开 Tab + * + * @param array $tabs Tab 注册表 + */ + $this->tabs = apply_filters('aksrt_user_home_tabs', $this->tabs); + } + + /** + * 获取 Tab 注册表 + */ + public function get_tabs(): array { + return $this->tabs; + } + + /** + * 获取当前用户主页实际可见的公开 Tab。 + */ + public function get_visible_tabs(bool $is_own_profile = false): array { + $settings = class_exists('AKSRT_Settings') + ? AKSRT_Settings::get_user_home_settings() + : [ + 'tabs' => ['posts', 'comments', 'favorites'], + ]; + + $visible = []; + foreach ((array) ($settings['tabs'] ?? []) as $key) { + if (!isset($this->tabs[$key])) { + continue; + } + + if ($key === 'credits' && (!$is_own_profile || !class_exists('AKSRT_Settings') || !AKSRT_Settings::credits_enabled())) { + continue; + } + + $visible[$key] = $this->tabs[$key]; + } + + if (empty($visible)) { + foreach (['posts', 'comments', 'favorites'] as $fallback) { + if (isset($this->tabs[$fallback])) { + $visible[$fallback] = $this->tabs[$fallback]; + } + } + } + + return $visible; + } + + /** + * 获取公开展示的身份信号(认证 / 等级 / 徽章)。 + * + * @return array + */ + public function get_profile_signals(int $user_id): array { + if ($user_id <= 0 || !class_exists('AKSRT_Settings')) { + return []; + } + + $settings = AKSRT_Settings::get_user_identity_settings(); + $signals = []; + + if (!empty($settings['verify']['enabled']) && !empty($settings['verify']['public_display']) && $this->is_user_verified($user_id, $settings['verify'])) { + $signals[] = [ + 'label' => (string) $settings['verify']['badge_text'], + 'icon' => 'check-circle', + 'tone' => 'success', + ]; + } + + if (!empty($settings['level']['enabled']) && !empty($settings['level']['public_display'])) { + $level = $this->get_user_level($user_id, $settings['level']); + if ($level > 0) { + $signals[] = [ + 'label' => (string) $settings['level']['label_prefix'] . $level, + 'icon' => 'layer-group', + 'tone' => 'info', + ]; + } + } + + if (!empty($settings['badge']['enabled']) && !empty($settings['badge']['public_display'])) { + $badge_map = []; + foreach ((array) ($settings['badge']['dictionary'] ?? []) as $item) { + if (!empty($item['slug'])) { + $badge_map[(string) $item['slug']] = $item; + } + } + + $assigned = get_user_meta($user_id, 'aksrt_user_badges', true); + if (!is_array($assigned)) { + $assigned = preg_split('/[\s,]+/', (string) $assigned, -1, PREG_SPLIT_NO_EMPTY); + } + + $limit = max(1, (int) ($settings['badge']['limit'] ?? 3)); + foreach ((array) $assigned as $slug) { + $slug = sanitize_key((string) $slug); + if ($slug === '' || !isset($badge_map[$slug])) { + continue; + } + + $signals[] = [ + 'label' => (string) $badge_map[$slug]['label'], + 'icon' => 'award', + 'tone' => (string) ($badge_map[$slug]['tone'] ?? 'neutral'), + ]; + + if (count($signals) >= $limit + 2) { + break; + } + } + } + + return $signals; + } + + /** + * 获取公开治理提示。 + * + * @return array{state:string,text:string}|array{} + */ + public function get_profile_notice(int $user_id): array { + if ($user_id <= 0 || !class_exists('AKSRT_Settings')) { + return []; + } + + $settings = AKSRT_Settings::get_user_identity_settings(); + if (empty($settings['report']['enabled']) || empty($settings['report']['public_notice'])) { + return []; + } + + $state = sanitize_key((string) get_user_meta($user_id, 'aksrt_user_ban_state', true)); + if (!in_array($state, ['muted', 'restricted', 'banned'], true)) { + return []; + } + + return [ + 'state' => $state, + 'text' => (string) $settings['report']['ban_notice_text'], + ]; + } + + /** + * 公开主页是否显示举报入口。 + */ + public function show_report_entry(): bool { + if (!class_exists('AKSRT_Settings')) { + return false; + } + + $settings = AKSRT_Settings::get_user_identity_settings(); + return !empty($settings['report']['enabled']) && !empty($settings['report']['public_entry']); + } + + /** + * @param array{accept_email_verified:bool} $verify_settings + */ + private function is_user_verified(int $user_id, array $verify_settings): bool { + $manual = sanitize_key((string) get_user_meta($user_id, 'aksrt_user_verify_status', true)); + if ($manual === 'verified') { + return true; + } + + if (!empty($verify_settings['accept_email_verified']) && class_exists('AKSRT_Auth_Service')) { + return AKSRT_Auth_Service::user_is_email_verified($user_id); + } + + return false; + } + + /** + * @param array{source:string,thresholds:array} $level_settings + */ + private function get_user_level(int $user_id, array $level_settings): int { + if (($level_settings['source'] ?? 'credits_balance') === 'manual_meta') { + return max(0, (int) get_user_meta($user_id, 'aksrt_user_level', true)); + } + + $score = function_exists('aksrt_user_credits') ? (int) aksrt_user_credits($user_id) : 0; + $level = 0; + + foreach ((array) ($level_settings['thresholds'] ?? []) as $candidate => $threshold) { + if ($score >= (int) $threshold) { + $level = (int) $candidate; + } + } + + return $level; + } + + /** + * 判断当前页面是否为公开用户主页 + */ + public static function is_page(): bool { + return function_exists('aksrt_is_user_home_page') && aksrt_is_user_home_page(); + } +} diff --git a/inc/classes/class-aksrt-user-notifications.php b/inc/classes/class-aksrt-user-notifications.php new file mode 100644 index 00000000..4dfe1777 --- /dev/null +++ b/inc/classes/class-aksrt-user-notifications.php @@ -0,0 +1,595 @@ + + */ +function aksrt_get_user_notifications(int $user_id = 0): array { + $user_id = $user_id > 0 ? $user_id : get_current_user_id(); + if ($user_id <= 0) { + return []; + } + + $items = []; + + $email_verified = true; + if (class_exists('AKSRT_Auth_Service')) { + $email_verified = AKSRT_Auth_Service::user_is_email_verified($user_id); + } + + if (!$email_verified && (!class_exists('AKSRT_Settings') || AKSRT_Settings::notification_enabled('verify_email'))) { + $items[] = [ + 'type' => 'verify_email', + 'title' => __('邮箱待验证', 'aksrt'), + 'desc' => __('请尽快完成邮箱验证,部分功能需要验证后才能使用。', 'aksrt'), + 'url' => function_exists('aksrt_get_verify_email_url') ? aksrt_get_verify_email_url($user_id) : home_url('/'), + 'count' => 1, + ]; + } + + $pending_comments = (int) get_comments([ + 'user_id' => $user_id, + 'count' => true, + 'status' => 'hold', + ]); + + if ($pending_comments > 0 && (!class_exists('AKSRT_Settings') || AKSRT_Settings::notification_enabled('pending_comments'))) { + $items[] = [ + 'type' => 'pending_comments', + 'title' => __('评论待审核', 'aksrt'), + 'desc' => sprintf(__('你有 %d 条评论正在等待审核。', 'aksrt'), $pending_comments), + 'url' => aksrt_get_user_center_url('comments'), + 'count' => $pending_comments, + ]; + } + + return $items; +} +endif; + +if (!function_exists('aksrt_notification_type_label')) : +/** 通知类型中文标签 */ +function aksrt_notification_type_label(string $type): string { + $map = [ + 'comment_reply' => __('评论回复', 'aksrt'), + 'comment_approved' => __('评论审核', 'aksrt'), + 'download_ready' => __('下载就绪', 'aksrt'), + 'credits_added' => __('积分增加', 'aksrt'), + 'credits_deducted' => __('积分扣减', 'aksrt'), + 'verify_email' => __('邮箱验证', 'aksrt'), + 'pending_comments' => __('待审核评论', 'aksrt'), + 'notice' => __('通知', 'aksrt'), + ]; + return $map[$type] ?? $type; +} +endif; + +if (!function_exists('aksrt_get_user_notification_count')) : +/** 获取用户提醒总数 */ +function aksrt_get_user_notification_count(int $user_id = 0): int { + $items = aksrt_get_user_notifications($user_id); + $count = 0; + foreach ($items as $item) { + $count += max(0, (int) ($item['count'] ?? 0)); + } + return $count; +} +endif; + +if (!function_exists('aksrt_get_user_inbox_notifications')) : +/** 获取用户通知收件箱 */ +function aksrt_get_user_inbox_notifications(int $user_id = 0): array { + $user_id = $user_id > 0 ? $user_id : get_current_user_id(); + if ($user_id <= 0) return []; + + global $wpdb; + $table = class_exists('AKSRT_DB_Migrator') ? AKSRT_DB_Migrator::table('notification') : ''; + if ($table === '') { + $items = get_user_meta($user_id, 'aksrt_notifications', true); + return is_array($items) ? array_values($items) : []; + } + + static $migrated = []; + if (empty($migrated[$user_id])) { + $legacy = get_user_meta($user_id, 'aksrt_notifications', true); + if (is_array($legacy) && !empty($legacy)) { + foreach ($legacy as $item) { + aksrt_add_user_notification($user_id, [ + 'id' => $item['id'] ?? '', + 'type' => $item['type'] ?? 'notice', + 'title' => $item['title'] ?? '', + 'desc' => $item['desc'] ?? '', + 'url' => $item['url'] ?? '', + 'read' => !empty($item['read']), + 'created_at' => $item['created_at'] ?? current_time('mysql', true), + ]); + } + delete_user_meta($user_id, 'aksrt_notifications'); + } + $migrated[$user_id] = true; + } + + $rows = $wpdb->get_results($wpdb->prepare( + "SELECT id, type, title, description, target_url, is_read, created_at, read_at + FROM {$table} + WHERE user_id = %d + ORDER BY created_at DESC, id DESC + LIMIT 50", + $user_id + ), ARRAY_A); + + if (!is_array($rows)) return []; + + return array_map(static function ($row) { + return [ + 'id' => (string) ($row['id'] ?? ''), + 'type' => (string) ($row['type'] ?? 'notice'), + 'title' => (string) ($row['title'] ?? ''), + 'desc' => (string) ($row['description'] ?? ''), + 'url' => (string) ($row['target_url'] ?? ''), + 'read' => !empty($row['is_read']) ? 1 : 0, + 'created_at' => (string) ($row['created_at'] ?? ''), + 'read_at' => (string) ($row['read_at'] ?? ''), + ]; + }, $rows); +} +endif; + +if (!function_exists('aksrt_schedule_notification_cleanup')) : +/** 注册通知清理定时任务 */ +function aksrt_schedule_notification_cleanup(): void { + if (!wp_next_scheduled('aksrt_notification_cleanup')) { + $next = (new DateTimeImmutable('tomorrow 04:10', wp_timezone()))->getTimestamp(); + wp_schedule_event($next, 'daily', 'aksrt_notification_cleanup'); + } +} +endif; + +if (!function_exists('aksrt_cleanup_notifications')) : +/** + * 清理通知表: + * - 已读通知保留 30 天 + * - 未读通知保留 180 天 + * - 每个用户最多保留最近 200 条通知 + */ +function aksrt_cleanup_notifications(): void { + global $wpdb; + + $table = class_exists('AKSRT_DB_Migrator') ? AKSRT_DB_Migrator::table('notification') : ''; + if ($table === '') { + return; + } + + $settings = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_notification_settings() : [ + 'read_retention_days' => 30, + 'unread_retention_days' => 180, + 'max_per_user' => 200, + ]; + + $read_retention_days = max(1, (int) ($settings['read_retention_days'] ?? 30)); + $unread_retention_days = max(7, (int) ($settings['unread_retention_days'] ?? 180)); + $max_per_user = max(20, (int) ($settings['max_per_user'] ?? 200)); + + $read_cutoff = (new DateTimeImmutable('-' . $read_retention_days . ' days', new DateTimeZone('UTC')))->format('Y-m-d H:i:s'); + $unread_cutoff = (new DateTimeImmutable('-' . $unread_retention_days . ' days', new DateTimeZone('UTC')))->format('Y-m-d H:i:s'); + + $wpdb->query($wpdb->prepare( + "DELETE FROM {$table} WHERE is_read = 1 AND created_at < %s", + $read_cutoff + )); + + $wpdb->query($wpdb->prepare( + "DELETE FROM {$table} WHERE is_read = 0 AND created_at < %s", + $unread_cutoff + )); + + $user_ids = $wpdb->get_col("SELECT DISTINCT user_id FROM {$table}"); + if (empty($user_ids)) { + return; + } + + foreach ($user_ids as $user_id) { + $user_id = (int) $user_id; + if ($user_id <= 0) continue; + + $ids = $wpdb->get_col($wpdb->prepare( + "SELECT id FROM {$table} WHERE user_id = %d ORDER BY created_at DESC, id DESC LIMIT %d, 1000", + $user_id, + $max_per_user + )); + + if (empty($ids)) { + continue; + } + + $ids = array_map('absint', $ids); + $ids = array_filter($ids, static function ($id) { return $id > 0; }); + if (empty($ids)) { + continue; + } + + $wpdb->query("DELETE FROM {$table} WHERE id IN (" . implode(',', $ids) . ")"); + } +} +endif; + +add_action('wp', 'aksrt_schedule_notification_cleanup'); +add_action('aksrt_notification_cleanup', 'aksrt_cleanup_notifications'); + +if (!function_exists('aksrt_write_user_inbox_notifications')) : +/** 写入用户通知收件箱 */ +function aksrt_write_user_inbox_notifications(int $user_id, array $items): void { + if ($user_id <= 0) return; + global $wpdb; + $table = class_exists('AKSRT_DB_Migrator') ? AKSRT_DB_Migrator::table('notification') : ''; + if ($table === '') { + update_user_meta($user_id, 'aksrt_notifications', array_values(array_slice($items, 0, 50))); + return; + } + + $wpdb->delete($table, ['user_id' => $user_id], ['%d']); + $items = array_values(array_slice($items, 0, 50)); + foreach ($items as $item) { + $wpdb->insert($table, [ + 'user_id' => $user_id, + 'type' => sanitize_key((string) ($item['type'] ?? 'notice')), + 'title' => sanitize_text_field((string) ($item['title'] ?? '')), + 'description' => sanitize_text_field((string) ($item['desc'] ?? '')), + 'target_url' => esc_url_raw((string) ($item['url'] ?? '')), + 'is_read' => !empty($item['read']) ? 1 : 0, + 'created_at' => (string) ($item['created_at'] ?? current_time('mysql', true)), + 'read_at' => !empty($item['read']) ? current_time('mysql', true) : null, + ], ['%d', '%s', '%s', '%s', '%s', '%d', '%s', '%s']); + } +} +endif; + +if (!function_exists('aksrt_add_user_notification')) : +/** 添加一条用户通知 */ +function aksrt_add_user_notification(int $user_id, array $payload): void { + if ($user_id <= 0) return; + $type = sanitize_key((string) ($payload['type'] ?? 'notice')); + if (class_exists('AKSRT_Settings') && !AKSRT_Settings::notification_enabled($type)) { + return; + } + + global $wpdb; + $table = class_exists('AKSRT_DB_Migrator') ? AKSRT_DB_Migrator::table('notification') : ''; + $created_at = (string) ($payload['created_at'] ?? current_time('mysql', true)); + + if ($table === '') { + $items = aksrt_get_user_inbox_notifications($user_id); + array_unshift($items, [ + 'id' => isset($payload['id']) && $payload['id'] !== '' ? sanitize_text_field((string) $payload['id']) : uniqid('notif_', true), + 'type' => $type, + 'title' => sanitize_text_field((string) ($payload['title'] ?? '')), + 'desc' => sanitize_text_field((string) ($payload['desc'] ?? '')), + 'url' => esc_url_raw((string) ($payload['url'] ?? '')), + 'read' => !empty($payload['read']) ? 1 : 0, + 'created_at' => $created_at, + ]); + aksrt_write_user_inbox_notifications($user_id, $items); + return; + } + + $wpdb->insert($table, [ + 'user_id' => $user_id, + 'type' => $type, + 'title' => sanitize_text_field((string) ($payload['title'] ?? '')), + 'description' => sanitize_text_field((string) ($payload['desc'] ?? '')), + 'target_url' => esc_url_raw((string) ($payload['url'] ?? '')), + 'is_read' => !empty($payload['read']) ? 1 : 0, + 'created_at' => $created_at, + 'read_at' => !empty($payload['read']) ? current_time('mysql', true) : null, + ], ['%d', '%s', '%s', '%s', '%s', '%d', '%s', '%s']); +} +endif; + +if (!function_exists('aksrt_mark_user_notification_read')) : +/** 标记通知已读 */ +function aksrt_mark_user_notification_read(int $user_id, string $notification_id = '', bool $mark_all = false): void { + if ($user_id <= 0) return; + global $wpdb; + $table = class_exists('AKSRT_DB_Migrator') ? AKSRT_DB_Migrator::table('notification') : ''; + if ($table === '') { + $items = aksrt_get_user_inbox_notifications($user_id); + foreach ($items as &$item) { + if ($mark_all || (($item['id'] ?? '') === $notification_id)) { + $item['read'] = 1; + } + } + unset($item); + aksrt_write_user_inbox_notifications($user_id, $items); + return; + } + + if ($mark_all) { + $wpdb->update( + $table, + ['is_read' => 1, 'read_at' => current_time('mysql', true)], + ['user_id' => $user_id, 'is_read' => 0], + ['%d', '%s'], + ['%d', '%d'] + ); + return; + } + + $wpdb->update( + $table, + ['is_read' => 1, 'read_at' => current_time('mysql', true)], + ['id' => absint($notification_id), 'user_id' => $user_id], + ['%d', '%s'], + ['%d', '%d'] + ); +} +endif; + +if (!function_exists('aksrt_delete_user_notification')) : +/** 删除一条用户通知 */ +function aksrt_delete_user_notification(int $user_id, string $notification_id): void { + if ($user_id <= 0 || $notification_id === '') return; + global $wpdb; + $table = class_exists('AKSRT_DB_Migrator') ? AKSRT_DB_Migrator::table('notification') : ''; + if ($table === '') { + $items = aksrt_get_user_inbox_notifications($user_id); + $items = array_values(array_filter($items, static function ($item) use ($notification_id) { + return (string) ($item['id'] ?? '') !== $notification_id; + })); + aksrt_write_user_inbox_notifications($user_id, $items); + return; + } + $wpdb->delete($table, ['id' => absint($notification_id), 'user_id' => $user_id], ['%d', '%d']); +} +endif; + +if (!function_exists('aksrt_delete_user_notifications')) : +/** 批量删除用户通知 */ +function aksrt_delete_user_notifications(int $user_id, array $notification_ids): void { + if ($user_id <= 0 || empty($notification_ids)) return; + $ids = array_values(array_filter(array_map('absint', $notification_ids), static function ($id) { return $id > 0; })); + if (empty($ids)) return; + + global $wpdb; + $table = class_exists('AKSRT_DB_Migrator') ? AKSRT_DB_Migrator::table('notification') : ''; + if ($table === '') { + $items = aksrt_get_user_inbox_notifications($user_id); + $items = array_values(array_filter($items, static function ($item) use ($ids) { + return !in_array(absint($item['id'] ?? 0), $ids, true); + })); + aksrt_write_user_inbox_notifications($user_id, $items); + return; + } + + $wpdb->query( + "DELETE FROM {$table} WHERE user_id = " . (int) $user_id . " AND id IN (" . implode(',', $ids) . ")" + ); +} +endif; + +if (!function_exists('aksrt_get_user_unread_notification_count')) : +/** 获取用户未读通知数 */ +function aksrt_get_user_unread_notification_count(int $user_id = 0): int { + $items = aksrt_get_user_inbox_notifications($user_id); + $count = 0; + foreach ($items as $item) { + if (empty($item['read'])) $count++; + } + return $count; +} +endif; + +if (!function_exists('aksrt_get_user_tab_badge_count')) : +/** user 底部 Tab 徽标计数:未读持久化通知 + 即时状态提醒 */ +function aksrt_get_user_tab_badge_count(int $user_id = 0): int { + $user_id = $user_id > 0 ? $user_id : get_current_user_id(); + if ($user_id <= 0) { + return 0; + } + if (class_exists('AKSRT_Settings') && !AKSRT_Settings::notification_user_badge_enabled()) { + return 0; + } + + $count = aksrt_get_user_unread_notification_count($user_id); + if (class_exists('AKSRT_Settings') && AKSRT_Settings::notification_badge_include_stateful()) { + $count += aksrt_get_user_notification_count($user_id); + } + return $count; +} +endif; + +if (!function_exists('aksrt_get_user_all_notifications')) : +/** 获取用户完整通知列表(持久化通知 + 即时提醒) */ +function aksrt_get_user_all_notifications(int $user_id = 0): array { + $user_id = $user_id > 0 ? $user_id : get_current_user_id(); + if ($user_id <= 0) return []; + + $inbox = aksrt_get_user_inbox_notifications($user_id); + $alerts = aksrt_get_user_notifications($user_id); + + $normalized_alerts = []; + foreach ($alerts as $alert) { + $normalized_alerts[] = [ + 'id' => 'state_' . sanitize_key((string) ($alert['type'] ?? 'notice')), + 'type' => sanitize_key((string) ($alert['type'] ?? 'notice')), + 'title' => sanitize_text_field((string) ($alert['title'] ?? '')), + 'desc' => sanitize_text_field((string) ($alert['desc'] ?? '')), + 'url' => esc_url_raw((string) ($alert['url'] ?? '')), + 'read' => 0, + 'created_at' => current_time('mysql', true), + 'count' => max(0, (int) ($alert['count'] ?? 0)), + 'stateful' => 1, + ]; + } + + return array_merge($inbox, $normalized_alerts); +} +endif; + +add_action('wp_ajax_aksrt_mark_notification_read', function () { + if (!is_user_logged_in()) { + wp_send_json_error(['message' => __('请先登录', 'aksrt')], 401); + } + + $nonce = isset($_POST['nonce']) ? (string) $_POST['nonce'] : ''; + if (!wp_verify_nonce($nonce, 'aksrt_public_nonce')) { + wp_send_json_error(['message' => __('安全验证失败', 'aksrt')], 403); + } + + $notification_id = sanitize_text_field((string) ($_POST['notification_id'] ?? '')); + $mark_all = !empty($_POST['mark_all']); + aksrt_mark_user_notification_read(get_current_user_id(), $notification_id, $mark_all); + wp_send_json_success([ + 'unread' => aksrt_get_user_unread_notification_count(get_current_user_id()), + ]); +}); + +add_action('wp_ajax_aksrt_delete_notification', function () { + if (!is_user_logged_in()) { + wp_send_json_error(['message' => __('请先登录', 'aksrt')], 401); + } + + $nonce = isset($_POST['nonce']) ? (string) $_POST['nonce'] : ''; + if (!wp_verify_nonce($nonce, 'aksrt_public_nonce')) { + wp_send_json_error(['message' => __('安全验证失败', 'aksrt')], 403); + } + + $notification_id = sanitize_text_field((string) ($_POST['notification_id'] ?? '')); + if ($notification_id === '') { + wp_send_json_error(['message' => __('参数错误', 'aksrt')], 400); + } + + aksrt_delete_user_notification(get_current_user_id(), $notification_id); + wp_send_json_success([ + 'unread' => aksrt_get_user_unread_notification_count(get_current_user_id()), + ]); +}); + +add_action('wp_ajax_aksrt_delete_notifications', function () { + if (!is_user_logged_in()) { + wp_send_json_error(['message' => __('请先登录', 'aksrt')], 401); + } + + $nonce = isset($_POST['nonce']) ? (string) $_POST['nonce'] : ''; + if (!wp_verify_nonce($nonce, 'aksrt_public_nonce')) { + wp_send_json_error(['message' => __('安全验证失败', 'aksrt')], 403); + } + + $ids = isset($_POST['notification_ids']) ? (array) $_POST['notification_ids'] : []; + aksrt_delete_user_notifications(get_current_user_id(), $ids); + wp_send_json_success([ + 'unread' => aksrt_get_user_unread_notification_count(get_current_user_id()), + ]); +}); + +add_action('wp_ajax_aksrt_check_new_notifications', function () { + if (!is_user_logged_in()) { + wp_send_json_error(['message' => __('请先登录', 'aksrt')], 401); + } + + $nonce = isset($_POST['nonce']) ? (string) $_POST['nonce'] : ''; + if (!wp_verify_nonce($nonce, 'aksrt_public_nonce')) { + wp_send_json_error(['message' => __('安全验证失败', 'aksrt')], 403); + } + + $unread = aksrt_get_user_unread_notification_count(get_current_user_id()); + wp_send_json_success([ + 'count' => $unread, + 'unread' => $unread, + ]); +}); +add_action('wp_insert_comment', function ($comment_id, $comment) { + if (!$comment instanceof WP_Comment) return; + if ((int) $comment->comment_parent <= 0) return; + if ((int) $comment->comment_approved !== 1) return; + if (get_comment_meta($comment_id, 'aksrt_reply_notified', true)) return; + + $parent = get_comment((int) $comment->comment_parent); + if (!$parent instanceof WP_Comment) return; + $target_user_id = (int) $parent->user_id; + if ($target_user_id <= 0 || $target_user_id === (int) $comment->user_id) return; + + $post_id = (int) $comment->comment_post_ID; + $url = get_comment_link($comment); + aksrt_add_user_notification($target_user_id, [ + 'type' => 'comment_reply', + 'title' => __('评论收到回复', 'aksrt'), + 'desc' => sprintf(__('“%s” 回复了你的评论。', 'aksrt'), get_comment_author($comment)), + 'url' => $url ?: get_permalink($post_id), + ]); + update_comment_meta($comment_id, 'aksrt_reply_notified', 1); +}, 20, 2); + +add_action('transition_comment_status', function ($new_status, $old_status, $comment) { + if (!$comment instanceof WP_Comment) return; + if ($new_status !== 'approve' || $old_status === 'approve') return; + + $comment_owner_id = (int) $comment->user_id; + if ($comment_owner_id > 0 && !get_comment_meta($comment->comment_ID, 'aksrt_approved_notified', true)) { + aksrt_add_user_notification($comment_owner_id, [ + 'type' => 'comment_approved', + 'title' => __('评论已通过审核', 'aksrt'), + 'desc' => __('你的评论已通过审核,现已公开展示。', 'aksrt'), + 'url' => get_comment_link($comment), + ]); + update_comment_meta($comment->comment_ID, 'aksrt_approved_notified', 1); + } + + if ((int) $comment->comment_parent <= 0) return; + if (get_comment_meta($comment->comment_ID, 'aksrt_reply_notified', true)) return; + + $parent = get_comment((int) $comment->comment_parent); + if (!$parent instanceof WP_Comment) return; + $target_user_id = (int) $parent->user_id; + if ($target_user_id <= 0 || $target_user_id === (int) $comment->user_id) return; + + aksrt_add_user_notification($target_user_id, [ + 'type' => 'comment_reply', + 'title' => __('评论收到回复', 'aksrt'), + 'desc' => sprintf(__('“%s” 回复了你的评论。', 'aksrt'), get_comment_author($comment)), + 'url' => get_comment_link($comment), + ]); + update_comment_meta($comment->comment_ID, 'aksrt_reply_notified', 1); +}, 20, 3); + +add_action('aksrt_credits_added', function ($user_id, $delta, $reason, $new_balance) { + if ($user_id <= 0 || $delta <= 0) return; + aksrt_add_user_notification((int) $user_id, [ + 'type' => 'credits_added', + 'title' => __('积分增加', 'aksrt'), + 'desc' => sprintf(__('积分变动:+%1$d,当前余额 %2$d。', 'aksrt'), (int) $delta, (int) $new_balance), + 'url' => aksrt_get_user_center_url('credits'), + ]); +}, 10, 4); + +add_action('aksrt_credits_deducted', function ($user_id, $cost, $reason, $new_balance) { + if ($user_id <= 0 || $cost <= 0) return; + aksrt_add_user_notification((int) $user_id, [ + 'type' => 'credits_deducted', + 'title' => __('积分扣减', 'aksrt'), + 'desc' => sprintf(__('积分变动:-%1$d,当前余额 %2$d。', 'aksrt'), (int) $cost, (int) $new_balance), + 'url' => aksrt_get_user_center_url('credits'), + ]); +}, 10, 4); + + + + + + + diff --git a/inc/classes/class-aksrt-user-restriction.php b/inc/classes/class-aksrt-user-restriction.php new file mode 100644 index 00000000..a1010bed --- /dev/null +++ b/inc/classes/class-aksrt-user-restriction.php @@ -0,0 +1,242 @@ +prefix . 'aksrt_user_restriction'; + } + + /** + * Add a restriction to a user. + */ + public function restrict(int $user_id, string $type, string $reason, string $scope = self::SCOPE_GLOBAL, string $expires_at = '', int $created_by = 0): array { + global $wpdb; + + if (!in_array($type, self::TYPES, true)) { + return ['success' => false, 'message' => 'Invalid restriction type.']; + } + if (!in_array($scope, self::SCOPES, true)) { + return ['success' => false, 'message' => 'Invalid scope.']; + } + if ($created_by <= 0) { + $created_by = get_current_user_id(); + } + + $data = [ + 'user_id' => $user_id, + 'type' => $type, + 'reason' => sanitize_textarea_field($reason), + 'scope' => $scope, + 'expires_at' => $expires_at ? gmdate('Y-m-d H:i:s', strtotime($expires_at)) : null, + 'created_by' => $created_by, + 'created_at' => current_time('mysql', true), + ]; + + $result = $wpdb->insert($this->table(), $data); + if ($result === false) { + return ['success' => false, 'message' => 'Database error.']; + } + + // Update user meta for quick lookup + $this->sync_user_restriction_meta($user_id); + + return ['success' => true, 'id' => $wpdb->insert_id]; + } + + /** + * Remove a restriction by ID. + */ + public function remove_restriction(int $restriction_id): bool { + global $wpdb; + $restriction = $this->get_restriction($restriction_id); + if (!$restriction) return false; + + $result = $wpdb->delete($this->table(), ['id' => $restriction_id]); + if ($result) { + $this->sync_user_restriction_meta((int) $restriction['user_id']); + } + return (bool) $result; + } + + /** + * Get a single restriction by ID. + */ + public function get_restriction(int $restriction_id): ?array { + global $wpdb; + $row = $wpdb->get_row($wpdb->prepare( + "SELECT * FROM {$this->table()} WHERE id = %d", + $restriction_id + ), ARRAY_A); + return $row ?: null; + } + + /** + * Get all active restrictions for a user. + */ + public function get_user_restrictions(int $user_id, bool $active_only = true): array { + global $wpdb; + $now = current_time('mysql', true); + + if ($active_only) { + $rows = $wpdb->get_results($wpdb->prepare( + "SELECT * FROM {$this->table()} WHERE user_id = %d AND (expires_at IS NULL OR expires_at > %s) ORDER BY created_at DESC", + $user_id, $now + ), ARRAY_A); + } else { + $rows = $wpdb->get_results($wpdb->prepare( + "SELECT * FROM {$this->table()} WHERE user_id = %d ORDER BY created_at DESC", + $user_id + ), ARRAY_A); + } + + return $rows ?: []; + } + + /** + * Check if a user has an active restriction of a given type and scope. + */ + public function is_restricted(int $user_id, string $type = '', string $scope = ''): bool { + $restrictions = $this->get_user_restrictions($user_id, true); + foreach ($restrictions as $r) { + if ($type && $r['type'] !== $type) continue; + if ($scope && $r['scope'] !== $scope && $r['scope'] !== self::SCOPE_GLOBAL) continue; + return true; + } + return false; + } + + /** + * Check if a user is banned (global). + */ + public function is_banned(int $user_id): bool { + return $this->is_restricted($user_id, self::TYPE_BAN, self::SCOPE_GLOBAL); + } + + /** + * Get the active ban restriction for a user (if any). + */ + public function get_active_ban(int $user_id): ?array { + $restrictions = $this->get_user_restrictions($user_id, true); + foreach ($restrictions as $r) { + if ($r['type'] === self::TYPE_BAN && $r['scope'] === self::SCOPE_GLOBAL) { + return $r; + } + } + return null; + } + + /** + * Enforce ban on login. + */ + public function enforce_login_ban($user, string $username, string $password) { + if (is_wp_error($user) || !$user instanceof WP_User) { + return $user; + } + if ($this->is_banned($user->ID)) { + $ban = $this->get_active_ban($user->ID); + $message = '您的账号已被封禁。'; + if (!empty($ban['reason'])) { + $message .= '原因:' . $ban['reason']; + } + return new WP_Error('user_banned', $message); + } + return $user; + } + + /** + * Enforce comment mute. + */ + public function enforce_comment_mute($post_id) { + $user_id = get_current_user_id(); + if ($user_id > 0 && $this->is_restricted($user_id, self::TYPE_MUTE, self::SCOPE_COMMENT)) { + wp_die('您已被限制评论。', '评论限制', ['response' => 403]); + } + return $post_id; + } + + /** + * Enforce forum mute. + */ + public function enforce_forum_mute($data) { + $user_id = get_current_user_id(); + if ($user_id > 0 && $this->is_restricted($user_id, self::TYPE_MUTE, self::SCOPE_FORUM)) { + return new WP_Error('forum_muted', '您已被限制在论坛发言。'); + } + return $data; + } + + /** + * Sync restriction meta for quick lookup. + */ + private function sync_user_restriction_meta(int $user_id): void { + $active = $this->get_user_restrictions($user_id, true); + $types = array_unique(array_column($active, 'type')); + update_user_meta($user_id, '_aksrt_active_restrictions', $types); + update_user_meta($user_id, '_aksrt_is_banned', in_array(self::TYPE_BAN, $types, true) ? 1 : 0); + } + + /** + * Get restriction stats for admin overview. + */ + public function get_stats(): array { + global $wpdb; + $now = current_time('mysql', true); + $table = $this->table(); + + $total = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table}"); + $active = (int) $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM {$table} WHERE expires_at IS NULL OR expires_at > %s", $now + )); + $banned = (int) $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM {$table} WHERE type = %s AND (expires_at IS NULL OR expires_at > %s)", + self::TYPE_BAN, $now + )); + + return ['total' => $total, 'active' => $active, 'banned' => $banned]; + } +} diff --git a/inc/classes/class-aksrt-user.php b/inc/classes/class-aksrt-user.php new file mode 100644 index 00000000..8019e347 --- /dev/null +++ b/inc/classes/class-aksrt-user.php @@ -0,0 +1,1263 @@ + [ + 'file' => 'templates/pages/page-login.php', + 'slug' => 'login', + 'title' => '登录', + 'fallback' => '/login/', + ], + 'register' => [ + 'file' => 'templates/pages/page-register.php', + 'slug' => 'register', + 'title' => '注册', + 'fallback' => '/register/', + ], + 'bind' => [ + 'file' => 'templates/pages/page-bind-account.php', + 'slug' => 'bind-account', + 'title' => '绑定账号', + 'fallback' => '/bind-account/', + ], + 'reset-password' => [ + 'file' => 'templates/pages/page-reset-password.php', + 'slug' => 'reset-password', + 'title' => '重置密码', + 'fallback' => '/reset-password/', + ], + 'verify-email' => [ + 'file' => 'templates/pages/page-verify-email.php', + 'slug' => 'verify-email', + 'title' => '邮箱验证', + 'fallback' => '/verify-email/', + ], + 'categories' => [ + 'file' => 'templates/pages/page-categories.php', + 'slug' => 'categories', + 'title' => '分类目录', + 'fallback' => '/categories/', + ], + 'friends' => [ + 'file' => 'templates/pages/page-friends.php', + 'slug' => 'friends', + 'title' => '友情链接', + 'fallback' => '/friends/', + ], + 'messages' => [ + 'file' => 'templates/pages/page-messages.php', + 'slug' => 'messages', + 'title' => '消息', + 'fallback' => '/messages/', + ], + 'profile' => [ + 'file' => 'templates/pages/page-profile.php', + 'slug' => 'profile', + 'title' => '个人中心', + 'fallback' => '/', + ], +]; + +// Zibll-style v2 routing keeps only true fixed template pages in the registry. +$_aksrt_special_templates = [ + 'sign' => [ + 'file' => 'templates/pages/page-sign.php', + 'slug' => 'sign', + 'title' => '登录/注册', + 'fallback' => '/sign/', + ], + 'bind' => [ + 'file' => 'templates/pages/page-bind-account.php', + 'slug' => 'bind-account', + 'title' => '绑定账号', + 'fallback' => '/bind-account/', + ], + 'reset-password' => [ + 'file' => 'templates/pages/page-reset-password.php', + 'slug' => 'reset-password', + 'title' => '重置密码', + 'fallback' => '/reset-password/', + ], + 'verify-email' => [ + 'file' => 'templates/pages/page-verify-email.php', + 'slug' => 'verify-email', + 'title' => '邮箱验证', + 'fallback' => '/verify-email/', + ], + 'categories' => [ + 'file' => 'templates/pages/page-categories.php', + 'slug' => 'categories', + 'title' => '分类目录', + 'fallback' => '/categories/', + ], + 'friends' => [ + 'file' => 'templates/pages/page-friends.php', + 'slug' => 'friends', + 'title' => '友情链接', + 'fallback' => '/friends/', + ], + 'messages' => [ + 'file' => 'templates/pages/page-messages.php', + 'slug' => 'messages', + 'title' => '消息', + 'fallback' => '/messages/', + ], + 'profile' => [ + 'file' => 'templates/pages/page-profile.php', + 'slug' => 'profile', + 'title' => '个人中心', + 'fallback' => '/profile/', + ], +]; + +/** @var array 缓存 key=模板文件路径 => value=WP_Post|null */ +$_aksrt_template_page_cache = []; + + +// ============================================================ +// 二、核心工具函数 +// ============================================================ + +/** + * 查找使用指定模板文件的页面(按模板匹配,自适应任意 slug) + * + * @param string $template_file 相对于主题的模板路径 + * @return WP_Post|null + */ +function aksrt_find_template_page(string $template_file) { + global $_aksrt_template_page_cache; + if (isset($_aksrt_template_page_cache[$template_file])) { + return $_aksrt_template_page_cache[$template_file]; + } + + $page = null; + $pages = get_pages([ + 'meta_key' => '_wp_page_template', + 'meta_value' => $template_file, + 'number' => 1, + 'post_status' => 'publish', + ]); + if (!empty($pages)) { + $page = $pages[0]; + } + + $_aksrt_template_page_cache[$template_file] = $page; + return $page; +} + +/** + * 获取使用指定模板的页面 URL + * + * @param string $key 注册表 key(当前仅 profile 使用) + * @return string 页面 URL 或回退地址 + */ +function aksrt_get_template_url(string $key): string { + global $_aksrt_special_templates; + if (!isset($_aksrt_special_templates[$key])) { + return home_url('/'); + } + + $cfg = $_aksrt_special_templates[$key]; + $page = aksrt_find_template_page($cfg['file']); + + if (!$page) { + aksrt_ensure_template_page($key); + $page = aksrt_find_template_page($cfg['file']); + } + + if ($page) { + return get_permalink($page->ID); + } + + return home_url($cfg['fallback']); +} + +/** + * 定位模板文件的绝对路径 + * + * @param string $template_file 相对于主题的路径 + * @return string|false 文件绝对路径或 false + */ +function aksrt_locate_template_file(string $template_file) { + $path = locate_template([$template_file]); + if ($path) return $path; + + $full = AKSRT_DIR . '/' . $template_file; + return file_exists($full) ? $full : false; +} + +/** + * Resolve a public-facing page template file from stored meta or page slug. + */ +function aksrt_resolve_page_template_file(string $template_file = '', string $post_slug = ''): string { + $template_file = ltrim($template_file, '/\\'); + $post_slug = sanitize_title($post_slug); + + $legacy_auth_templates = [ + 'templates/pages/page-login.php' => 'login', + 'templates/pages/page-register.php' => 'register', + 'templates/pages/page-bind-account.php' => 'bind', + 'templates/pages/page-reset-password.php' => 'reset-password', + 'templates/pages/page-verify-email.php' => 'verify-email', + ]; + + if ($template_file !== '') { + if ($template_file === 'templates/pages/page-sign.php') { + return aksrt_get_auth_template_file(aksrt_get_auth_tab(), true); + } + + if (!empty($legacy_auth_templates[$template_file])) { + return aksrt_get_auth_template_file((string) $legacy_auth_templates[$template_file], true); + } + + if (strpos($template_file, 'templates/pages/') === 0 && aksrt_locate_template_file($template_file)) { + return $template_file; + } + } + + if ($post_slug !== '') { + $auth_slug_map = [ + 'sign' => aksrt_get_auth_tab(), + 'login' => 'login', + 'register' => 'register', + 'bind-account' => 'bind', + 'reset-password' => 'reset-password', + 'verify-email' => 'verify-email', + ]; + + if (!empty($auth_slug_map[$post_slug])) { + return aksrt_get_auth_template_file((string) $auth_slug_map[$post_slug], true); + } + + $slug_map = [ + 'categories' => 'templates/pages/page-categories.php', + 'friends' => 'templates/pages/page-friends.php', + 'messages' => 'templates/pages/page-messages.php', + 'profile' => 'templates/pages/page-profile.php', + ]; + + if (!empty($slug_map[$post_slug]) && aksrt_locate_template_file($slug_map[$post_slug])) { + return $slug_map[$post_slug]; + } + } + + return ''; +} + +/** + * Normalize auth view keys used by the unified sign page. + */ +function aksrt_normalize_auth_tab(string $tab): string { + $tab = sanitize_key($tab); + + switch ($tab) { + case 'signin': + case 'login': + return 'login'; + case 'signup': + case 'register': + return 'register'; + case 'bind': + case 'bind-account': + return 'bind'; + case 'reset': + case 'lostpassword': + case 'reset-password': + return 'reset-password'; + case 'verify': + case 'verify-email': + return 'verify-email'; + default: + return 'login'; + } +} + +/** + * Whether an auth tab should be rendered by the unified sign page. + */ +function aksrt_auth_tab_uses_unified_sign_page(string $tab): bool { + return in_array( + aksrt_normalize_auth_tab($tab), + ['login', 'register'], + true + ); +} + +/** + * Resolve the auth template file, preferring the unified sign page when present. + */ +function aksrt_get_auth_template_file(string $tab = 'login', bool $prefer_sign_page = true): string { + $normalized_tab = aksrt_normalize_auth_tab($tab); + $sign_template = 'templates/pages/page-sign.php'; + + if ( + $prefer_sign_page + && aksrt_auth_tab_uses_unified_sign_page($normalized_tab) + && aksrt_locate_template_file($sign_template) + ) { + return $sign_template; + } + + switch ($normalized_tab) { + case 'register': + return 'templates/pages/page-register.php'; + case 'bind': + return 'templates/pages/page-bind-account.php'; + case 'reset-password': + return 'templates/pages/page-reset-password.php'; + case 'verify-email': + return 'templates/pages/page-verify-email.php'; + default: + return 'templates/pages/page-login.php'; + } +} + +/** + * Return the active auth view for sign routes and the sign page template. + */ +function aksrt_get_auth_tab(): string { + $query_tab = get_query_var('aksrt_auth_tab'); + if (is_string($query_tab) && $query_tab !== '') { + return aksrt_normalize_auth_tab($query_tab); + } + + if (!empty($_GET['tab'])) { + return aksrt_normalize_auth_tab((string) wp_unslash($_GET['tab'])); + } + + $post = get_queried_object(); + if ($post instanceof WP_Post) { + return aksrt_normalize_auth_tab((string) $post->post_name); + } + + return 'login'; +} + +/** + * 清除模板页面缓存 + */ +function aksrt_clear_template_page_cache(): void { + global $_aksrt_template_page_cache; + $_aksrt_template_page_cache = []; +} + +/** + * Prepare the public user-home template context and return its template path. + */ +function aksrt_get_user_home_template_path(): string { + global $_aksrt_special_templates, $aksrt_profile_user; + + $nicename = (string) get_query_var('user_nicename'); + if ($nicename === '') { + return ''; + } + + $user = get_user_by('slug', $nicename); + if (!$user instanceof WP_User) { + return ''; + } + + $template_path = aksrt_locate_template_file('templates/pages/page-user-home.php'); + + if (!$template_path) { + return ''; + } + + $aksrt_profile_user = $user; + return $template_path; +} + +/** + * Resolve the active special-page template path for the current request. + */ +function aksrt_get_current_special_template_path(): string { + if (get_query_var('user_nicename')) { + return aksrt_get_user_home_template_path(); + } + + if (get_query_var('aksrt_user_center')) { + $profile_template = aksrt_locate_template_file('templates/pages/page-profile.php'); + return $profile_template ?: ''; + } + + if (get_query_var('aksrt_sign')) { + $auth_template = aksrt_get_auth_template_file((string) get_query_var('aksrt_auth_tab'), true); + $auth_template_path = aksrt_locate_template_file($auth_template); + return $auth_template_path ?: ''; + } + + if (is_page()) { + $page_id = get_queried_object_id(); + if ($page_id > 0) { + $page_template = get_post_meta($page_id, '_wp_page_template', true); + $resolved_template = aksrt_resolve_page_template_file( + is_string($page_template) ? $page_template : '', + (string) get_post_field('post_name', $page_id) + ); + + if ($resolved_template !== '') { + $resolved_path = aksrt_locate_template_file($resolved_template); + if ($resolved_path) { + return $resolved_path; + } + } + } + } + + return ''; +} + +/** + * 根据当前请求路径识别系统模板页 key。 + * + * 用于前台直接访问固定 slug 时的自动补页。 + */ +function aksrt_get_requested_template_key(): string { + global $_aksrt_special_templates; + + $request_uri = isset($_SERVER['REQUEST_URI']) ? (string) $_SERVER['REQUEST_URI'] : ''; + if ($request_uri === '') { + return ''; + } + + $request_path = wp_parse_url($request_uri, PHP_URL_PATH); + if (!is_string($request_path) || $request_path === '') { + return ''; + } + + $home_path = (string) wp_parse_url(home_url('/'), PHP_URL_PATH); + if ($home_path !== '' && str_starts_with($request_path, $home_path)) { + $request_path = (string) substr($request_path, strlen($home_path)); + } + + $request_slug = trim($request_path, '/'); + if ($request_slug === '') { + return ''; + } + + foreach ($_aksrt_special_templates as $key => $cfg) { + $slug = isset($cfg['slug']) ? sanitize_title((string) $cfg['slug']) : ''; + if ($slug !== '' && $slug === $request_slug) { + return (string) $key; + } + } + + return ''; +} + +/** + * 确保使用指定模板的页面存在,缺失则自动创建 + * + * @param string $key 注册表 key + */ +function aksrt_ensure_template_page(string $key): void { + global $_aksrt_special_templates; + if (!isset($_aksrt_special_templates[$key])) return; + + $cfg = $_aksrt_special_templates[$key]; + $desired_slug = sanitize_title($cfg['slug'] ?? $key); + + // 已有页面使用此模板? + if (aksrt_find_template_page($cfg['file'])) return; + + if ($desired_slug !== '') { + $existing_page = get_page_by_path($desired_slug, OBJECT, 'page'); + if ($existing_page instanceof WP_Post) { + if ($existing_page->post_status !== 'publish') { + wp_update_post([ + 'ID' => $existing_page->ID, + 'post_status' => 'publish', + ]); + } + + update_post_meta($existing_page->ID, '_wp_page_template', $cfg['file']); + aksrt_clear_template_page_cache(); + return; + } + } + + // 自动创建 + $page_id = wp_insert_post([ + 'post_title' => $cfg['title'], + 'post_content' => '', + 'post_status' => 'publish', + 'post_type' => 'page', + 'post_name' => $desired_slug !== '' ? $desired_slug : $key, + 'comment_status' => 'closed', + 'ping_status' => 'closed', + ]); + + if (!is_wp_error($page_id) && $page_id > 0) { + update_post_meta($page_id, '_wp_page_template', $cfg['file']); + aksrt_clear_template_page_cache(); + } +} + +// ============================================================ +// 三、URL 路由与模板加载 +// ============================================================ + +// --- 3a. /user/{昵称} 自定义重写 --- +add_filter('query_vars', function ($vars) { + $vars[] = 'aksrt_user_center'; + $vars[] = 'user_nicename'; + return $vars; +}); + +add_action('template_redirect', function () { + if (is_admin() || wp_doing_ajax() || is_feed() || is_embed() || is_preview()) { + return; + } + + $resolved_path = aksrt_get_current_special_template_path(); + if ($resolved_path === '') { + return; + } + + status_header(200); + load_template($resolved_path, true); + exit; +}, 100); + +add_action('init', function () { + add_rewrite_tag('%aksrt_user_center%', '([^&]+)'); + add_rewrite_tag('%user_nicename%', '([^/]+)'); + add_rewrite_rule('^profile/?$', 'index.php?aksrt_user_center=1', 'top'); + add_rewrite_rule('^user/([^/]+)/?$', 'index.php?user_nicename=$matches[1]', 'top'); +}); + +// --- 3b. 统一模板拦截器(核心)--- +// 拦截所有使用主题特殊模板的页面请求,确保正确加载 +add_filter('template_include', function ($template) { + $resolved_path = aksrt_get_current_special_template_path(); + if ($resolved_path !== '') { + return $resolved_path; + } + + return $template; + + global $_aksrt_special_templates; + + // A) /user/{昵称} 路由 → 加载公开主页模板 + $nicename = get_query_var('user_nicename'); + if (!empty($nicename)) { + $user = get_user_by('slug', $nicename); + if (!$user) return $template; // 不存在的用户 → 默认 404 + + $tpl = aksrt_locate_template_file('templates/pages/page-user-home.php'); + if (!$tpl) { + // 兜底:如果新模板不存在,回退到旧 profile 模板 + $tpl = aksrt_locate_template_file($_aksrt_special_templates['profile']['file']); + } + if (!$tpl) return $template; + + global $aksrt_profile_user; + $aksrt_profile_user = $user; + return $tpl; + } + + // B) 普通页面 → 检测是否使用了我们的特殊模板,确保加载正确的模板文件 + // (防止 WP 在某些情况下未能正确解析自定义页面模板) + if (is_page()) { + $page_id = get_queried_object_id(); + if ($page_id) { + $page_template = get_post_meta($page_id, '_wp_page_template', true); + if ($page_template && strpos($page_template, 'templates/pages/') === 0) { + $tpl = aksrt_locate_template_file($page_template); + if ($tpl) return $tpl; + } + } + } + + return $template; +}, 99); + +add_filter('template_include', function ($template) { + $resolved_path = aksrt_get_current_special_template_path(); + if ($resolved_path !== '') { + return $resolved_path; + } + + if (get_query_var('aksrt_user_center')) { + $profile_template = aksrt_locate_template_file('templates/pages/page-profile.php'); + if ($profile_template) { + return $profile_template; + } + } + + if (is_page()) { + $page_id = get_queried_object_id(); + if ($page_id > 0) { + $page_template = get_post_meta($page_id, '_wp_page_template', true); + $resolved_template = aksrt_resolve_page_template_file( + is_string($page_template) ? $page_template : '', + (string) get_post_field('post_name', $page_id) + ); + + if ($resolved_template !== '') { + $resolved_path = aksrt_locate_template_file($resolved_template); + if ($resolved_path) { + return $resolved_path; + } + } + } + } + + return $template; +}, 100); + +// ============================================================ +// 四、自动维护 + flush +// ============================================================ +add_action('init', function () { + global $_aksrt_special_templates; + + // 后台/AJAX:检查并创建缺失页面 + $pages_bootstrapped = get_option('aksrt_template_pages_bootstrapped'); + $requested_template_key = (!is_admin() && !wp_doing_ajax()) ? aksrt_get_requested_template_key() : ''; + if (is_admin() || wp_doing_ajax() || $pages_bootstrapped !== AKSRT_VERSION) { + foreach ($_aksrt_special_templates as $key => $cfg) { + if ($key === 'profile') { + continue; + } + aksrt_ensure_template_page($key); + } + + if ($pages_bootstrapped !== AKSRT_VERSION) { + update_option('aksrt_template_pages_bootstrapped', AKSRT_VERSION); + } + } elseif ($requested_template_key !== '' && $requested_template_key !== 'profile') { + aksrt_ensure_template_page($requested_template_key); + } + + // 统一 flush 检查 + $rewrite_version = AKSRT_VERSION . ':sign-profile-routing-v1'; + $flushed = get_option('aksrt_user_rules_flushed'); + if ($flushed !== $rewrite_version) { + flush_rewrite_rules(false); + update_option('aksrt_user_rules_flushed', $rewrite_version); + } +}); + +// 页面变更时清缓存 +add_action('save_post', function ($pid) { if (get_post_type($pid) === 'page') aksrt_clear_template_page_cache(); }); +add_action('delete_post', function ($pid) { if (get_post_type($pid) === 'page') aksrt_clear_template_page_cache(); }); + +// ============================================================ +// 五、公开 API 函数(兼容旧代码调用) +// ============================================================ + +if (!function_exists('aksrt_get_user_center_url')) : +/** + * 获取本人中心 URL(私有空间入口) + * + * @param string $tab 可选 tab 参数 + * @return string + */ +function aksrt_get_user_center_url(string $tab = ''): string { + if (get_option('permalink_structure')) { + $url = home_url('/profile/'); + } else { + $url = add_query_arg('aksrt_user_center', '1', home_url('/')); + } + + if ($tab !== '') { + $url = add_query_arg('tab', $tab, $url); + } + return $url; +} +endif; + +if (!function_exists('aksrt_get_user_home_url')) : +/** + * 获取公开用户主页 URL(/user/{nicename}/) + * + * @param WP_User|int|string|null $user 用户对象、ID、nicename 或 null(当前用户) + * @return string + */ +function aksrt_get_user_home_url($user = null): string { + $slug = ''; + if ($user instanceof WP_User) { + $slug = $user->user_nicename; + } elseif (is_int($user) && $user > 0) { + $u = get_userdata($user); + $slug = $u ? $u->user_nicename : ''; + } elseif (is_string($user) && $user) { + $slug = $user; + } else { + $current = wp_get_current_user(); + $slug = $current->ID ? $current->user_nicename : ''; + } + if (!$slug) return home_url('/'); + return home_url('/user/' . trailingslashit($slug)); +} +endif; + +if (!function_exists('aksrt_is_user_center_page')) : +/** + * 判断当前是否为本人中心页面 + */ +function aksrt_is_user_center_page(): bool { + return (bool) get_query_var('aksrt_user_center') + || (is_page_template('templates/pages/page-profile.php') && !(bool) get_query_var('user_nicename')); +} +endif; + +if (!function_exists('aksrt_is_user_home_page')) : +/** + * 判断当前是否为公开用户主页 + */ +function aksrt_is_user_home_page(): bool { + return (bool) get_query_var('user_nicename'); +} +endif; + +if (!function_exists('aksrt_get_profile_url')) : +/** + * 兼容层:获取用户主页 URL + * + * @deprecated 使用 aksrt_get_user_center_url() 或 aksrt_get_user_home_url() 替代 + * @param WP_User|int|string|null $user + * @return string + */ +function aksrt_get_profile_url($user = null): string { + // 无参调用 → 当前用户主页(公开页) + if ($user === null) { + return aksrt_get_user_home_url(); + } + return aksrt_get_user_home_url($user); +} +endif; + +if (!function_exists('aksrt_get_sign_page_url')) : +/** + * Get the unified auth page URL, with legacy route fallback while the page self-heals. + * + * @param string $tab + * @param array $args + */ +function aksrt_get_sign_page_url(string $tab = 'login', array $args = []): string { + $tab = aksrt_normalize_auth_tab($tab); + + $legacy_fallbacks = [ + 'login' => home_url('/login/'), + 'register' => home_url('/register/'), + 'bind' => home_url('/bind-account/'), + 'reset-password' => home_url('/reset-password/'), + 'verify-email' => home_url('/verify-email/'), + ]; + + $page = aksrt_find_template_page('templates/pages/page-sign.php'); + if (!$page) { + aksrt_ensure_template_page('sign'); + $page = aksrt_find_template_page('templates/pages/page-sign.php'); + } + + if ($page instanceof WP_Post) { + $url = get_permalink($page->ID); + $args = ['tab' => $tab] + $args; + return add_query_arg($args, $url); + } + + $url = $legacy_fallbacks[$tab] ?? home_url('/login/'); + return !empty($args) ? add_query_arg($args, $url) : $url; +} + + +/** + * Determine whether the current request is served by the unified auth experience. + */ +function aksrt_is_auth_page_request(): bool { + return (bool) get_query_var('aksrt_sign') + || is_page_template('templates/pages/page-sign.php') + || is_page_template('templates/pages/page-login.php') + || is_page_template('templates/pages/page-register.php') + || is_page_template('templates/pages/page-bind-account.php') + || is_page_template('templates/pages/page-reset-password.php') + || is_page_template('templates/pages/page-verify-email.php'); +} +endif; + +if (!function_exists('aksrt_get_login_url')) : +/** 获取登录页 URL */ +function aksrt_get_login_url($redirect = '') { + $url = aksrt_get_sign_page_url('login'); + if ($redirect) { + $url = add_query_arg('redirect_to', urlencode($redirect), $url); + } + return $url; +} +endif; + +if (!function_exists('aksrt_get_register_url')) : +/** 获取注册页 URL */ +function aksrt_get_register_url() { + return aksrt_get_sign_page_url('register'); +} +endif; + +if (!function_exists('aksrt_get_bind_url')) : +/** 获取绑定账号页面 URL */ +function aksrt_get_bind_url($transient_key = '') { + $url = aksrt_get_template_url('bind'); + if ($transient_key) { + $url = add_query_arg('obk', $transient_key, $url); + } + return $url; +} +endif; + +if (!function_exists('aksrt_get_logout_url')) : +/** 获取退出登录 URL */ +function aksrt_get_logout_url($redirect = '') { + $target = $redirect ?: home_url('/'); + $url = add_query_arg('redirect_to', rawurlencode($target), home_url('/logout/')); + return wp_nonce_url($url, 'aksrt-logout'); +} +endif; + +if (!function_exists('aksrt_get_reset_password_url')) : +/** + * 获取重置密码页面 URL + * + * @param string $token 主题重置 token(可选,设置密码时需要) + * @param int $user_id 用户 ID(可选,设置密码时需要) + * @return string + */ +function aksrt_get_reset_password_url(string $token = '', int $user_id = 0): string { + $url = aksrt_get_template_url('reset-password'); + if ($token !== '' && $user_id > 0) { + $url = add_query_arg([ + 'token' => $token, + 'uid' => $user_id, + ], $url); + } + return $url; +} +endif; + +if (!function_exists('aksrt_get_verify_email_url')) : +/** 获取邮箱验证页面 URL */ +function aksrt_get_verify_email_url(int $user_id = 0, array $args = []): string { + $url = aksrt_get_template_url('verify-email'); + if ($user_id > 0) { + $args['uid'] = $user_id; + } + if (!empty($args)) { + $url = add_query_arg($args, $url); + } + return $url; +} +endif; + +require_once __DIR__ . '/class-aksrt-user-notifications.php'; + +// ============================================================ +// 六、作者链接改写 +// ============================================================ +add_filter('author_link', function ($link, $author_id) { + $user = get_userdata($author_id); + $slug = $user ? $user->user_nicename : 'user-' . $author_id; + return home_url('/user/' . trailingslashit($slug)); +}, 10, 2); + +add_action('template_redirect', function () { + if (!is_author()) return; + $author_id = get_queried_object_id(); + if ($author_id) { + $user = get_userdata($author_id); + $slug = $user ? $user->user_nicename : 'user-' . $author_id; + wp_safe_redirect(home_url('/user/' . trailingslashit($slug)), 301); + exit; + } +}); + +// ============================================================ +// AJAX: 保存用户个人资料(显示名称、简介、联系方式) +// ============================================================ +add_action('wp_ajax_aksrt_save_profile', function () { + // 验证 nonce + if ( ! isset( $_POST['_profile_nonce'] ) || ! wp_verify_nonce( $_POST['_profile_nonce'], 'aksrt_profile_save' ) ) { + wp_send_json_error( array( 'message' => __( '安全验证失败,请刷新页面重试', 'aksrt' ) ) ); + } + + $user_id = isset( $_POST['user_id'] ) ? (int) $_POST['user_id'] : 0; + if ( ! $user_id || $user_id !== get_current_user_id() ) { + wp_send_json_error( array( 'message' => __( '无权操作', 'aksrt' ) ) ); + } + + $errors = array(); + + // === 更新昵称(display_name) === + if ( isset( $_POST['nickname'] ) ) { + $name = sanitize_text_field( $_POST['nickname'] ); + if ( mb_strlen( $name ) < 1 || mb_strlen( $name ) > 50 ) { + $errors[] = __( '昵称长度需在 1-50 字之间', 'aksrt' ); + } else { + wp_update_user( array( 'ID' => $user_id, 'display_name' => $name ) ); + } + } + + // === 更新个人简介 === + if ( isset( $_POST['description'] ) ) { + $desc = sanitize_textarea_field( $_POST['description'] ); + if ( mb_strlen( $desc ) > 500 ) { + $errors[] = __( '个人简介不能超过 500 字', 'aksrt' ); + } else { + wp_update_user( array( 'ID' => $user_id, 'description' => $desc ) ); + } + } + + // === 更新联系方式 === + $contacts = array(); + if ( isset( $_POST['contacts'] ) && is_array( $_POST['contacts'] ) ) { + foreach ( $_POST['contacts'] as $c ) { + $url = isset( $c['url'] ) ? esc_url_raw( $c['url'] ) : ''; + $icon = isset( $c['icon'] ) ? sanitize_key( $c['icon'] ) : ''; + $icon = preg_replace('/^(fab|fas|far)[\s-]*/', '', $icon); + $icon = preg_replace('/^fa[\s-]*/', '', $icon); + $icon = preg_replace('/^[\s-]+/', '', $icon); + $icon = trim( $icon, '- ' ); + if ( ! $url || ! $icon ) continue; + $name = aksrt_icon_key_to_name( $icon ); + $contacts[] = array( 'name' => $name, 'url' => $url, 'icon' => $icon ); + } + } + update_user_meta( $user_id, 'aksrt_user_contacts', $contacts ); + + if ( ! empty( $errors ) ) { + wp_send_json_error( array( 'message' => implode( '
', $errors ) ) ); + } + + wp_send_json_success( array( 'message' => __( '保存成功!', 'aksrt' ) ) ); +}); + +// ============================================================ +// AJAX: 发送邮箱验证码(修改密码/更改邮箱通用) +// ============================================================ + +if (!function_exists('aksrt_get_email_code_key')) : +function aksrt_get_email_code_key(int $user_id, string $scene): string { + return 'aksrt_email_code_' . $scene . '_' . $user_id; +} +endif; + +if (!function_exists('aksrt_get_email_code_rate_key')) : +function aksrt_get_email_code_rate_key(int $user_id, string $scene, string $target = ''): string { + $suffix = $target !== '' ? '_' . md5(strtolower($target)) : ''; + return 'aksrt_email_code_rate_' . $scene . '_' . $user_id . $suffix; +} +endif; + +if (!function_exists('aksrt_store_email_code')) : +function aksrt_store_email_code(int $user_id, string $scene, string $code, string $target = ''): void { + set_transient( + aksrt_get_email_code_key($user_id, $scene), + [ + 'hash' => wp_hash_password($code), + 'target' => $target, + 'issued_at' => time(), + ], + 5 * MINUTE_IN_SECONDS + ); +} +endif; + +if (!function_exists('aksrt_consume_email_code')) : +function aksrt_consume_email_code(int $user_id, string $scene, string $code, string $target = ''): bool { + $key = aksrt_get_email_code_key($user_id, $scene); + $stored = get_transient($key); + if (!is_array($stored) || empty($stored['hash']) || !wp_check_password($code, $stored['hash'])) { + return false; + } + if ($target !== '' && strtolower((string) ($stored['target'] ?? '')) !== strtolower($target)) { + return false; + } + + delete_transient($key); + return true; +} + +endif; + +add_action('wp_ajax_aksrt_send_email_code', function () { + if ( ! isset( $_POST['_settings_nonce'] ) || ! wp_verify_nonce( $_POST['_settings_nonce'], 'aksrt_account_settings' ) ) { + wp_send_json_error( array( 'message' => __( '安全验证失败,请刷新页面重试', 'aksrt' ) ) ); + } + + if ( ! is_user_logged_in() ) { + wp_send_json_error( array( 'message' => __( '请先登录', 'aksrt' ) ) ); + } + + $scene = isset( $_POST['scene'] ) ? sanitize_key( (string) $_POST['scene'] ) : ''; + if ( ! in_array( $scene, array( 'change_password', 'change_email' ), true ) ) { + wp_send_json_error( array( 'message' => __( '无效的验证场景', 'aksrt' ) ) ); + } + + if ( aksrt_is_captcha_enabled_for( $scene ) ) { + $captcha_result = aksrt_verify_captcha( $_POST ); + if ( $captcha_result !== true ) { + wp_send_json_error( array( 'message' => $captcha_result ) ); + } + } + + $current_user = wp_get_current_user(); + $target_email = ''; + $success_message = ''; + + if ( $scene === 'change_password' ) { + $target_email = $current_user->user_email; + if ( empty( $target_email ) || ! is_email( $target_email ) ) { + wp_send_json_error( array( 'message' => __( '当前账号未绑定有效邮箱', 'aksrt' ) ) ); + } + $success_message = __( '验证码已发送到您的注册邮箱', 'aksrt' ); + } else { + $new_email = isset( $_POST['new_email'] ) ? trim( wp_unslash( $_POST['new_email'] ) ) : ''; + $current_password = isset( $_POST['current_password'] ) ? trim( wp_unslash( $_POST['current_password'] ) ) : ''; + + if ( empty( $current_password ) || ! wp_check_password( $current_password, $current_user->user_pass, $current_user->ID ) ) { + wp_send_json_error( array( 'message' => __( '当前密码不正确', 'aksrt' ) ) ); + } + if ( empty( $new_email ) || ! is_email( $new_email ) ) { + wp_send_json_error( array( 'message' => __( '请输入有效的新邮箱地址', 'aksrt' ) ) ); + } + if ( strtolower( $new_email ) === strtolower( $current_user->user_email ) ) { + wp_send_json_error( array( 'message' => __( '新邮箱与当前邮箱相同', 'aksrt' ) ) ); + } + if ( email_exists( $new_email ) ) { + wp_send_json_error( array( 'message' => __( '该邮箱已被其他账号使用', 'aksrt' ) ) ); + } + + $target_email = $new_email; + $success_message = __( '验证码已发送到新邮箱,请完成验证后再提交', 'aksrt' ); + } + + // 频率限制:60秒内不能重复发送 + $rate_key = aksrt_get_email_code_rate_key( $current_user->ID, $scene, $target_email ); + if ( get_transient( $rate_key ) ) { + wp_send_json_error( array( 'message' => __( '操作过于频繁,请稍后再试', 'aksrt' ) ) ); + } + set_transient( $rate_key, true, 60 ); + + // 生成验证码并同步发送,只有真正发信成功后才写入验证码缓存。 + $code = str_pad( (string) random_int( 0, 999999 ), 6, '0', STR_PAD_LEFT ); + + $site_name = get_bloginfo( 'name' ); + $default_subject = '[' . $site_name . '] ' . __( '邮箱验证码', 'aksrt' ); + $default_content = sprintf( + __( "您好 %s,\n\n您正在进行敏感操作验证,验证码为:\n\n【%s】\n\n验证码有效期为 5 分钟。如非本人操作,请忽略此邮件。", 'aksrt' ), + $current_user->display_name, + $code + ); + $sent = class_exists( 'AKSRT_Settings' ) + ? AKSRT_Settings::send_event_email( + 'change_code', $target_email, $default_subject, $default_content, + [ '{username}' => $current_user->display_name, '{site_name}' => $site_name, '{code}' => $code ] + ) + : wp_mail( $target_email, $default_subject, $default_content, [ 'Content-Type: text/plain; charset=UTF-8' ] ); + + if ( ! $sent ) { + delete_transient( $rate_key ); + error_log( '[AKSRT] 验证码邮件发送失败 user_id=' . $current_user->ID . ' email=' . $target_email . ' scene=' . $scene ); + wp_send_json_error( array( 'message' => __( '验证码邮件发送失败,请稍后重试', 'aksrt' ) ) ); + } + + aksrt_store_email_code( $current_user->ID, $scene, $code, $target_email ); + + wp_send_json_success( array( 'message' => $success_message ) ); +}); + +// ============================================================ +// AJAX: 修改密码 +// ============================================================ +add_action('wp_ajax_aksrt_change_password', function () { + if ( ! isset( $_POST['_settings_nonce'] ) || ! wp_verify_nonce( $_POST['_settings_nonce'], 'aksrt_account_settings' ) ) { + wp_send_json_error( array( 'message' => __( '安全验证失败,请刷新页面重试', 'aksrt' ) ) ); + } + + $user_id = get_current_user_id(); + if ( ! $user_id ) { + wp_send_json_error( array( 'message' => __( '请先登录', 'aksrt' ) ) ); + } + + $old_password = isset( $_POST['old_password'] ) ? trim( wp_unslash( $_POST['old_password'] ) ) : ''; + $new_password = isset( $_POST['new_password'] ) ? trim( wp_unslash( $_POST['new_password'] ) ) : ''; + $new_password_confirm = isset( $_POST['new_password_confirm'] ) ? trim( wp_unslash( $_POST['new_password_confirm'] ) ) : ''; + $email_code = isset( $_POST['email_code'] ) ? sanitize_text_field( $_POST['email_code'] ) : ''; + + // 验证原密码 + $user = get_userdata( $user_id ); + if ( ! $user || ! wp_check_password( $old_password, $user->data->user_pass, $user_id ) ) { + wp_send_json_error( array( 'message' => __( '原密码不正确', 'aksrt' ) ) ); + } + + // 验证新密码强度 + if ( mb_strlen( $new_password ) < 6 ) { + wp_send_json_error( array( 'message' => __( '新密码长度至少6位', 'aksrt' ) ) ); + } + if ( mb_strlen( $new_password ) > 128 ) { + wp_send_json_error( array( 'message' => __( '新密码过长', 'aksrt' ) ) ); + } + if ( $new_password !== $new_password_confirm ) { + wp_send_json_error( array( 'message' => __( '两次输入的新密码不一致', 'aksrt' ) ) ); + } + + // 人机验证 + if ( aksrt_is_captcha_enabled_for( 'change_password' ) ) { + $captcha_result = aksrt_verify_captcha( $_POST ); + if ( $captcha_result !== true ) { + wp_send_json_error( array( 'message' => $captcha_result ) ); + } + } + + // 验证邮箱验证码 + if ( ! aksrt_consume_email_code( $user_id, 'change_password', $email_code, $user->user_email ) ) { + wp_send_json_error( array( 'message' => __( '邮箱验证码错误或已过期', 'aksrt' ) ) ); + } + + // 设置新密码 + wp_set_password( $new_password, $user_id ); + + if ( class_exists( 'WP_Session_Tokens' ) ) { + WP_Session_Tokens::get_instance( $user_id )->destroy_all(); + } + + // 清除登录态 + wp_clear_auth_cookie(); + + wp_send_json_success( array( + 'message' => __( '密码修改成功,即将跳转到登录页…', 'aksrt' ), + 'redirect_to' => aksrt_get_login_url(), + )); +}); + +// ============================================================ +// AJAX: 更改邮箱 +// ============================================================ +add_action('wp_ajax_aksrt_change_email', function () { + if ( ! isset( $_POST['_settings_nonce'] ) || ! wp_verify_nonce( $_POST['_settings_nonce'], 'aksrt_account_settings' ) ) { + wp_send_json_error( array( 'message' => __( '安全验证失败,请刷新页面重试', 'aksrt' ) ) ); + } + + $user_id = get_current_user_id(); + if ( ! $user_id ) { + wp_send_json_error( array( 'message' => __( '请先登录', 'aksrt' ) ) ); + } + + $current_password = isset( $_POST['current_password'] ) ? trim( wp_unslash( $_POST['current_password'] ) ) : ''; + $new_email = isset( $_POST['new_email'] ) ? trim( wp_unslash( $_POST['new_email'] ) ) : ''; + $email_code = isset( $_POST['email_code'] ) ? sanitize_text_field( $_POST['email_code'] ) : ''; + + $current_user = wp_get_current_user(); + if ( empty( $current_password ) || ! wp_check_password( $current_password, $current_user->user_pass, $current_user->ID ) ) { + wp_send_json_error( array( 'message' => __( '当前密码不正确', 'aksrt' ) ) ); + } + + // 验证新邮箱格式 + if ( empty( $new_email ) || ! is_email( $new_email ) ) { + wp_send_json_error( array( 'message' => __( '请输入有效的邮箱地址', 'aksrt' ) ) ); + } + + if ( strtolower( $new_email ) === strtolower( $current_user->user_email ) ) { + wp_send_json_error( array( 'message' => __( '新邮箱与当前邮箱相同', 'aksrt' ) ) ); + } + + // 检查邮箱是否已被使用 + if ( email_exists( $new_email ) ) { + wp_send_json_error( array( 'message' => __( '该邮箱已被其他账号使用', 'aksrt' ) ) ); + } + + // 人机验证 + if ( aksrt_is_captcha_enabled_for( 'change_email' ) ) { + $captcha_result = aksrt_verify_captcha( $_POST ); + if ( $captcha_result !== true ) { + wp_send_json_error( array( 'message' => $captcha_result ) ); + } + } + + // 验证邮箱验证码 + if ( ! aksrt_consume_email_code( $user_id, 'change_email', $email_code, $new_email ) ) { + wp_send_json_error( array( 'message' => __( '邮箱验证码错误或已过期', 'aksrt' ) ) ); + } + + // 更新邮箱 + $old_email = $current_user->user_email; + $update_result = wp_update_user( [ + 'ID' => $user_id, + 'user_email' => $new_email, + ] ); + + if ( is_wp_error( $update_result ) ) { + wp_send_json_error( array( 'message' => $update_result->get_error_message() ?: __( '邮箱更新失败,请稍后重试', 'aksrt' ) ) ); + } + + if ( is_email( $old_email ) ) { + $site_name = get_bloginfo( 'name' ); + $default_subject = '[' . $site_name . '] ' . __( '账号邮箱已变更', 'aksrt' ); + $default_content = sprintf( + __( "您好 %s,\n\n您的账号邮箱已成功修改为:%s\n\n如果这不是您的操作,请立即联系站点管理员。", 'aksrt' ), + $current_user->display_name, + $new_email + ); + if ( class_exists( 'AKSRT_Settings' ) ) { + AKSRT_Settings::send_event_email( + 'change_notice', $old_email, $default_subject, $default_content, + [ '{username}' => $current_user->display_name, '{site_name}' => $site_name, '{new_email}' => $new_email ] + ); + } else { + wp_mail( $old_email, $default_subject, $default_content, [ 'Content-Type: text/plain; charset=UTF-8' ] ); + } + } + + // ====== 邮箱变更后重置验证状态(阶段 5)====== + // 1. 写入 14 天回滚窗口 meta(管理员可在此期内还原旧邮箱) + // 2. 把 aksrt_email_verified 重置为 0 + // 3. 若功能启用 → 立即给新邮箱发送验证邮件 + update_user_meta( $user_id, 'aksrt_email_previous', $old_email ); + update_user_meta( $user_id, 'aksrt_email_changed_at', time() ); + + $verify_url = ''; + $verify_message = ''; + if ( class_exists( 'AKSRT_Settings' ) && class_exists( 'AKSRT_Auth_Service' ) + && AKSRT_Settings::registration_requires_email_verification() ) { + update_user_meta( $user_id, 'aksrt_email_verified', 0 ); + // 用户对自身的 edit_user 权限映射到 'read',因此 admin_send_verification 会通过权限校验。 + $verify_result = AKSRT_Auth_Service::admin_send_verification( $user_id, $user_id ); + if ( ! empty( $verify_result['success'] ) ) { + $verify_url = function_exists('aksrt_get_verify_email_url') ? aksrt_get_verify_email_url( $user_id ) : ''; + $verify_message = __( '请前往新邮箱完成验证,否则下次登录将被拦截。', 'aksrt' ); + } + } + + if ( class_exists( 'WP_Session_Tokens' ) ) { + WP_Session_Tokens::get_instance( $user_id )->destroy_all(); + } + + // 清除登录态 + wp_clear_auth_cookie(); + + wp_send_json_success( array( + 'message' => $verify_message !== '' + ? __( '邮箱修改成功,请前往新邮箱完成验证后重新登录…', 'aksrt' ) + : __( '邮箱修改成功,即将跳转到登录页…', 'aksrt' ), + 'redirect_to' => $verify_url !== '' ? $verify_url : aksrt_get_login_url(), + 'verify_url' => $verify_url, + )); +}); diff --git a/inc/classes/class-aksrt-wechat-notify.php b/inc/classes/class-aksrt-wechat-notify.php new file mode 100644 index 00000000..8b1c1797 --- /dev/null +++ b/inc/classes/class-aksrt-wechat-notify.php @@ -0,0 +1,271 @@ + false]; + } + + /** + * 发送微信模板消息 + * + * @param int $user_id 用户 ID + * @param string $template 模板标识 + * @param array $data 模板数据 + * @return bool + */ + public function send(int $user_id, string $template, array $data = []): bool { + $config = $this->get_config(); + + if (empty($config['enabled'])) { + return false; + } + + // 获取用户的微信 openid + $openid = get_user_meta($user_id, 'aksrt_wechat_openid', true); + if (empty($openid)) { + return false; + } + + // 获取 access token + $access_token = $this->get_access_token($config); + if (empty($access_token)) { + if (defined('WP_DEBUG') && WP_DEBUG) { + error_log('[AKSRT WeChat] Failed to get access token'); + } + return false; + } + + // 构建模板消息 + $template_id = $config['template_id'] ?? ''; + if (empty($template_id)) { + if (defined('WP_DEBUG') && WP_DEBUG) { + error_log('[AKSRT WeChat] No template ID configured'); + } + return false; + } + + $message = $this->build_template_message($template, $data, $openid, $template_id); + + // 调用微信模板消息 API + $response = wp_remote_post('https://api.weixin.qq.com/cgi-bin/message/template/send?access_token=' . $access_token, [ + 'headers' => ['Content-Type' => 'application/json'], + 'body' => wp_json_encode($message), + 'timeout' => 10, + ]); + + if (is_wp_error($response)) { + if (defined('WP_DEBUG') && WP_DEBUG) { + error_log('[AKSRT WeChat] API error: ' . $response->get_error_message()); + } + return false; + } + + $body = json_decode(wp_remote_retrieve_body($response), true); + $errcode = (int) ($body['errcode'] ?? 0); + + if (defined('WP_DEBUG') && WP_DEBUG) { + error_log(sprintf('[AKSRT WeChat] User: %d, Template: %s, Result: %s', $user_id, $template, wp_json_encode($body))); + } + + return $errcode === 0; + } + + /** + * 获取微信 access token(带缓存) + */ + private function get_access_token(array $config): string { + $cache_key = 'aksrt_wechat_token'; + $cached = get_transient($cache_key); + if (!empty($cached)) return (string) $cached; + + $app_id = $config['app_id'] ?? ''; + $app_secret = $config['app_secret'] ?? ''; + if (empty($app_id) || empty($app_secret)) return ''; + + $response = wp_remote_get(sprintf( + 'https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s', + urlencode($app_id), + urlencode($app_secret) + )); + + if (is_wp_error($response)) return ''; + + $body = json_decode(wp_remote_retrieve_body($response), true); + $token = $body['access_token'] ?? ''; + $expires = max(600, (int) ($body['expires_in'] ?? 7200) - 300); + + if (!empty($token)) { + set_transient($cache_key, $token, $expires); + } + + return (string) $token; + } + + /** + * 构建模板消息体 + */ + private function build_template_message(string $template, array $data, string $openid, string $template_id): array { + $url = $data['url'] ?? home_url('/'); + $values = $data['values'] ?? []; + + $template_data = []; + foreach ($values as $key => $value) { + $template_data[$key] = [ + 'value' => (string) $value, + 'color' => '#333333', + ]; + } + + return [ + 'touser' => $openid, + 'template_id' => $template_id, + 'url' => esc_url($url), + 'data' => $template_data, + ]; + } + + /** + * 绑定用户微信 openid + */ + public function bind_user(int $user_id, string $openid): bool { + if ($user_id <= 0 || empty($openid)) return false; + return (bool) update_user_meta($user_id, 'aksrt_wechat_openid', sanitize_text_field($openid)); + } + + /** + * 解绑用户微信 + */ + public function unbind_user(int $user_id): bool { + return (bool) delete_user_meta($user_id, 'aksrt_wechat_openid'); + } + + /** + * 监听站内通知,联动微信通知 + */ + public function maybe_send_wechat(int $user_id, string $type, array $data): void { + $config = $this->get_config(); + if (empty($config['enabled'])) return; + + // 仅特定类型通知发送微信 + $allowed_types = ['comment_reply', 'system_notice', 'order_paid', 'credits_change', 'forum_reply']; + if (!in_array($type, $allowed_types, true)) return; + + $this->send($user_id, $type, $data); + } + + /** + * AJAX: Get WeChat notification status (admin only). + */ + public function ajax_status(): void { + if (!current_user_can('manage_options')) { + wp_send_json_error(['message' => __('权限不足', 'aksrt')]); + } + + wp_send_json_success($this->get_status()); + } + + /** + * Get WeChat service status (used by REST and AJAX). + */ + public function get_status(): array { + $config = $this->get_config(); + $has_token = false; + + if (!empty($config['app_id']) && !empty($config['app_secret'])) { + $cached = get_transient('aksrt_wechat_token'); + $has_token = !empty($cached); + } + + // Count bound users + global $wpdb; + $bound_count = 0; + if ($wpdb) { + $bound_count = (int) $wpdb->get_var( + "SELECT COUNT(*) FROM {$wpdb->usermeta} WHERE meta_key = 'aksrt_wechat_openid' AND meta_value != ''" + ); + } + + return [ + 'enabled' => !empty($config['enabled']), + 'appid' => $config['app_id'] ?? '', + 'configured' => !empty($config['app_id']) && !empty($config['app_secret']), + 'has_token' => $has_token, + 'has_template' => !empty($config['template_id']), + 'bound_users' => $bound_count, + 'app_secret' => !empty($config['app_secret']) ? '***' : '', + 'allowed_types' => ['comment_reply', 'system_notice', 'order_paid', 'credits_change', 'forum_reply'], + ]; + } + + /** + * Get notification subscriptions for a user. + */ + public function get_user_subscriptions(int $user_id): array { + $subs = get_user_meta($user_id, 'aksrt_wechat_subscriptions', true); + if (!is_array($subs)) { + $subs = [ + 'comment_reply' => true, + 'system_notice' => true, + 'order_paid' => true, + 'credits_change' => false, + 'forum_reply' => false, + ]; + } + return $subs; + } + + /** + * Update notification subscriptions for a user. + */ + public function update_user_subscriptions(int $user_id, array $subscriptions): bool { + $allowed = ['comment_reply', 'system_notice', 'order_paid', 'credits_change', 'forum_reply']; + $clean = []; + foreach ($allowed as $key) { + $clean[$key] = !empty($subscriptions[$key]); + } + return (bool) update_user_meta($user_id, 'aksrt_wechat_subscriptions', $clean); + } + + /** + * Check if a user should receive a specific notification type via WeChat. + */ + public function should_notify(int $user_id, string $type): bool { + $config = $this->get_config(); + if (empty($config['enabled'])) return false; + + $openid = get_user_meta($user_id, 'aksrt_wechat_openid', true); + if (empty($openid)) return false; + + $subs = $this->get_user_subscriptions($user_id); + return !empty($subs[$type]); + } +} diff --git a/inc/classes/class-aksrt-widgets-batch1.php b/inc/classes/class-aksrt-widgets-batch1.php new file mode 100644 index 00000000..22e558c8 --- /dev/null +++ b/inc/classes/class-aksrt-widgets-batch1.php @@ -0,0 +1,564 @@ + esc_html__('带样式的搜索框', 'aksrt'), + ]); + } + + public function widget($args, $instance): void { + echo $args['before_widget']; + $title = apply_filters('widget_title', $instance['title'] ?? '', $instance, $this->id_base); + if ($title) echo $args['before_title'] . esc_html($title) . $args['after_title']; + $placeholder = sanitize_text_field($instance['placeholder'] ?? __('搜索文章...', 'aksrt')); + ?> + + +

+ + +

+

+ + +

+ sanitize_text_field($new_instance['title'] ?? ''), + 'placeholder' => sanitize_text_field($new_instance['placeholder'] ?? ''), + ]; + } +} + +// ============================================================ +// 2. 公告通知小工具 +// ============================================================ + +class AKSRT_Notice_Widget extends WP_Widget { + + public function __construct() { + parent::__construct('aksrt_notice', esc_html__('AKSRT 公告', 'aksrt'), [ + 'description' => esc_html__('显示公告/通知信息', 'aksrt'), + ]); + } + + public function widget($args, $instance): void { + echo $args['before_widget']; + $title = apply_filters('widget_title', $instance['title'] ?? '', $instance, $this->id_base); + $content = wp_kses_post($instance['content'] ?? ''); + $style = sanitize_key($instance['style'] ?? 'info'); + $icon = sanitize_text_field($instance['icon'] ?? 'fas fa-bullhorn'); + + if ($title) echo $args['before_title'] . esc_html($title) . $args['after_title']; + ?> +
+ +
+ +
+
+ +

+ + +

+

+ + +

+

+ + +

+

+ + + +

+ sanitize_text_field($new_instance['title'] ?? ''), + 'content' => wp_kses_post($new_instance['content'] ?? ''), + 'style' => in_array($new_instance['style'] ?? '', ['info', 'success', 'warning', 'danger'], true) ? $new_instance['style'] : 'info', + 'icon' => sanitize_text_field($new_instance['icon'] ?? 'fas fa-bullhorn'), + ]; + } +} + +// ============================================================ +// 3. 最新评论小工具 +// ============================================================ + +class AKSRT_Recent_Comments_Widget extends WP_Widget { + + public function __construct() { + parent::__construct('aksrt_recent_comments', esc_html__('AKSRT 最新评论', 'aksrt'), [ + 'description' => esc_html__('显示最新评论列表', 'aksrt'), + ]); + } + + public function widget($args, $instance): void { + echo $args['before_widget']; + $title = apply_filters('widget_title', $instance['title'] ?? __('最新评论', 'aksrt'), $instance, $this->id_base); + $count = max(1, min(20, (int) ($instance['count'] ?? 5))); + $show_avatar = !empty($instance['show_avatar']); + + if ($title) echo $args['before_title'] . esc_html($title) . $args['after_title']; + + $comments = get_comments([ + 'number' => $count, + 'status' => 'approve', + 'post_status' => 'publish', + ]); + + if ($comments) : ?> +
+ comment_post_ID); + if (!$post) continue; + ?> +
+ +
+ +
+ +
+
+ comment_author); ?> + comment_date), current_time('timestamp'))); ?> +
+

+ comment_content, 15, '...')); ?> +

+ + ID)); ?> + +
+
+ +
+ +

+ +

+ + +

+

+ + +

+

+ +

+ sanitize_text_field($new_instance['title'] ?? ''), + 'count' => max(1, min(20, (int) ($new_instance['count'] ?? 5))), + 'show_avatar' => !empty($new_instance['show_avatar']), + ]; + } +} + +// ============================================================ +// 4. 友情链接小工具 +// ============================================================ + +class AKSRT_Friend_Links_Widget extends WP_Widget { + + public function __construct() { + parent::__construct('aksrt_friend_links', esc_html__('AKSRT 友情链接', 'aksrt'), [ + 'description' => esc_html__('显示友情链接列表', 'aksrt'), + ]); + } + + public function widget($args, $instance): void { + echo $args['before_widget']; + $title = apply_filters('widget_title', $instance['title'] ?? __('友情链接', 'aksrt'), $instance, $this->id_base); + $show_icon = !empty($instance['show_icon']); + $columns = max(1, min(4, (int) ($instance['columns'] ?? 2))); + + if ($title) echo $args['before_title'] . esc_html($title) . $args['after_title']; + + $links = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_friend_links() : []; + + if (!empty($links)) : ?> + + +

+ +

+ + +

+

+ + +

+

+ +

+

+ sanitize_text_field($new_instance['title'] ?? ''), + 'columns' => max(1, min(4, (int) ($new_instance['columns'] ?? 2))), + 'show_icon'=> !empty($new_instance['show_icon']), + ]; + } +} + +// ============================================================ +// 5. 图标卡片小工具 +// ============================================================ + +class AKSRT_Icon_Cards_Widget extends WP_Widget { + + public function __construct() { + parent::__construct('aksrt_icon_cards', esc_html__('AKSRT 图标卡片', 'aksrt'), [ + 'description' => esc_html__('显示图标+文字卡片网格', 'aksrt'), + ]); + } + + public function widget($args, $instance): void { + echo $args['before_widget']; + $title = apply_filters('widget_title', $instance['title'] ?? '', $instance, $this->id_base); + $cards = isset($instance['cards']) && is_array($instance['cards']) ? $instance['cards'] : []; + $columns = max(1, min(4, (int) ($instance['columns'] ?? 2))); + + if ($title) echo $args['before_title'] . esc_html($title) . $args['after_title']; + + if (!empty($cards)) : ?> +
+ + < class="widget-icon-cards__item"> + +
+ +
+
+ +
+ +
+ > + +
+ '', 'icon' => '', 'link' => '', 'description' => '']; + } + ?> +

+ + +

+

+ + +

+
+ $card) : ?> +
+

" value="" placeholder="">

+

" value="" placeholder="">

+

" value="" placeholder="">

+

" value="" placeholder="">

+
+ sanitize_text_field($card['title'] ?? ''), + 'icon' => sanitize_text_field($card['icon'] ?? ''), + 'link' => esc_url_raw($card['link'] ?? ''), + 'description' => sanitize_text_field($card['description'] ?? ''), + ]; + } + } + return [ + 'title' => sanitize_text_field($new_instance['title'] ?? ''), + 'columns' => max(1, min(4, (int) ($new_instance['columns'] ?? 2))), + 'cards' => $cards, + ]; + } +} + +// ============================================================ +// 6. 按钮组小工具 +// ============================================================ + +class AKSRT_Buttons_Widget extends WP_Widget { + + public function __construct() { + parent::__construct('aksrt_buttons', esc_html__('AKSRT 按钮组', 'aksrt'), [ + 'description' => esc_html__('显示一组自定义按钮', 'aksrt'), + ]); + } + + public function widget($args, $instance): void { + echo $args['before_widget']; + $title = apply_filters('widget_title', $instance['title'] ?? '', $instance, $this->id_base); + $buttons = isset($instance['buttons']) && is_array($instance['buttons']) ? $instance['buttons'] : []; + $layout = sanitize_key($instance['layout'] ?? 'vertical'); + + if ($title) echo $args['before_title'] . esc_html($title) . $args['after_title']; + + if (!empty($buttons)) : ?> +
+ + + + + + +
+ '', 'url' => '', 'style' => 'primary', 'icon' => '', 'new_window' => false]; + } + ?> +

+ + +

+

+ + +

+
+ $btn) : ?> +
+

+ " value="" placeholder=""> +

+

+ " value="" placeholder=""> +

+

+ +

+

+ " value="" placeholder=""> +

+

+ +

+
+ sanitize_text_field($btn['text'] ?? ''), + 'url' => esc_url_raw($btn['url'] ?? ''), + 'style' => $style, + 'icon' => sanitize_text_field($btn['icon'] ?? ''), + 'new_window' => !empty($btn['new_window']), + ]; + } + } + return [ + 'title' => sanitize_text_field($new_instance['title'] ?? ''), + 'layout' => in_array($new_instance['layout'] ?? '', ['vertical', 'horizontal'], true) ? $new_instance['layout'] : 'vertical', + 'buttons' => $buttons, + ]; + } +} + +// ============================================================ +// 7. 自定义 HTML / 间距小工具 +// ============================================================ + +class AKSRT_Custom_HTML_Gap_Widget extends WP_Widget { + + public function __construct() { + parent::__construct('aksrt_custom_html_gap', esc_html__('AKSRT 自定义HTML/间距', 'aksrt'), [ + 'description' => esc_html__('插入自定义 HTML 或间距', 'aksrt'), + ]); + } + + public function widget($args, $instance): void { + $mode = sanitize_key($instance['mode'] ?? 'html'); + + if ($mode === 'gap') { + $height = max(0, min(200, (int) ($instance['gap_height'] ?? 20))); + echo ''; + return; + } + + echo $args['before_widget']; + $title = apply_filters('widget_title', $instance['title'] ?? '', $instance, $this->id_base); + if ($title) echo $args['before_title'] . esc_html($title) . $args['after_title']; + echo '
' . wp_kses_post($instance['content'] ?? '') . '
'; + echo $args['after_widget']; + } + + public function form($instance): void { + $mode = sanitize_key($instance['mode'] ?? 'html'); + ?> +

+ + +

+

+ + +

+

+ + +

+

+ + +

+ in_array($new_instance['mode'] ?? '', ['html', 'gap'], true) ? $new_instance['mode'] : 'html', + 'title' => sanitize_text_field($new_instance['title'] ?? ''), + 'content' => wp_kses_post($new_instance['content'] ?? ''), + 'gap_height' => max(0, min(200, (int) ($new_instance['gap_height'] ?? 20))), + ]; + } +} diff --git a/inc/classes/class-aksrt-widgets-new.php b/inc/classes/class-aksrt-widgets-new.php new file mode 100644 index 00000000..08b5c689 --- /dev/null +++ b/inc/classes/class-aksrt-widgets-new.php @@ -0,0 +1,467 @@ + esc_html__('按浏览量/评论数/点赞数排序的热门文章列表', 'aksrt'), + 'customize_selective_refresh' => true, + ] + ); + } + + public function widget($args, $instance): void { + echo $args['before_widget']; + + $title = apply_filters('widget_title', $instance['title'] ?? __('热门文章', 'aksrt'), $instance, $this->id_base); + $count = absint($instance['count'] ?? 5); + $order_by = $instance['order_by'] ?? 'views'; + $days = absint($instance['days'] ?? 30); + + if ($title) { + echo $args['before_title'] . esc_html($title) . $args['after_title']; + } + + $query_args = [ + 'post_type' => 'post', + 'post_status' => 'publish', + 'posts_per_page' => $count, + 'ignore_sticky_posts' => true, + 'no_found_rows' => true, + ]; + + if ($days > 0 && $days < 365) { + $query_args['date_query'] = [['after' => $days . ' days ago']]; + } + + switch ($order_by) { + case 'comments': + $query_args['orderby'] = 'comment_count'; + $query_args['order'] = 'DESC'; + break; + case 'likes': + $query_args['meta_key'] = '_aksrt_like_count'; + $query_args['orderby'] = 'meta_value_num'; + $query_args['order'] = 'DESC'; + break; + case 'favorites': + $query_args['meta_key'] = '_aksrt_favorite_count'; + $query_args['orderby'] = 'meta_value_num'; + $query_args['order'] = 'DESC'; + break; + case 'downloads': + $query_args['meta_key'] = '_aksrt_res_download_count'; + $query_args['orderby'] = 'meta_value_num'; + $query_args['order'] = 'DESC'; + break; + case 'views': + default: + $query_args['meta_key'] = 'aksrt_post_views'; + $query_args['orderby'] = 'meta_value_num'; + $query_args['order'] = 'DESC'; + break; + } + + $query = new WP_Query($query_args); + + if ($query->have_posts()) { + echo '
'; + $index = 0; + while ($query->have_posts()) { + $query->the_post(); + $index++; + $rank_class = $index <= 3 ? ' rank-top' : ''; + $views = (int) get_post_meta(get_the_ID(), 'aksrt_post_views', true); + ?> +
+ +
+ +
+ + +
+
+
+ '; + wp_reset_postdata(); + } else { + echo '

' . esc_html__('暂无热门文章', 'aksrt') . '

'; + } + + echo $args['after_widget']; + } + + public function form($instance): void { + $title = $instance['title'] ?? __('热门文章', 'aksrt'); + $count = (int) ($instance['count'] ?? 5); + $order_by = $instance['order_by'] ?? 'views'; + $days = (int) ($instance['days'] ?? 30); + ?> +

+ + +

+

+ + +

+

+ + +

+

+ + +

+ sanitize_text_field($new_instance['title'] ?? ''), + 'count' => absint($new_instance['count'] ?? 5), + 'order_by' => in_array($new_instance['order_by'] ?? '', ['views', 'comments', 'likes', 'favorites', 'downloads'], true) ? $new_instance['order_by'] : 'views', + 'days' => absint($new_instance['days'] ?? 30), + ]; + } +} + + +// ============================================================ +// 2. 多栏目 Tab 切换文章列表小工具 +// ============================================================ + +class AKSRT_Tab_Posts_Widget extends WP_Widget { + + public function __construct() { + parent::__construct( + 'aksrt_tab_posts', + esc_html__('AKSRT 多栏目文章', 'aksrt'), + [ + 'description' => esc_html__('Tab 切换显示不同分类的文章列表', 'aksrt'), + 'customize_selective_refresh' => true, + ] + ); + } + + /** + * Normalize tabs from legacy comma-separated string or structured array. + * + * @param mixed $tabs Raw tabs data. + * @return array Normalized structured tabs. + */ + private function normalize_tabs($tabs): array { + // Legacy comma-separated format + if (is_string($tabs) && $tabs !== '') { + $legacy_keys = array_map('trim', explode(',', $tabs)); + $legacy_defs = [ + 'latest' => ['title' => '最新', 'orderby' => 'date'], + 'comments' => ['title' => '热评', 'orderby' => 'comments'], + 'random' => ['title' => '随机', 'orderby' => 'rand'], + 'views' => ['title' => '热门', 'orderby' => 'views'], + ]; + $result = []; + foreach ($legacy_keys as $key) { + if (isset($legacy_defs[$key])) { + $result[] = [ + 'title' => __($legacy_defs[$key]['title'], 'aksrt'), + 'category_id' => 0, + 'orderby' => $legacy_defs[$key]['orderby'], + 'count' => 0, + 'enabled' => true, + ]; + } elseif (is_numeric($key) && absint($key) > 0) { + $term = get_term(absint($key), 'category'); + $result[] = [ + 'title' => ($term && !is_wp_error($term)) ? $term->name : '', + 'category_id' => absint($key), + 'orderby' => 'date', + 'count' => 0, + 'enabled' => true, + ]; + } + } + return $result; + } + + // Structured array format + if (is_array($tabs)) { + $allowed_orderby = ['date', 'modified', 'views', 'likes', 'comments', 'favorites', 'downloads', 'rand']; + $result = []; + foreach ($tabs as $tab) { + if (!is_array($tab)) continue; + $orderby = sanitize_key($tab['orderby'] ?? 'date'); + if (!in_array($orderby, $allowed_orderby, true)) $orderby = 'date'; + $result[] = [ + 'title' => sanitize_text_field($tab['title'] ?? ''), + 'category_id' => absint($tab['category_id'] ?? 0), + 'orderby' => $orderby, + 'count' => max(0, min(20, (int) ($tab['count'] ?? 0))), + 'enabled' => !empty($tab['enabled']), + ]; + } + return $result; + } + + return []; + } + + /** + * Build WP_Query args for a tab's orderby. + */ + private function build_tab_query_args(array $tab, int $default_count): array { + $count = $tab['count'] > 0 ? $tab['count'] : $default_count; + $args = [ + 'post_type' => 'post', + 'post_status' => 'publish', + 'posts_per_page' => $count, + 'ignore_sticky_posts' => true, + 'no_found_rows' => true, + ]; + + if ($tab['category_id'] > 0) { + $args['cat'] = $tab['category_id']; + } + + switch ($tab['orderby']) { + case 'views': + $args['meta_key'] = 'aksrt_post_views'; + $args['orderby'] = 'meta_value_num'; + $args['order'] = 'DESC'; + break; + case 'likes': + $args['meta_key'] = '_aksrt_like_count'; + $args['orderby'] = 'meta_value_num'; + $args['order'] = 'DESC'; + break; + case 'favorites': + $args['meta_key'] = '_aksrt_favorite_count'; + $args['orderby'] = 'meta_value_num'; + $args['order'] = 'DESC'; + break; + case 'downloads': + $args['meta_key'] = '_aksrt_res_download_count'; + $args['orderby'] = 'meta_value_num'; + $args['order'] = 'DESC'; + break; + case 'comments': + $args['orderby'] = 'comment_count'; + $args['order'] = 'DESC'; + break; + case 'rand': + $args['orderby'] = 'rand'; + break; + case 'modified': + $args['orderby'] = 'modified'; + $args['order'] = 'DESC'; + break; + case 'date': + default: + $args['orderby'] = 'date'; + $args['order'] = 'DESC'; + break; + } + + return $args; + } + + public function widget($args, $instance): void { + echo $args['before_widget']; + + $title = apply_filters('widget_title', $instance['title'] ?? '', $instance, $this->id_base); + $count = absint($instance['count'] ?? 5); + $tabs = $this->normalize_tabs($instance['tabs'] ?? 'latest,comments,random'); + + // Filter enabled tabs + $tabs = array_filter($tabs, fn($t) => !empty($t['enabled'])); + if (empty($tabs)) { + echo $args['after_widget']; + return; + } + + if ($title) { + echo $args['before_title'] . esc_html($title) . $args['after_title']; + } + + $widget_id = $this->id; + $use_shared = function_exists('aksrt_render_mini_list'); + ?> +
+ +
    + $tab) : + $tab_key = 'tab-' . $i; + $tab_title = $tab['title'] !== '' ? $tab['title'] : __('Tab ' . ($i + 1), 'aksrt'); + ?> +
  • + +
  • + +
+ + + $tab) : + $tab_key = 'tab-' . $i; + $q = new WP_Query($this->build_tab_query_args($tab, $count)); + ?> +
> + have_posts()) : ?> + +
+ 'sidebar', 'show_thumb' => true, 'show_meta' => true], true); ?> +
+ + + + +

+ +
+ +
+ normalize_tabs($instance['tabs'] ?? 'latest,comments,random'); + $orderby_options = [ + 'date' => __('最新', 'aksrt'), + 'modified' => __('更新', 'aksrt'), + 'views' => __('浏览', 'aksrt'), + 'likes' => __('点赞', 'aksrt'), + 'comments' => __('评论', 'aksrt'), + 'favorites' => __('收藏', 'aksrt'), + 'downloads' => __('下载', 'aksrt'), + 'rand' => __('随机', 'aksrt'), + ]; + ?> +

+ + +

+

+ + +

+
+

+ '', 'category_id' => 0, 'orderby' => 'date', 'count' => 0, 'enabled' => false]; + } + foreach ($tabs as $i => $tab) : + ?> +
+

+ +

+

+ + " value="" placeholder=""> +

+

+ + " value="" placeholder="0"> +

+

+ + +

+

+ + " value=""> +

+
+ + " value=""> + sanitize_text_field($new_instance['title'] ?? ''), + 'count' => max(1, min(20, (int) ($new_instance['count'] ?? 5))), + ]; + + $tabs = []; + if (isset($new_instance['tabs']) && is_array($new_instance['tabs'])) { + $allowed_orderby = ['date', 'modified', 'views', 'likes', 'comments', 'favorites', 'downloads', 'rand']; + foreach ($new_instance['tabs'] as $tab) { + if (!is_array($tab)) continue; + $orderby = sanitize_key($tab['orderby'] ?? 'date'); + if (!in_array($orderby, $allowed_orderby, true)) $orderby = 'date'; + $tabs[] = [ + 'title' => sanitize_text_field($tab['title'] ?? ''), + 'category_id' => absint($tab['category_id'] ?? 0), + 'orderby' => $orderby, + 'count' => max(0, min(20, (int) ($tab['count'] ?? 0))), + 'enabled' => !empty($tab['enabled']), + ]; + } + } + + $result['tabs'] = !empty($tabs) ? $tabs : sanitize_text_field($new_instance['tabs'] ?? 'latest,comments,random'); + return $result; + } +} diff --git a/inc/classes/class-aksrt-widgets-original.php b/inc/classes/class-aksrt-widgets-original.php new file mode 100644 index 00000000..09c05829 --- /dev/null +++ b/inc/classes/class-aksrt-widgets-original.php @@ -0,0 +1,518 @@ + esc_html__('登录状态面板:未登录显示登录/注册按钮,已登录显示用户信息与快捷入口', 'aksrt'), + 'customize_selective_refresh' => true, + ] + ); + } + + /** + * 前端渲染 + */ + public function widget($args, $instance): void { + echo $args['before_widget']; + + $title = apply_filters('widget_title', $instance['title'] ?? '', $instance, $this->id_base); + if ($title) { + echo $args['before_title'] . esc_html($title) . $args['after_title']; + } + + // 判断登录状态 + if (is_user_logged_in()) { + $this->render_logged_in(); + } else { + $this->render_logged_out($instance); + } + + // 输出内联样式 & JS(仅在本 widget 实例加载时注入一次) + $this->inline_assets(); + + echo $args['after_widget']; + } + + // ========================================== + // 已登录状态渲染(Zibll 风格用户卡片) + // ========================================== + private function render_logged_in(): void { + $u = wp_get_current_user(); + $avatar = get_avatar_url($u->ID, ['size' => 160]); + $profile = aksrt_get_user_center_url(); + $logout = aksrt_get_logout_url(home_url('/')); + + // 封面图:用户在用户中心上传的背景图 > 默认背景图 + $cover = get_user_meta($u->ID, 'aksrt_background_image', true); + if (! $cover) { + $cover = get_theme_file_uri('assets/img/person_background.png'); + } + ?> +
+
+ > +
+ +
+
+ > +
+ +
+
display_name); ?>
+
user_email); ?>
+
+ + +
+
+ 默认 + $cover = ! empty($instance['cover']) ? $instance['cover'] : get_theme_file_uri('assets/img/widgets-original.png'); + $avatar = get_avatar_url(0, ['size' => 160, 'default' => 'mystery']); + + $oauth_html = ''; + if (function_exists('AKSRT_OAuth') && method_exists(AKSRT_OAuth::get_instance(), 'get_login_buttons')) { + $oauth_html = AKSRT_OAuth::get_instance()->get_login_buttons(); + } + ?> +
+
+ + > + +
+ +
+
+ > +
+ +
+
+
+ +
+ + + + +
+ + +
+
+ +
+ +
+
+ + + +

+ + +

+

+ + +

+

+ +

+ sanitize_text_field($new_instance['title'] ?? ''), + 'cover' => esc_url_raw($new_instance['cover'] ?? ''), + ]; + } +} + + +// ============================================================ +// 2. 分类目录小工具 +// ============================================================ + +class AKSRT_Categories_Widget extends WP_Widget { + + public function __construct() { + parent::__construct( + 'aksrt_categories', + esc_html__('AKSRT 分类目录', 'aksrt'), + array( + 'description' => esc_html__('以列表形式展示文章分类', 'aksrt'), + 'customize_selective_refresh' => true, + ) + ); + } + + public function widget($args, $instance) { + echo $args['before_widget']; + + $title = apply_filters('widget_title', $instance['title'], $instance, $this->id_base); + + if ($title): ?> +

+ $count, 'orderby' => 'count', 'order' => 'DESC')); + ?> + + +

+ + +

+

+ + +

+ sanitize_text_field($new['title']), + 'count' => absint($new['count']), + ]; + } +} + + +// ============================================================ +// 3. 标签云小工具 +// ============================================================ + +class AKSRT_Tag_Cloud_Widget extends WP_Widget { + + public function __construct() { + parent::__construct( + 'aksrt_tag_cloud', + esc_html__('AKSRT 标签云', 'aksrt'), + array( + 'description' => esc_html__('展示文章标签云', 'aksrt'), + 'customize_selective_refresh' => true, + ) + ); + } + + public function widget($args, $instance) { + echo $args['before_widget']; + + $title = apply_filters('widget_title', $instance['title'], $instance, $this->id_base); + $count = absint($instance['count'] ?: 15); + + if ($title): ?> +

+ + +
+ $count, 'orderby' => 'count', 'order' => 'DESC']); + foreach ($tags as $tag): + $size = 12 + min(4, (int)($tag->count / 5)); + ?> + #name); ?> + +
+ +

+ + +

+

+ + +

+ sanitize_text_field($new['title']), + 'count' => absint($new['count']), + ]; + } +} + + +// 注册函数已迁移至 class-aksrt-widgets.php(注册中心) diff --git a/inc/classes/class-aksrt-widgets.php b/inc/classes/class-aksrt-widgets.php new file mode 100644 index 00000000..d680b7aa --- /dev/null +++ b/inc/classes/class-aksrt-widgets.php @@ -0,0 +1,726 @@ + + */ + function aksrt_widget_sidebar_area_ids(): array { + return [ + 'home_sidebar', + 'sidebar-blog', + 'sidebar-article', + 'all_sidebar_top', + 'all_sidebar_bottom', + 'cat_sidebar', + 'tag_sidebar', + 'search_sidebar', + ]; + } +} + +if (!function_exists('aksrt_widget_archive_area_ids')) { + /** + * Widget zones for archive pages (cat/tag/search). + * + * @return array + */ + function aksrt_widget_archive_area_ids(): array { + return [ + 'cat_top_fluid', + 'cat_top_content', + 'cat_bottom_content', + 'cat_bottom_fluid', + 'cat_sidebar', + 'tag_top_fluid', + 'tag_top_content', + 'tag_bottom_content', + 'tag_bottom_fluid', + 'tag_sidebar', + 'search_top_fluid', + 'search_top_content', + 'search_bottom_content', + 'search_bottom_fluid', + 'search_sidebar', + ]; + } +} + +if (!function_exists('aksrt_widget_homepage_area_ids')) { + /** + * Homepage widget zones for homepage-only widgets. + * + * @return array + */ + function aksrt_widget_homepage_area_ids(): array { + return [ + 'home_top_fluid', + 'home_top_content', + 'home_bottom_content', + 'home_bottom_fluid', + ]; + } +} + +if (!function_exists('aksrt_widget_single_area_ids')) { + /** + * Single post/page widget zones. + * + * @return array + */ + function aksrt_widget_single_area_ids(): array { + return [ + 'single_top_fluid', + 'single_top_content', + 'single_bottom_content', + 'single_bottom_fluid', + ]; + } +} + +if (!function_exists('aksrt_widget_base_from_id')) { + /** + * Get widget base id from a stored widget instance id. + */ + function aksrt_widget_base_from_id(string $widget_id): string { + return preg_replace('/-\d+$/', '', $widget_id) ?: $widget_id; + } +} + +if (!function_exists('aksrt_widget_allowed_sidebars')) { + /** + * Allowed sidebar ids for a widget base id. + * + * @return array + */ + function aksrt_widget_allowed_sidebars(string $widget_base_id): array { + $sidebar_only = [ + 'aksrt_about', + 'aksrt_categories', + 'aksrt_tag_cloud', + 'aksrt_hot_posts', + ]; + $homepage_only = [ + 'aksrt_category_recommend', + ]; + $hybrid = [ + 'aksrt_tab_posts', + 'aksrt_category_block', + 'aksrt_category_tabs', + ]; + + if (in_array($widget_base_id, $sidebar_only, true)) { + return aksrt_widget_sidebar_area_ids(); + } + + if (in_array($widget_base_id, $homepage_only, true)) { + return aksrt_widget_homepage_area_ids(); + } + + if (in_array($widget_base_id, $hybrid, true)) { + return array_values(array_unique(array_merge( + aksrt_widget_sidebar_area_ids(), + aksrt_widget_homepage_area_ids() + ))); + } + + return []; + } +} + +if (!function_exists('aksrt_widget_is_allowed_in_sidebar')) { + /** + * Whether a widget base id can be assigned to the given sidebar id. + */ + function aksrt_widget_is_allowed_in_sidebar(string $widget_base_id, string $sidebar_id): bool { + if ($widget_base_id === '' || $sidebar_id === '') { + return true; + } + + if ($sidebar_id === 'wp_inactive_widgets' || strpos($sidebar_id, 'orphaned_widgets') === 0) { + return true; + } + + $allowed = aksrt_widget_allowed_sidebars($widget_base_id); + if (empty($allowed)) { + return true; + } + + return in_array($sidebar_id, $allowed, true); + } +} + +if (!function_exists('aksrt_widget_get_area_context')) { + /** + * Resolve widget area context from sidebar args. + */ + function aksrt_widget_get_area_context(array $args): string { + $sidebar_id = isset($args['id']) ? (string) $args['id'] : ''; + + if (in_array($sidebar_id, aksrt_widget_sidebar_area_ids(), true)) { + return 'sidebar'; + } + + if (in_array($sidebar_id, aksrt_widget_homepage_area_ids(), true)) { + return 'homepage'; + } + + if (in_array($sidebar_id, aksrt_widget_archive_area_ids(), true)) { + return 'archive'; + } + + if (in_array($sidebar_id, aksrt_widget_single_area_ids(), true)) { + return 'single'; + } + + return 'other'; + } +} + +// ============================================================ +// Shared Widget Form Helper — 公共表单字段与显示规则 +// ============================================================ + +if (!function_exists('aksrt_widget_common_defaults')) { + /** + * Default values for shared widget instance fields. + * + * @return array + */ + function aksrt_widget_common_defaults(): array { + return [ + 'subtitle' => '', + 'title_link' => '', + 'show_type' => 'all', + 'show_id_type' => '', + 'show_ids' => '', + 'sidebar_affix' => false, + 'animation_in' => '', + 'animation_repeat'=> false, + ]; + } +} + +if (!function_exists('aksrt_widget_normalize_common')) { + /** + * Normalize common widget instance fields, filling defaults for legacy instances. + * + * @param array $instance + * @return array + */ + function aksrt_widget_normalize_common(array $instance): array { + $defaults = aksrt_widget_common_defaults(); + $result = array_merge($defaults, $instance); + + $result['subtitle'] = sanitize_text_field($result['subtitle'] ?? ''); + $result['title_link'] = esc_url_raw($result['title_link'] ?? ''); + $result['show_type'] = in_array($result['show_type'] ?? '', ['all', 'pc', 'mobile'], true) ? $result['show_type'] : 'all'; + $result['show_id_type'] = in_array($result['show_id_type'] ?? '', ['', 'include', 'exclude'], true) ? $result['show_id_type'] : ''; + $result['show_ids'] = sanitize_text_field($result['show_ids'] ?? ''); + $result['sidebar_affix'] = !empty($result['sidebar_affix']); + $result['animation_in'] = sanitize_key($result['animation_in'] ?? ''); + $result['animation_repeat']= !empty($result['animation_repeat']); + + return $result; + } +} + +if (!function_exists('aksrt_widget_render_common_form')) { + /** + * Render shared widget admin form fields. + * + * Call this from your widget's form() method after rendering custom fields. + * + * @param array $instance Current widget instance. + */ + function aksrt_widget_render_common_form(array $instance): void { + $instance = aksrt_widget_normalize_common($instance); + ?> +
+

+ +

+
+ +

+ +

+
+ +

+ +

+
+ +

+ +

+ +

+ +

+
+ +

+ +

+ +

+ $new_instance + * @param array $old_instance + * @return array + */ + function aksrt_widget_update_common(array $new_instance, array $old_instance): array { + $merged = array_merge($old_instance, $new_instance); + return aksrt_widget_normalize_common($merged); + } +} + +if (!function_exists('aksrt_widget_render_common_before')) { + /** + * Render common wrapper attributes and classes before widget content. + * + * Outputs CSS classes for animation, affix, show_type, etc. + * Call this at the start of widget() before rendering content. + * + * @param array $args Sidebar args. + * @param array $instance Widget instance. + * @return array Modified args with extra classes. + */ + function aksrt_widget_get_common_wrapper_classes(array $args, array $instance): array { + $instance = aksrt_widget_normalize_common($instance); + $classes = []; + + // Show type: PC or mobile + if ($instance['show_type'] === 'pc') { + $classes[] = 'aksrt-widget--pc-only'; + } elseif ($instance['show_type'] === 'mobile') { + $classes[] = 'aksrt-widget--mobile-only'; + } + + // Sidebar affix + if ($instance['sidebar_affix']) { + $classes[] = 'aksrt-widget--affix'; + } + + // Animation + if ($instance['animation_in'] !== '') { + $classes[] = 'aksrt-widget--animated'; + $classes[] = 'aksrt-anim-' . $instance['animation_in']; + if ($instance['animation_repeat']) { + $classes[] = 'aksrt-anim-repeat'; + } + } + + return $classes; + } +} + +if (!function_exists('aksrt_widget_should_display_by_ids')) { + /** + * Check if the current post/page should display the widget based on show_id_type and show_ids. + * + * @param array $instance Widget instance. + * @return bool + */ + function aksrt_widget_should_display_by_ids(array $instance): bool { + $instance = aksrt_widget_normalize_common($instance); + + if ($instance['show_id_type'] === '' || $instance['show_ids'] === '') { + return true; + } + + $current_id = get_the_ID(); + if (!$current_id) { + return true; // Not on a singular page, allow display + } + + $ids = array_filter(array_map('absint', explode(',', $instance['show_ids']))); + + if ($instance['show_id_type'] === 'include') { + return in_array($current_id, $ids, true); + } + + if ($instance['show_id_type'] === 'exclude') { + return !in_array($current_id, $ids, true); + } + + return true; + } +} + +if (!function_exists('aksrt_widget_render_location_help')) { + /** + * Render placement hint inside widget admin forms. + */ + function aksrt_widget_render_location_help(string $scope): void { + $messages = [ + 'sidebar' => __('仅限侧栏区域:首页侧栏、博客侧栏、文章侧栏、全站侧栏、分类/标签/搜索侧栏。', 'aksrt'), + 'homepage' => __('仅限首页挂载区域:首页顶部/底部全宽、首页内容上方/下方。', 'aksrt'), + 'hybrid' => __('支持侧栏、首页和归档页挂载区域。', 'aksrt'), + 'archive' => __('仅限归档页挂载区域:分类页、标签页、搜索页的顶部/底部/侧栏。', 'aksrt'), + ]; + + if (empty($messages[$scope])) { + return; + } + + printf( + '

%s

', + esc_html($messages[$scope]) + ); + } +} + +if (!function_exists('aksrt_filter_widget_display_by_area')) { + /** + * Prevent widgets from rendering outside their allowed areas, + * and apply per-widget display rules (show_type, show_id_type/show_ids). + * + * @param array|false $instance Widget instance. + * @param WP_Widget $widget Widget object. + * @param array $args Sidebar args. + * @return array|false + */ + function aksrt_filter_widget_display_by_area($instance, WP_Widget $widget, array $args) { + $sidebar_id = isset($args['id']) ? (string) $args['id'] : ''; + $widget_base_id = isset($widget->id_base) ? (string) $widget->id_base : ''; + + if ($instance === false) { + return false; + } + + // Layer 1: Area legality check + if (!aksrt_widget_is_allowed_in_sidebar($widget_base_id, $sidebar_id)) { + return false; + } + + // Layer 2: Display rule check (show_type for PC/mobile) + if (is_array($instance) && function_exists('aksrt_widget_normalize_common')) { + $normalized = aksrt_widget_normalize_common($instance); + + // Show type: PC / mobile filter + if ($normalized['show_type'] === 'pc' && wp_is_mobile()) { + return false; + } + if ($normalized['show_type'] === 'mobile' && !wp_is_mobile()) { + return false; + } + + // Show IDs: whitelist / blacklist filter + if (!aksrt_widget_should_display_by_ids($normalized)) { + return false; + } + } + + return $instance; + } +} +add_filter('widget_display_callback', 'aksrt_filter_widget_display_by_area', 10, 3); + +if (!function_exists('aksrt_filter_widget_sidebar_assignments')) { + /** + * Move disallowed widget assignments back to the inactive widgets bucket. + * + * @param mixed $new_value New sidebars_widgets option value. + * @param mixed $old_value Previous option value. + * @return mixed + */ + function aksrt_filter_widget_sidebar_assignments($new_value, $old_value) { + if (!is_array($new_value)) { + return $new_value; + } + + $removed = []; + + foreach ($new_value as $sidebar_id => $widget_ids) { + if (!is_array($widget_ids) || $sidebar_id === 'wp_inactive_widgets') { + continue; + } + + $filtered = []; + foreach ($widget_ids as $widget_id) { + if (!is_string($widget_id) || $widget_id === '') { + continue; + } + + $widget_base_id = aksrt_widget_base_from_id($widget_id); + if (aksrt_widget_is_allowed_in_sidebar($widget_base_id, (string) $sidebar_id)) { + $filtered[] = $widget_id; + } else { + $removed[] = $widget_id; + } + } + + $new_value[$sidebar_id] = array_values($filtered); + } + + if (empty($removed)) { + return $new_value; + } + + if (!isset($new_value['wp_inactive_widgets']) || !is_array($new_value['wp_inactive_widgets'])) { + $new_value['wp_inactive_widgets'] = []; + } + + $assigned = []; + foreach ($new_value as $widget_ids) { + if (!is_array($widget_ids)) { + continue; + } + + foreach ($widget_ids as $widget_id) { + if (is_string($widget_id) && $widget_id !== '') { + $assigned[$widget_id] = true; + } + } + } + + foreach (array_values(array_unique($removed)) as $widget_id) { + if (!isset($assigned[$widget_id]) && !in_array($widget_id, $new_value['wp_inactive_widgets'], true)) { + $new_value['wp_inactive_widgets'][] = $widget_id; + } + } + + return $new_value; + } +} +add_filter('pre_update_option_sidebars_widgets', 'aksrt_filter_widget_sidebar_assignments', 10, 2); + +/** + * Ensure widget instance config exists. + */ +function aksrt_ensure_widget_instance(string $option_name, int $number, array $defaults): void { + $instances = get_option($option_name, []); + + if (!is_array($instances)) { + $instances = []; + } + + if (!isset($instances['_multiwidget'])) { + $instances['_multiwidget'] = 1; + } + + if (empty($instances[$number]) || !is_array($instances[$number])) { + $instances[$number] = $defaults; + update_option($option_name, $instances); + } +} + +/** + * Repair the sidebar-blog structure and seed default sidebar widgets on first install. + */ +function aksrt_maybe_repair_sidebar_blog_widgets(): void { + $sidebars = get_option('sidebars_widgets', []); + $seeded = (bool) get_option('aksrt_sidebar_blog_seeded', false); + + if (!is_array($sidebars)) { + $sidebars = []; + } + + $current = $sidebars['sidebar-blog'] ?? null; + $needs_repair = false; + $normalized = []; + + if (is_array($current)) { + foreach ($current as $item) { + if (is_array($item)) { + $needs_repair = true; + } + + if (is_string($item) && $item !== '') { + $normalized[] = $item; + } + } + } + + if ($needs_repair) { + $sidebars['sidebar-blog'] = array_values($normalized); + update_option('sidebars_widgets', $sidebars); + update_option('aksrt_sidebar_blog_seeded', true); + return; + } + + if ($current !== null) { + if (!$seeded) { + update_option('aksrt_sidebar_blog_seeded', true); + } + return; + } + + if ($seeded) { + return; + } + + aksrt_ensure_widget_instance('widget_aksrt_about', 2, [ + 'title' => '', + ]); + aksrt_ensure_widget_instance('widget_aksrt_categories', 2, [ + 'title' => __('分类目录', 'aksrt'), + 'count' => 8, + ]); + aksrt_ensure_widget_instance('widget_aksrt_tag_cloud', 2, [ + 'title' => __('标签云', 'aksrt'), + 'count' => 15, + ]); + + $sidebars['sidebar-blog'] = [ + 'aksrt_about-2', + 'aksrt_categories-2', + 'aksrt_tag_cloud-2', + ]; + update_option('sidebars_widgets', $sidebars); + update_option('aksrt_sidebar_blog_seeded', true); +} + +/** + * Register a block editor collection for AKSRT widgets. + */ +add_action('enqueue_block_editor_assets', function () { + $screen = get_current_screen(); + if (!$screen || $screen->id !== 'widgets') { + return; + } + + wp_add_inline_script('wp-blocks', ' + document.addEventListener("DOMContentLoaded", function() { + if (typeof wp !== "undefined" && wp.blocks && wp.blocks.registerBlockCollection) { + wp.blocks.registerBlockCollection("aksrt", { + title: "AKSRT 主题小工具", + icon: undefined + }); + } + }); + '); +}); + +/** + * Assign AKSRT widgets to the AKSRT block category in the widgets editor. + * + * @param array $metadata Block metadata. + * @return array + */ +add_filter('block_type_metadata', function ($metadata) { + $aksrt_widgets = [ + 'aksrt_about', + 'aksrt_categories', + 'aksrt_tag_cloud', + ]; + + if (!empty($metadata['name']) && in_array($metadata['name'], $aksrt_widgets, true)) { + $metadata['category'] = 'aksrt'; + } + + return $metadata; +}); + +/** + * Show widget placement hints in the admin form. + */ +add_action('in_widget_form', function (WP_Widget $widget): void { + $map = [ + 'aksrt_about' => 'sidebar', + 'aksrt_categories' => 'sidebar', + 'aksrt_tag_cloud' => 'sidebar', + 'aksrt_hot_posts' => 'sidebar', + 'aksrt_tab_posts' => 'hybrid', + 'aksrt_category_block' => 'hybrid', + 'aksrt_category_tabs' => 'hybrid', + 'aksrt_category_recommend' => 'homepage', + ]; + + $scope = $map[$widget->id_base] ?? ''; + if ($scope !== '') { + aksrt_widget_render_location_help($scope); + } +}, 10, 1); diff --git a/inc/classes/class-aksrt-withdrawal.php b/inc/classes/class-aksrt-withdrawal.php new file mode 100644 index 00000000..413ed541 --- /dev/null +++ b/inc/classes/class-aksrt-withdrawal.php @@ -0,0 +1,166 @@ +insert($this->table(), [ + 'user_id' => $user_id, + 'amount' => $amount, + 'method' => sanitize_key($method), + 'account_info' => sanitize_textarea_field($account_info), + 'status' => self::STATUS_PENDING, + 'created_at' => current_time('mysql'), + ], ['%d', '%f', '%s', '%s', '%s', '%s']); + + return (int) $wpdb->insert_id; + } + + /** + * 更新提现状态 + */ + public function update_status(int $withdrawal_id, string $new_status, string $note = ''): bool { + global $wpdb; + + $update = ['status' => sanitize_key($new_status)]; + $format = ['%s']; + + if ($new_status === self::STATUS_COMPLETED || $new_status === self::STATUS_REJECTED) { + $update['processed_at'] = current_time('mysql'); + $format[] = '%s'; + } + + if ($note !== '') { + $update['note'] = sanitize_textarea_field($note); + $format[] = '%s'; + } + + return $wpdb->update($this->table(), $update, ['id' => $withdrawal_id], $format, ['%d']) !== false; + } + + /** + * 获取用户提现列表 + */ + public function get_user_withdrawals(int $user_id, array $args = []): array { + global $wpdb; + + $where = ['user_id = %d']; + $params = [$user_id]; + + $status = sanitize_key((string) ($args['status'] ?? '')); + if ($status !== '') { + $where[] = 'status = %s'; + $params[] = $status; + } + + $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); + $offset = max(0, (int) ($args['offset'] ?? 0)); + + $sql = "SELECT * FROM {$this->table()} WHERE " . implode(' AND ', $where) . " ORDER BY created_at DESC LIMIT %d OFFSET %d"; + $params[] = $limit; + $params[] = $offset; + + return (array) $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A); + } + + /** + * 获取所有提现列表(管理后台) + */ + public function get_all(array $args = []): array { + global $wpdb; + + $where = []; + $params = []; + + $status = sanitize_key((string) ($args['status'] ?? '')); + if ($status !== '') { + $where[] = 'status = %s'; + $params[] = $status; + } + + $limit = max(1, min(100, (int) ($args['limit'] ?? 20))); + $offset = max(0, (int) ($args['offset'] ?? 0)); + + $where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : ''; + $sql = "SELECT * FROM {$this->table()} {$where_sql} ORDER BY created_at DESC LIMIT %d OFFSET %d"; + $params[] = $limit; + $params[] = $offset; + + if (!empty($params)) { + return (array) $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A); + } + return (array) $wpdb->get_results($sql, ARRAY_A); + } + + /** + * 获取提现总数(支持状态过滤) + */ + public function get_count(array $args = []): int { + global $wpdb; + + $status = sanitize_key((string) ($args['status'] ?? '')); + if ($status !== '') { + return (int) $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM {$this->table()} WHERE status = %s", + $status + )); + } + return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$this->table()}"); + } + + /** + * 获取提现统计 + */ + public function get_stats(): array { + global $wpdb; + + $row = $wpdb->get_row( + "SELECT + COUNT(*) as total, + SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending, + SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed, + COALESCE(SUM(CASE WHEN status = 'completed' THEN amount ELSE 0 END), 0) as total_paid + FROM {$this->table()}", + ARRAY_A + ); + + return [ + 'total' => (int) ($row['total'] ?? 0), + 'pending' => (int) ($row['pending'] ?? 0), + 'completed' => (int) ($row['completed'] ?? 0), + 'total_paid' => (float) ($row['total_paid'] ?? 0), + ]; + } +} diff --git a/inc/classes/db-traits/trait-aksrt-db-migrator-part-1.php b/inc/classes/db-traits/trait-aksrt-db-migrator-part-1.php new file mode 100644 index 00000000..ad463333 --- /dev/null +++ b/inc/classes/db-traits/trait-aksrt-db-migrator-part-1.php @@ -0,0 +1,198 @@ +post_type === 'aksrt_resource', + !empty($legacy_downloads), + ]); + + if ($raw_pay_type === '' && $has_resource_marker) { + $raw_pay_type = AKSRT_Domain_Model::PAY_TYPE_DOWNLOAD; + } + + $pay_meta = AKSRT_Domain_Model::sanitize_pay_meta([ + 'pay_type' => $raw_pay_type, + 'price' => self::first_numeric([ + $canonical_meta['price'] ?? null, + self::meta_value($all_meta, AKSRT_Domain_Model::PAY_PRICE_KEY), + self::meta_value($all_meta, '_aksrt_res_price'), + ]), + 'original_price' => self::first_numeric([ + $canonical_meta['original_price'] ?? null, + self::meta_value($all_meta, '_aksrt_res_original_price'), + ]), + 'vip1_price' => self::first_numeric([ + $canonical_meta['vip1_price'] ?? null, + self::meta_value($all_meta, '_aksrt_res_vip1_price'), + ]), + 'vip2_price' => self::first_numeric([ + $canonical_meta['vip2_price'] ?? null, + self::meta_value($all_meta, '_aksrt_res_vip2_price'), + ]), + 'version' => self::first_non_empty_string([ + $canonical_meta['version'] ?? '', + self::meta_value($all_meta, '_aksrt_res_version'), + ]), + 'size' => self::first_non_empty_string([ + $canonical_meta['size'] ?? '', + self::meta_value($all_meta, '_aksrt_res_size'), + ]), + 'license' => self::first_non_empty_string([ + $canonical_meta['license'] ?? '', + self::meta_value($all_meta, '_aksrt_res_license'), + ]), + 'compat' => self::first_non_empty_string([ + $canonical_meta['compat'] ?? '', + self::meta_value($all_meta, '_aksrt_res_compat'), + ]), + 'demo_url' => self::first_non_empty_string([ + $canonical_meta['demo_url'] ?? '', + self::meta_value($all_meta, '_aksrt_res_demo_url'), + ]), + 'video_url' => self::first_non_empty_string([ + $canonical_meta['video_url'] ?? '', + ]), + 'downloads' => self::first_array([ + $canonical_meta['downloads'] ?? [], + $legacy_downloads, + ]), + 'attributes' => self::first_array([ + $canonical_meta['attributes'] ?? [], + ]), + 'gallery' => self::first_array([ + $canonical_meta['gallery'] ?? [], + ]), + 'require_login' => self::first_boolean([ + $canonical_meta['require_login'] ?? null, + self::meta_value($all_meta, '_aksrt_res_require_login'), + ]), + 'require_captcha' => self::first_boolean([ + $canonical_meta['require_captcha'] ?? null, + self::meta_value($all_meta, '_aksrt_res_require_captcha'), + ]), + ]); + + if ($pay_meta['pay_type'] === AKSRT_Domain_Model::PAY_TYPE_NO && $has_resource_marker) { + $pay_meta['pay_type'] = AKSRT_Domain_Model::PAY_TYPE_DOWNLOAD; + } + + $pay_meta = AKSRT_Domain_Model::sanitize_pay_meta($pay_meta); + + update_post_meta($post_id, AKSRT_Domain_Model::PAY_META_KEY, $pay_meta); + update_post_meta($post_id, AKSRT_Domain_Model::PAY_TYPE_KEY, (string) $pay_meta['pay_type']); + update_post_meta($post_id, AKSRT_Domain_Model::PAY_PRICE_KEY, (float) $pay_meta['price']); + update_post_meta( + $post_id, + AKSRT_Domain_Model::RESOURCE_MARKER_KEY, + AKSRT_Domain_Model::is_resource_pay_type((string) $pay_meta['pay_type']) ? 1 : 0 + ); + + clean_post_cache($post_id); + } + + /** + * @param array> $all_meta + * @return mixed + */ + private static function meta_value(array $all_meta, string $key) { + if (!isset($all_meta[$key][0])) { + return null; + } + + return maybe_unserialize($all_meta[$key][0]); + } + + /** + * @param array $candidates + */ + private static function first_non_empty_string(array $candidates): string { + foreach ($candidates as $candidate) { + if (is_array($candidate)) { + continue; + } + + $value = trim((string) $candidate); + if ($value !== '') { + return $value; + } + } + + return ''; + } + + /** + * @param array $candidates + */ + private static function first_numeric(array $candidates): float { + foreach ($candidates as $candidate) { + if ($candidate === null || $candidate === '') { + continue; + } + + if (is_numeric($candidate)) { + return (float) $candidate; + } + } + + return 0.0; + } + + /** + * @param array $candidates + */ + private static function first_boolean(array $candidates): bool { + foreach ($candidates as $candidate) { + if ($candidate === null || $candidate === '') { + continue; + } + + return !empty($candidate); + } + + return false; + } + + /** + * @param array $candidates + * @return array + */ + private static function first_array(array $candidates): array { + foreach ($candidates as $candidate) { + if (is_array($candidate) && !empty($candidate)) { + return $candidate; + } + } + + return []; + } +} diff --git a/inc/classes/db-traits/trait-aksrt-db-migrator-part-2.php b/inc/classes/db-traits/trait-aksrt-db-migrator-part-2.php new file mode 100644 index 00000000..54d9e194 --- /dev/null +++ b/inc/classes/db-traits/trait-aksrt-db-migrator-part-2.php @@ -0,0 +1,437 @@ +get_charset_collate(); + $prefix = $wpdb->prefix; + + // ---------- 1. 积分流水 ---------- + $sql_credit_log = "CREATE TABLE {$prefix}aksrt_credit_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + delta INT NOT NULL, + balance_after INT NOT NULL, + reason VARCHAR(64) NOT NULL, + remark VARCHAR(255) DEFAULT NULL, + ref_type VARCHAR(32) DEFAULT NULL, + ref_id BIGINT UNSIGNED DEFAULT NULL, + ip VARCHAR(64) DEFAULT NULL, + ua VARCHAR(255) DEFAULT NULL, + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY idx_user_time (user_id, created_at), + KEY idx_reason (reason) + ) {$charset_collate};"; + dbDelta($sql_credit_log); + + // ---------- 2. 签到 ---------- + $sql_signin = "CREATE TABLE {$prefix}aksrt_signin_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + signin_date DATE NOT NULL, + streak INT NOT NULL DEFAULT 1, + reward INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uniq_user_date (user_id, signin_date), + KEY idx_user (user_id) + ) {$charset_collate};"; + dbDelta($sql_signin); + + // ---------- 3. 邀请 ---------- + $sql_invite = "CREATE TABLE {$prefix}aksrt_invite ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + inviter_id BIGINT UNSIGNED NOT NULL, + invitee_id BIGINT UNSIGNED DEFAULT NULL, + invite_code VARCHAR(32) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'pending', + created_at DATETIME NOT NULL, + rewarded_at DATETIME DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY uniq_code (invite_code), + KEY idx_inviter (inviter_id), + KEY idx_invitee (invitee_id), + KEY idx_status (status) + ) {$charset_collate};"; + dbDelta($sql_invite); + + // ---------- 4. 下载 token ---------- + $sql_dl_token = "CREATE TABLE {$prefix}aksrt_dl_token ( + token CHAR(48) NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + post_id BIGINT UNSIGNED NOT NULL, + source_index INT NOT NULL DEFAULT 0, + cost INT NOT NULL DEFAULT 0, + ip_bind VARCHAR(64) DEFAULT NULL, + ua_hash CHAR(40) DEFAULT NULL, + used_count INT NOT NULL DEFAULT 0, + max_uses INT NOT NULL DEFAULT 1, + expires_at DATETIME NOT NULL, + created_at DATETIME NOT NULL, + PRIMARY KEY (token), + KEY idx_expires (expires_at), + KEY idx_user (user_id), + KEY idx_post (post_id) + ) {$charset_collate};"; + dbDelta($sql_dl_token); + + // ---------- 5. 用户通知收件箱 ---------- + $sql_notification = "CREATE TABLE {$prefix}aksrt_notification ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + type VARCHAR(32) NOT NULL, + title VARCHAR(191) NOT NULL, + description TEXT DEFAULT NULL, + target_url TEXT DEFAULT NULL, + is_read TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL, + read_at DATETIME DEFAULT NULL, + PRIMARY KEY (id), + KEY idx_user_created (user_id, created_at), + KEY idx_user_read (user_id, is_read) + ) {$charset_collate};"; + dbDelta($sql_notification); + + // ---------- 6. 私信会话 ---------- + $sql_conversation = "CREATE TABLE {$prefix}aksrt_conversation ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_a BIGINT UNSIGNED NOT NULL, + user_b BIGINT UNSIGNED NOT NULL, + last_message VARCHAR(255) DEFAULT '', + last_message_at DATETIME DEFAULT NULL, + unread_a INT NOT NULL DEFAULT 0, + unread_b INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uniq_pair (user_a, user_b), + KEY idx_user_a (user_a), + KEY idx_user_b (user_b), + KEY idx_last_msg (last_message_at) + ) {$charset_collate};"; + dbDelta($sql_conversation); + + // ---------- 7. 私信消息 ---------- + $sql_conv_message = "CREATE TABLE {$prefix}aksrt_conversation_message ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + conversation_id BIGINT UNSIGNED NOT NULL, + sender_id BIGINT UNSIGNED NOT NULL, + content TEXT NOT NULL, + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY idx_conv_time (conversation_id, created_at), + KEY idx_sender (sender_id) + ) {$charset_collate};"; + dbDelta($sql_conv_message); + + // ---------- 8. 订单表 ---------- + $sql_order = "CREATE TABLE {$prefix}aksrt_order ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + order_number VARCHAR(32) NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + product_type VARCHAR(32) NOT NULL DEFAULT '', + product_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + amount DECIMAL(10,2) NOT NULL DEFAULT 0, + currency VARCHAR(8) NOT NULL DEFAULT 'CNY', + status VARCHAR(20) NOT NULL DEFAULT 'pending', + shipping_status TINYINT NOT NULL DEFAULT 0, + payment_method VARCHAR(32) NOT NULL DEFAULT '', + payment_transaction_id VARCHAR(128) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL, + paid_at DATETIME DEFAULT NULL, + delivered_at DATETIME DEFAULT NULL, + completed_at DATETIME DEFAULT NULL, + cancelled_at DATETIME DEFAULT NULL, + refunded_at DATETIME DEFAULT NULL, + expires_at DATETIME DEFAULT NULL, + note TEXT DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY uniq_order_number (order_number), + KEY idx_user (user_id), + KEY idx_status (status), + KEY idx_created (created_at), + KEY idx_product (product_type, product_id) + ) {$charset_collate};"; + dbDelta($sql_order); + + // ---------- 9. 收益台账 ---------- + $sql_revenue = "CREATE TABLE {$prefix}aksrt_revenue ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + order_number VARCHAR(32) NOT NULL DEFAULT '', + amount DECIMAL(10,2) NOT NULL DEFAULT 0, + type VARCHAR(32) NOT NULL DEFAULT 'income', + status VARCHAR(20) NOT NULL DEFAULT 'pending', + created_at DATETIME NOT NULL, + settled_at DATETIME DEFAULT NULL, + PRIMARY KEY (id), + KEY idx_user (user_id), + KEY idx_order (order_number), + KEY idx_status (status), + KEY idx_created (created_at) + ) {$charset_collate};"; + dbDelta($sql_revenue); + + // ---------- 10. 提现台账 ---------- + $sql_withdrawal = "CREATE TABLE {$prefix}aksrt_withdrawal ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + amount DECIMAL(10,2) NOT NULL DEFAULT 0, + method VARCHAR(32) NOT NULL DEFAULT '', + account_info TEXT DEFAULT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + created_at DATETIME NOT NULL, + processed_at DATETIME DEFAULT NULL, + note TEXT DEFAULT NULL, + PRIMARY KEY (id), + KEY idx_user (user_id), + KEY idx_status (status), + KEY idx_created (created_at) + ) {$charset_collate};"; + dbDelta($sql_withdrawal); + + // ---------- 11. 举报工单 ---------- + $sql_report = "CREATE TABLE {$prefix}aksrt_report ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + reporter_id BIGINT UNSIGNED NOT NULL, + target_type VARCHAR(20) NOT NULL, + target_id BIGINT UNSIGNED NOT NULL, + reason TEXT NOT NULL, + category VARCHAR(40) NOT NULL DEFAULT '', + status VARCHAR(20) NOT NULL DEFAULT 'pending', + created_at DATETIME NOT NULL, + updated_at DATETIME DEFAULT NULL, + notes_json TEXT DEFAULT NULL, + PRIMARY KEY (id), + KEY idx_status (status), + KEY idx_target (target_type, target_id), + KEY idx_reporter (reporter_id), + KEY idx_created (created_at) + ) {$charset_collate};"; + dbDelta($sql_report); + + // ---------- 12. 用户限制/封禁 ---------- + $sql_restriction = "CREATE TABLE {$prefix}aksrt_user_restriction ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + type VARCHAR(20) NOT NULL DEFAULT 'warn', + reason TEXT NOT NULL, + scope VARCHAR(20) NOT NULL DEFAULT 'global', + expires_at DATETIME DEFAULT NULL, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY idx_user (user_id), + KEY idx_type (type), + KEY idx_scope (scope), + KEY idx_expires (expires_at), + KEY idx_created (created_at) + ) {$charset_collate};"; + dbDelta($sql_restriction); + + // ---------- 13. 商品变体/SKU ---------- + $sql_variant = "CREATE TABLE {$prefix}aksrt_product_variant ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + product_id BIGINT UNSIGNED NOT NULL, + sku VARCHAR(64) NOT NULL DEFAULT '', + attributes TEXT DEFAULT NULL, + price DECIMAL(10,2) NOT NULL DEFAULT 0, + stock INT NOT NULL DEFAULT 0, + image VARCHAR(512) NOT NULL DEFAULT '', + sort_order INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL, + PRIMARY KEY (id), + KEY idx_product (product_id), + KEY idx_sku (sku), + KEY idx_sort (sort_order) + ) {$charset_collate};"; + dbDelta($sql_variant); + + // ── follow ── + $sql_follow = "CREATE TABLE {$prefix}aksrt_follow ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + follower_id BIGINT UNSIGNED NOT NULL, + following_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', + PRIMARY KEY (id), + UNIQUE KEY idx_pair (follower_id, following_id), + KEY idx_following (following_id), + KEY idx_created (created_at) + ) {$charset_collate};"; + dbDelta($sql_follow); + + // ── coupon ── + $sql_coupon = "CREATE TABLE {$prefix}aksrt_coupon ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + code VARCHAR(60) NOT NULL DEFAULT '', + type VARCHAR(20) NOT NULL DEFAULT 'fixed', + value DECIMAL(10,2) NOT NULL DEFAULT 0, + min_amount DECIMAL(10,2) NOT NULL DEFAULT 0, + usage_limit INT UNSIGNED NOT NULL DEFAULT 0, + used_count INT UNSIGNED NOT NULL DEFAULT 0, + expires_at DATETIME DEFAULT NULL, + per_user_limit INT UNSIGNED NOT NULL DEFAULT 0, + product_scope TEXT DEFAULT NULL, + max_discount DECIMAL(10,2) NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'active', + created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', + PRIMARY KEY (id), + UNIQUE KEY idx_code (code), + KEY idx_status (status), + KEY idx_expires (expires_at) + ) {$charset_collate};"; + dbDelta($sql_coupon); + + // ── coupon_usage (R68) ── + $sql_coupon_usage = "CREATE TABLE {$prefix}aksrt_coupon_usage ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + coupon_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + order_number VARCHAR(32) NOT NULL DEFAULT '', + discount DECIMAL(10,2) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', + PRIMARY KEY (id), + KEY idx_coupon (coupon_id), + KEY idx_user (user_id), + KEY idx_order (order_number) + ) {$charset_collate};"; + dbDelta($sql_coupon_usage); + + // ── after_sale ── + $sql_after_sale = "CREATE TABLE {$prefix}aksrt_after_sale ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + order_number VARCHAR(32) NOT NULL DEFAULT '', + user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + type VARCHAR(20) NOT NULL DEFAULT 'refund', + reason TEXT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + amount DECIMAL(10,2) NOT NULL DEFAULT 0, + note TEXT DEFAULT NULL, + operator_id BIGINT UNSIGNED DEFAULT NULL, + operator_note TEXT DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', + updated_at DATETIME DEFAULT NULL, + resolved_at DATETIME DEFAULT NULL, + PRIMARY KEY (id), + KEY idx_order (order_number), + KEY idx_user (user_id), + KEY idx_status (status), + KEY idx_type (type), + KEY idx_created (created_at) + ) {$charset_collate};"; + dbDelta($sql_after_sale); + + // ── order_shipping ── + $sql_shipping = "CREATE TABLE {$prefix}aksrt_order_shipping ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + order_number VARCHAR(32) NOT NULL DEFAULT '', + carrier VARCHAR(60) NOT NULL DEFAULT '', + tracking_no VARCHAR(128) NOT NULL DEFAULT '', + status VARCHAR(20) NOT NULL DEFAULT 'shipped', + note TEXT DEFAULT NULL, + operator_id BIGINT UNSIGNED DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', + updated_at DATETIME DEFAULT NULL, + delivered_at DATETIME DEFAULT NULL, + PRIMARY KEY (id), + KEY idx_order (order_number), + KEY idx_status (status), + KEY idx_tracking (tracking_no), + KEY idx_created (created_at) + ) {$charset_collate};"; + dbDelta($sql_shipping); + + // ── charge_card ── + $sql_charge_card = "CREATE TABLE {$prefix}aksrt_charge_card ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + code VARCHAR(60) NOT NULL DEFAULT '', + amount DECIMAL(10,2) NOT NULL DEFAULT 0, + balance DECIMAL(10,2) NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'active', + used_by BIGINT UNSIGNED DEFAULT NULL, + used_at DATETIME DEFAULT NULL, + expires_at DATETIME DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', + note TEXT DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY idx_code (code), + KEY idx_status (status), + KEY idx_used_by (used_by), + KEY idx_expires (expires_at) + ) {$charset_collate};"; + dbDelta($sql_charge_card); + + // ── rebate_log ── + $sql_rebate_log = "CREATE TABLE {$prefix}aksrt_rebate_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + order_number VARCHAR(32) NOT NULL DEFAULT '', + source_user_id BIGINT UNSIGNED DEFAULT NULL, + amount DECIMAL(10,2) NOT NULL DEFAULT 0, + type VARCHAR(32) NOT NULL DEFAULT 'order', + status VARCHAR(20) NOT NULL DEFAULT 'pending', + created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', + settled_at DATETIME DEFAULT NULL, + note TEXT DEFAULT NULL, + PRIMARY KEY (id), + KEY idx_user (user_id), + KEY idx_order (order_number), + KEY idx_source (source_user_id), + KEY idx_status (status), + KEY idx_type (type), + KEY idx_created (created_at) + ) {$charset_collate};"; + dbDelta($sql_rebate_log); + + // ── order_items (§6.5 多商品多商家) ── + $sql_order_items = "CREATE TABLE {$prefix}aksrt_order_item ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + order_number VARCHAR(32) NOT NULL DEFAULT '', + product_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + product_title VARCHAR(255) NOT NULL DEFAULT '', + options_key VARCHAR(255) NOT NULL DEFAULT '', + options_label VARCHAR(255) NOT NULL DEFAULT '', + quantity INT UNSIGNED NOT NULL DEFAULT 1, + unit_price DECIMAL(10,2) NOT NULL DEFAULT 0, + total_price DECIMAL(10,2) NOT NULL DEFAULT 0, + discount_amount DECIMAL(10,2) NOT NULL DEFAULT 0, + shipping_fee DECIMAL(10,2) NOT NULL DEFAULT 0, + pay_price DECIMAL(10,2) NOT NULL DEFAULT 0, + shipping_type VARCHAR(20) NOT NULL DEFAULT 'express', + auto_delivery_content TEXT DEFAULT NULL, + discount_hit TEXT DEFAULT NULL, + gift_data TEXT DEFAULT NULL, + image VARCHAR(512) NOT NULL DEFAULT '', + PRIMARY KEY (id), + KEY idx_order (order_number), + KEY idx_product (product_id) + ) {$charset_collate};"; + dbDelta($sql_order_items); + + // ── card_key 卡密表 (§6.2 自动发货) ── + $sql_card_key = "CREATE TABLE {$prefix}aksrt_card_key ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + product_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + options_key VARCHAR(255) NOT NULL DEFAULT '', + card_code TEXT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'available', + order_number VARCHAR(32) NOT NULL DEFAULT '', + used_by BIGINT UNSIGNED DEFAULT NULL, + used_at DATETIME DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00', + PRIMARY KEY (id), + KEY idx_product (product_id), + KEY idx_options (options_key(50)), + KEY idx_status (status), + KEY idx_order (order_number) + ) {$charset_collate};"; + dbDelta($sql_card_key); + } +} diff --git a/inc/classes/db-traits/trait-aksrt-db-migrator-part-3.php b/inc/classes/db-traits/trait-aksrt-db-migrator-part-3.php new file mode 100644 index 00000000..6c9ec448 --- /dev/null +++ b/inc/classes/db-traits/trait-aksrt-db-migrator-part-3.php @@ -0,0 +1,39 @@ + 'aksrt_credit_log', + 'signin_log' => 'aksrt_signin_log', + 'invite' => 'aksrt_invite', + 'dl_token' => 'aksrt_dl_token', + 'notification' => 'aksrt_notification', + 'conversation' => 'aksrt_conversation', + 'conv_message' => 'aksrt_conversation_message', + 'order' => 'aksrt_order', + 'order_item' => 'aksrt_order_item', + 'revenue' => 'aksrt_revenue', + 'withdrawal' => 'aksrt_withdrawal', + 'report' => 'aksrt_report', + 'follow' => 'aksrt_follow', + 'coupon' => 'aksrt_coupon', + 'after_sale' => 'aksrt_after_sale', + 'order_shipping' => 'aksrt_order_shipping', + 'charge_card' => 'aksrt_charge_card', + 'rebate_log' => 'aksrt_rebate_log', + 'coupon_usage' => 'aksrt_coupon_usage', + 'card_key' => 'aksrt_card_key', + ]; + if (!isset($allowed[$name])) { + return ''; + } + return $wpdb->prefix . $allowed[$name]; + } +} diff --git a/inc/classes/forum/board-service-traits/trait-aksrt-forum-board-service-part-1.php b/inc/classes/forum/board-service-traits/trait-aksrt-forum-board-service-part-1.php new file mode 100644 index 00000000..67f4e03c --- /dev/null +++ b/inc/classes/forum/board-service-traits/trait-aksrt-forum-board-service-part-1.php @@ -0,0 +1,419 @@ + 'aksrt_forum_plate', + 'post_title' => $title, + 'post_content' => wp_kses_post((string) ($data['content'] ?? '')), + 'post_excerpt' => sanitize_textarea_field((string) ($data['description'] ?? '')), + 'post_status' => 'publish', + 'post_parent' => (int) ($data['parent_id'] ?? 0), + 'post_author' => (int) ($data['author_id'] ?? get_current_user_id()), + 'menu_order' => (int) ($data['sort_order'] ?? 0), + ], true); + + if (is_wp_error($plate_id)) { + return 0; + } + + $icon = sanitize_text_field((string) ($data['icon'] ?? '')); + if ($icon !== '') { + update_post_meta($plate_id, '_aksrt_icon', $icon); + } + + update_post_meta($plate_id, '_aksrt_sort_order', (int) ($data['sort_order'] ?? 0)); + update_post_meta($plate_id, '_aksrt_posts_count', 0); + update_post_meta($plate_id, '_aksrt_reply_count', 0); + update_post_meta($plate_id, '_aksrt_follow_count', 0); + update_post_meta($plate_id, '_aksrt_plate_views', 0); + + if (!empty($data['plate_cat'])) { + wp_set_object_terms($plate_id, [(int) $data['plate_cat']], 'aksrt_plate_cat'); + } + + // Also create a corresponding taxonomy term for backward compat + $term_result = wp_insert_term($title, 'aksrt_forum_board', [ + 'description' => sanitize_textarea_field((string) ($data['description'] ?? '')), + ]); + if (!is_wp_error($term_result)) { + $term_id = (int) $term_result['term_id']; + update_term_meta($term_id, '_aksrt_sort_order', (int) ($data['sort_order'] ?? 0)); + if ($icon !== '') { + update_term_meta($term_id, '_aksrt_icon', $icon); + } + update_post_meta($plate_id, '_aksrt_migrated_from_term', $term_id); + } + + return $plate_id; + } + + /** + * Update plate. + */ + public function update_plate(int $plate_id, array $data): bool { + $post = get_post($plate_id); + if (!$post || $post->post_type !== 'aksrt_forum_plate') { + return false; + } + + $update = ['ID' => $plate_id]; + + if (isset($data['name'])) { + $name = sanitize_text_field((string) $data['name']); + if ($name !== '') { + $update['post_title'] = $name; + } + } + + if (isset($data['description'])) { + $update['post_excerpt'] = sanitize_textarea_field((string) $data['description']); + } + + if (isset($data['content'])) { + $update['post_content'] = wp_kses_post((string) $data['content']); + } + + if (isset($data['parent_id'])) { + $update['post_parent'] = (int) $data['parent_id']; + } + + if (isset($data['sort_order'])) { + $update['menu_order'] = (int) $data['sort_order']; + update_post_meta($plate_id, '_aksrt_sort_order', (int) $data['sort_order']); + } + + if (isset($data['author_id'])) { + $update['post_author'] = (int) $data['author_id']; + } + + $result = wp_update_post($update, true); + if (is_wp_error($result)) { + return false; + } + + if (isset($data['icon'])) { + update_post_meta($plate_id, '_aksrt_icon', sanitize_text_field((string) $data['icon'])); + } + + if (isset($data['allow_view'])) { + $allowed_views = ['public', 'login', 'role', 'follow']; + $view = in_array($data['allow_view'], $allowed_views, true) ? $data['allow_view'] : 'public'; + update_post_meta($plate_id, '_aksrt_allow_view', $view); + } + + // Sync to legacy taxonomy term if exists + $term_id = (int) get_post_meta($plate_id, '_aksrt_migrated_from_term', true); + if ($term_id > 0) { + $term_update = []; + if (isset($update['post_title'])) { + $term_update['name'] = $update['post_title']; + } + if (isset($update['post_excerpt'])) { + $term_update['description'] = $update['post_excerpt']; + } + if (!empty($term_update)) { + wp_update_term($term_id, 'aksrt_forum_board', $term_update); + } + if (isset($data['icon'])) { + update_term_meta($term_id, '_aksrt_icon', sanitize_text_field((string) $data['icon'])); + } + if (isset($data['sort_order'])) { + update_term_meta($term_id, '_aksrt_sort_order', (int) $data['sort_order']); + } + } + + return true; + } + + /** + * Delete plate (with cascade option). + */ + public function delete_plate(int $plate_id, string $cascade = 'trash', int $move_to = 0): bool { + $post = get_post($plate_id); + if (!$post || $post->post_type !== 'aksrt_forum_plate') { + return false; + } + + $topic_ids = get_posts([ + 'post_type' => 'aksrt_forum_topic', + 'meta_key' => '_aksrt_plate_id', + 'meta_value' => $plate_id, + 'posts_per_page' => -1, + 'fields' => 'ids', + 'post_status' => 'any', + ]); + + if (!empty($topic_ids)) { + if ($cascade === 'move' && $move_to > 0 && $move_to !== $plate_id) { + $target = get_post($move_to); + if ($target && $target->post_type === 'aksrt_forum_plate') { + foreach ($topic_ids as $tid) { + update_post_meta((int) $tid, '_aksrt_plate_id', $move_to); + } + $this->forum->sync_plate_counts($move_to); + } + } else { + foreach ($topic_ids as $tid) { + wp_trash_post((int) $tid); + } + } + } + + // Handle child plates (move to top-level) + $children = get_posts([ + 'post_type' => 'aksrt_forum_plate', + 'post_parent' => $plate_id, + 'posts_per_page' => -1, + 'fields' => 'ids', + 'post_status' => 'any', + ]); + foreach ($children as $child_id) { + wp_update_post(['ID' => (int) $child_id, 'post_parent' => 0]); + } + + // Remove legacy taxonomy term if linked + $term_id = (int) get_post_meta($plate_id, '_aksrt_migrated_from_term', true); + if ($term_id > 0) { + wp_delete_term($term_id, 'aksrt_forum_board'); + } + + wp_delete_post($plate_id, true); + return true; + } + + /** + * Reorder plates. + */ + public function reorder_plates(array $order_map): bool { + foreach ($order_map as $plate_id => $sort_order) { + $plate_id = (int) $plate_id; + if ($plate_id <= 0) continue; + + wp_update_post(['ID' => $plate_id, 'menu_order' => (int) $sort_order]); + update_post_meta($plate_id, '_aksrt_sort_order', (int) $sort_order); + } + + return true; + } + + /** + * Resolve plate_id for a topic (reads _aksrt_plate_id meta, falls back to taxonomy). + */ + public function get_topic_plate_id(int $topic_id): int { + $plate_id = (int) get_post_meta($topic_id, '_aksrt_plate_id', true); + if ($plate_id > 0) { + return $plate_id; + } + + // Fallback: find plate via legacy taxonomy term + $terms = get_the_terms($topic_id, 'aksrt_forum_board'); + if (!$terms || is_wp_error($terms)) { + return 0; + } + + $term = $terms[0]; + // Look up plate post by migrated_from_term meta + $plates = get_posts([ + 'post_type' => 'aksrt_forum_plate', + 'meta_key' => '_aksrt_migrated_from_term', + 'meta_value' => (string) $term->term_id, + 'numberposts' => 1, + 'fields' => 'ids', + 'post_status' => 'publish', + ]); + + if (!empty($plates)) { + $resolved = (int) $plates[0]; + update_post_meta($topic_id, '_aksrt_plate_id', $resolved); + return $resolved; + } + + return 0; + } + + /** + * Check if a user is a plate moderator (CPT-based). + */ + public function is_plate_moderator(int $user_id, int $plate_id): bool { + if ($user_id <= 0 || $plate_id <= 0) { + return false; + } + + if ($this->forum->is_forum_admin($user_id)) { + return true; + } + + $post = get_post($plate_id); + if ($post && (int) $post->post_author === $user_id) { + return true; + } + + $moderators = get_post_meta($plate_id, '_aksrt_moderators', true); + if (is_array($moderators) && in_array($user_id, $moderators, true)) { + return true; + } + + return false; + } + + /** + * Get plate view (topics + moderators + pagination) for frontend. + */ + public function get_plate_view(int $plate_id, array $args = []): array { + $plate = $this->get_plate($plate_id); + if (!$plate) { + return [ + 'plate' => null, + 'topics' => [], + 'sticky_topics' => [], + 'moderators' => [], + 'pagination' => ['page' => 1, 'max_pages' => 1, 'total' => 0], + 'permissions' => [], + 'children' => [], + ]; + } + + $user_id = (int) ($args['user_id'] ?? get_current_user_id()); + $paged = max(1, (int) ($args['paged'] ?? 1)); + + $topics = $this->forum->get_topic_list([ + 'plate_id' => $plate_id, + 'page' => $paged, + 'limit' => 20, + ]); + + // Global + super + plate sticky + $sticky_topics = $this->get_sticky_topics_for_plate($plate_id); + + $children = get_posts([ + 'post_type' => 'aksrt_forum_plate', + 'post_parent' => $plate_id, + 'post_status' => 'publish', + 'posts_per_page' => 50, + 'orderby' => 'menu_order', + 'order' => 'ASC', + ]); + + $child_plates = []; + foreach ($children as $child) { + $child_plates[] = $this->build_plate_payload($child); + } + + return [ + 'plate' => $plate, + 'topics' => $topics['items'], + 'sticky_topics' => $sticky_topics, + 'moderators' => $this->get_plate_moderator_profiles($plate_id), + 'pagination' => [ + 'page' => $topics['page'], + 'max_pages' => $topics['max_pages'], + 'total' => $topics['total'], + ], + 'permissions' => [ + 'can_compose' => $user_id > 0 && $this->can_compose_in_plate($user_id, $plate_id), + 'can_apply_moderator' => $user_id > 0 && $this->forum->forum_user_can($user_id, 'moderator_apply'), + 'can_moderate' => $this->forum->forum_user_can($user_id, 'topic_moderate', $plate_id), + 'can_manage_plate' => $this->forum->forum_user_can($user_id, 'plate_manage', $plate_id), + 'can_manage_mods' => $this->forum->forum_user_can($user_id, 'moderator_manage', $plate_id), + 'user_tier' => $this->forum->get_user_plate_tier($user_id, $plate_id), + ], + 'children' => $child_plates, + ]; + } + + /** + * Get sticky topics for a plate (global + super + plate-level). + */ + private function get_sticky_topics_for_plate(int $plate_id): array { + $global_sticky = get_posts([ + 'post_type' => 'aksrt_forum_topic', + 'post_status' => 'publish', + 'meta_key' => '_aksrt_sticky', + 'meta_value' => AKSRT_Forum::STICKY_GLOBAL, + 'posts_per_page' => 5, + 'orderby' => 'date', + 'order' => 'DESC', + ]); + + $plate_sticky = get_posts([ + 'post_type' => 'aksrt_forum_topic', + 'post_status' => 'publish', + 'posts_per_page' => 10, + 'orderby' => 'date', + 'order' => 'DESC', + 'meta_query' => [ + 'relation' => 'AND', + ['key' => '_aksrt_plate_id', 'value' => $plate_id, 'type' => 'NUMERIC'], + ['key' => '_aksrt_sticky', 'value' => 0, 'compare' => '>', 'type' => 'NUMERIC'], + ['key' => '_aksrt_sticky', 'value' => AKSRT_Forum::STICKY_GLOBAL, 'compare' => '<', 'type' => 'NUMERIC'], + ], + ]); + + $items = []; + $seen = []; + + foreach (array_merge($global_sticky, $plate_sticky) as $post) { + if (isset($seen[$post->ID])) continue; + $seen[$post->ID] = true; + $items[] = $this->forum->build_topic_payload($post); + } + + return array_slice($items, 0, 10); + } + + /** + * Get moderator user profiles for a plate. + */ + private function get_plate_moderator_profiles(int $plate_id): array { + $post = get_post($plate_id); + $profiles = []; + + // Author = super moderator + if ($post && $post->post_author > 0) { + $user = get_userdata((int) $post->post_author); + if ($user) { + $profiles[] = [ + 'id' => (int) $user->ID, + 'name' => $user->display_name, + 'avatar' => function_exists('aksrt_get_avatar_url') ? aksrt_get_avatar_url($user->ID, 40) : get_avatar_url($user->ID, ['size' => 40]), + 'role' => 'plate_author', + ]; + } + } + + $moderators = get_post_meta($plate_id, '_aksrt_moderators', true); + if (!is_array($moderators)) { + return $profiles; + } + + $roles = get_post_meta($plate_id, '_aksrt_moderator_roles', true); + if (!is_array($roles)) { + $roles = []; + } + + foreach ($moderators as $uid) { + $uid = (int) $uid; + if ($post && $uid === (int) $post->post_author) continue; + + $user = get_userdata($uid); + if (!$user) continue; + + $profiles[] = [ + 'id' => $uid, + 'name' => $user->display_name, + 'avatar' => function_exists('aksrt_get_avatar_url') ? aksrt_get_avatar_url($uid, 40) : get_avatar_url($uid, ['size' => 40]), + 'role' => (string) ($roles[$uid] ?? 'moderator'), + ]; + } + + return $profiles; + } +} diff --git a/inc/classes/forum/board-service-traits/trait-aksrt-forum-board-service-part-2.php b/inc/classes/forum/board-service-traits/trait-aksrt-forum-board-service-part-2.php new file mode 100644 index 00000000..6a61e2e2 --- /dev/null +++ b/inc/classes/forum/board-service-traits/trait-aksrt-forum-board-service-part-2.php @@ -0,0 +1,425 @@ +forum->is_plate_cpt_active()) { + return $this->get_plate_view_compat($board_id, $user_id, $page); + } + + $board = get_term($board_id, 'aksrt_forum_board'); + + if (!$board || is_wp_error($board)) { + return [ + 'board' => null, + 'topics' => [], + 'sticky_topics' => [], + 'moderators' => [], + 'pagination' => ['page' => 1, 'max_pages' => 1, 'total' => 0], + 'permissions' => [], + 'application' => null, + 'board_options' => [], + ]; + } + + $topics = $this->forum->get_topic_list([ + 'board_id' => $board_id, + 'page' => $page, + 'limit' => 20, + 'status' => 'publish', + 'viewer_user_id' => $user_id, + ]); + + $sticky_topics = $this->forum->get_sticky_topics_for_board($board_id); + $board_link = get_term_link($board); + + return [ + 'board' => [ + 'id' => $board_id, + 'name' => (string) $board->name, + 'description' => (string) $board->description, + 'content' => '', + 'icon' => (string) get_term_meta($board_id, '_aksrt_icon', true), + 'topic_count' => (int) $board->count, + 'reply_count' => 0, + 'today_reply_count' => 0, + 'views' => 0, + 'follow_count' => 0, + 'last_reply' => '', + 'last_post' => '', + 'url' => !is_wp_error($board_link) ? (string) $board_link : '', + 'thumbnail_url' => '', + 'author_id' => 0, + 'allow_view' => 'public', + ], + 'topics' => $topics['items'], + 'sticky_topics' => array_slice($sticky_topics, 0, 5), + 'moderators' => $this->forum->get_board_moderator_profiles($board_id), + 'pagination' => [ + 'page' => $topics['page'], + 'max_pages' => $topics['max_pages'], + 'total' => $topics['total'], + ], + 'permissions' => [ + 'can_compose' => $user_id > 0, + 'can_apply_moderator' => $user_id > 0 && !$this->forum->is_board_moderator($user_id, $board_id), + 'can_moderate' => $this->forum->is_board_moderator($user_id, $board_id), + ], + 'application' => $user_id > 0 ? $this->forum->get_board_application_for_user($user_id, $board_id) : null, + 'board_options' => $this->get_boards(), + ]; + } + + /** + * Plate CPT board view (backwards-compatible with get_board_view return shape). + */ + private function get_plate_view_compat(int $plate_id, int $user_id, int $page): array { + $plate = $this->get_plate($plate_id); + if (!$plate) { + return [ + 'board' => null, + 'topics' => [], + 'sticky_topics' => [], + 'moderators' => [], + 'pagination' => ['page' => 1, 'max_pages' => 1, 'total' => 0], + 'permissions' => [], + 'application' => null, + 'board_options' => [], + ]; + } + + // Check access permission + $allow_view = $plate['allow_view'] ?? 'public'; + if (!$this->can_view_plate($user_id, $plate_id, $allow_view)) { + return [ + 'board' => $plate, + 'topics' => [], + 'sticky_topics' => [], + 'moderators' => $this->get_plate_moderator_profiles($plate_id), + 'pagination' => ['page' => 1, 'max_pages' => 1, 'total' => 0], + 'permissions' => [ + 'can_compose' => false, + 'can_apply_moderator' => false, + 'can_moderate' => false, + 'access_denied' => true, + 'access_reason' => $allow_view, + ], + 'application' => null, + 'board_options' => $this->get_boards(), + ]; + } + + $topics = $this->forum->get_topic_list([ + 'plate_id' => $plate_id, + 'page' => $page, + 'limit' => 20, + 'status' => 'publish', + 'viewer_user_id' => $user_id, + ]); + + $sticky_topics = $this->get_sticky_topics_for_plate($plate_id); + + return [ + 'board' => $plate, + 'topics' => $topics['items'], + 'sticky_topics' => array_slice($sticky_topics, 0, 5), + 'moderators' => $this->get_plate_moderator_profiles($plate_id), + 'pagination' => [ + 'page' => $topics['page'], + 'max_pages' => $topics['max_pages'], + 'total' => $topics['total'], + ], + 'permissions' => [ + 'can_compose' => $user_id > 0 && $this->can_compose_in_plate($user_id, $plate_id), + 'can_apply_moderator' => $user_id > 0 && $this->forum->forum_user_can($user_id, 'moderator_apply'), + 'can_moderate' => $this->forum->forum_user_can($user_id, 'topic_moderate', $plate_id), + 'can_manage_plate' => $this->forum->forum_user_can($user_id, 'plate_manage', $plate_id), + 'can_manage_mods' => $this->forum->forum_user_can($user_id, 'moderator_manage', $plate_id), + 'user_tier' => $this->forum->get_user_plate_tier($user_id, $plate_id), + ], + 'application' => $user_id > 0 ? $this->forum->get_board_application_for_user($user_id, $plate_id) : null, + 'board_options' => $this->get_boards(), + ]; + } + + /** + * Check if a user can view a plate (§7.2 板块阅读权限). + */ + public function can_view_plate(int $user_id, int $plate_id, string $allow_view = ''): bool { + if ($allow_view === '') { + $allow_view = (string) (get_post_meta($plate_id, '_aksrt_allow_view', true) ?: 'public'); + } + + if ($allow_view === 'public') { + return true; + } + + if ($user_id <= 0) { + return false; + } + + if ($this->is_plate_moderator($user_id, $plate_id)) { + return true; + } + + if ($allow_view === 'login') { + return true; + } + + if ($allow_view === 'follow') { + return $this->is_user_following_plate($user_id, $plate_id); + } + + if ($allow_view === 'role') { + $allowed_roles = get_post_meta($plate_id, '_aksrt_allow_view_roles', true); + if (!is_array($allowed_roles) || empty($allowed_roles)) { + return true; + } + $user = get_userdata($user_id); + if (!$user) { + return false; + } + return !empty(array_intersect($user->roles, $allowed_roles)); + } + + return true; + } + + /** + * Check if a user can compose in a plate (§7.2 板块发帖限制). + */ + public function can_compose_in_plate(int $user_id, int $plate_id): bool { + if ($user_id <= 0) { + return false; + } + + if ($this->is_plate_moderator($user_id, $plate_id)) { + return true; + } + + $add_limit = (string) (get_post_meta($plate_id, '_aksrt_add_limit', true) ?: 'all'); + if ($add_limit === 'all') { + return true; + } + + $user = get_userdata($user_id); + if (!$user) { + return false; + } + + if ($add_limit === 'role') { + $allowed_roles = get_post_meta($plate_id, '_aksrt_add_limit_roles', true); + if (!is_array($allowed_roles) || empty($allowed_roles)) { + return true; + } + return !empty(array_intersect($user->roles, $allowed_roles)); + } + + return true; + } + + /** + * Check if a user is following a plate (§7.2 关注). + */ + public function is_user_following_plate(int $user_id, int $plate_id): bool { + if ($user_id <= 0) { + return false; + } + $follow_list = get_user_meta($user_id, 'aksrt_follow_plate', true); + if (!is_array($follow_list)) { + return false; + } + return in_array($plate_id, $follow_list, true); + } + + /** + * Toggle user follow on a plate (§7.2 关注/取消关注). + * + * @return array{followed:bool,follow_count:int} + */ + public function toggle_follow_plate(int $user_id, int $plate_id): array { + if ($user_id <= 0) { + return ['followed' => false, 'follow_count' => 0]; + } + + $follow_list = get_user_meta($user_id, 'aksrt_follow_plate', true); + if (!is_array($follow_list)) { + $follow_list = []; + } + + $followed = in_array($plate_id, $follow_list, true); + if ($followed) { + $follow_list = array_values(array_diff($follow_list, [$plate_id])); + } else { + $follow_list[] = $plate_id; + } + + update_user_meta($user_id, 'aksrt_follow_plate', array_values(array_unique(array_map('intval', $follow_list)))); + + // Update plate follow_count + $count = $this->count_plate_followers($plate_id); + update_post_meta($plate_id, '_aksrt_follow_count', $count); + + return [ + 'followed' => !$followed, + 'follow_count' => $count, + ]; + } + + /** + * Count followers for a plate. + */ + private function count_plate_followers(int $plate_id): int { + global $wpdb; + return (int) $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->usermeta} + WHERE meta_key = 'aksrt_follow_plate' + AND meta_value LIKE %s", + '%i:' . $plate_id . ';%' + )); + } + + /** + * Get plates a user follows. + */ + public function get_user_followed_plates(int $user_id): array { + $follow_list = get_user_meta($user_id, 'aksrt_follow_plate', true); + if (!is_array($follow_list) || empty($follow_list)) { + return []; + } + + $plates = get_posts([ + 'post_type' => 'aksrt_forum_plate', + 'post__in' => array_map('intval', $follow_list), + 'post_status' => 'publish', + 'posts_per_page' => 100, + 'orderby' => 'post__in', + ]); + + $result = []; + foreach ($plates as $plate) { + $result[] = $this->build_plate_payload($plate); + } + return $result; + } + + /** + * Get a user's managed plates (plates where user is moderator/author). + * + * @return array{owned:array,moderating:array} + */ + public function get_user_managed_plates(int $user_id): array { + $owned = []; + $moderating = []; + + if (!$this->forum->is_plate_cpt_active()) { + return ['owned' => $owned, 'moderating' => $moderating]; + } + + // Plates where user is author (超级版主) + $own_plates = get_posts([ + 'post_type' => 'aksrt_forum_plate', + 'author' => $user_id, + 'post_status' => 'publish', + 'posts_per_page' => 50, + 'orderby' => 'title', + 'order' => 'ASC', + ]); + foreach ($own_plates as $plate) { + $owned[] = $this->build_plate_payload($plate); + } + + // Plates where user is a moderator (in _aksrt_moderators list) + global $wpdb; + $mod_plate_ids = $wpdb->get_col($wpdb->prepare( + "SELECT pm.post_id FROM {$wpdb->postmeta} pm + INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id AND p.post_type = 'aksrt_forum_plate' AND p.post_status = 'publish' + WHERE pm.meta_key = '_aksrt_moderators'", + )); + + foreach ($mod_plate_ids as $plate_id) { + $plate_id = (int) $plate_id; + // Skip plates already in owned + if (in_array($plate_id, wp_list_pluck($own_plates, 'ID'), true)) { + continue; + } + $mods = get_post_meta($plate_id, '_aksrt_moderators', true); + if (is_array($mods) && in_array($user_id, $mods, true)) { + $plate = get_post($plate_id); + if ($plate) { + $moderating[] = $this->build_plate_payload($plate); + } + } + } + + return ['owned' => $owned, 'moderating' => $moderating]; + } + + // ── Plate utilities (extracted from facade) ── + + public function increment_plate_views(int $plate_id): void { + if ($plate_id <= 0 || !$this->forum->is_plate_cpt_active()) { + return; + } + $views = (int) get_post_meta($plate_id, '_aksrt_views', true); + update_post_meta($plate_id, '_aksrt_views', $views + 1); + } + + /** + * Check if a plate qualifies as "hot" (§7.2 热门版块). + */ + public function is_hot_plate(int $plate_id): bool { + $config = $this->forum->get_config(); + $threshold = (int) ($config['other']['hot_plate_threshold'] ?? 100); + $posts_count = (int) get_post_meta($plate_id, '_aksrt_posts_count', true); + $follow_count = (int) get_post_meta($plate_id, '_aksrt_follow_count', true); + return ($posts_count + $follow_count * 2) >= $threshold; + } + + /** + * Get plate categories (分区) with moderators. + */ + public function get_plate_categories(): array { + $terms = get_terms([ + 'taxonomy' => 'aksrt_plate_cat', + 'hide_empty' => false, + 'orderby' => 'name', + 'order' => 'ASC', + ]); + + if (is_wp_error($terms) || empty($terms)) { + return []; + } + + $categories = []; + foreach ($terms as $term) { + $term_id = (int) $term->term_id; + $moderator_ids = get_term_meta($term_id, '_aksrt_cat_moderator', true); + if (!is_array($moderator_ids)) { + $moderator_ids = []; + } + + $categories[] = [ + 'id' => $term_id, + 'name' => (string) $term->name, + 'description' => (string) $term->description, + 'slug' => (string) $term->slug, + 'count' => (int) $term->count, + 'parent_id' => (int) $term->parent, + 'moderators' => array_values(array_unique(array_filter(array_map('intval', $moderator_ids)))), + 'add_limit' => (string) (get_term_meta($term_id, '_aksrt_add_limit', true) ?: 'all'), + 'url' => (string) get_term_link($term), + ]; + } + + return $categories; + } +} diff --git a/inc/classes/forum/board-service-traits/trait-aksrt-forum-board-service-part-3.php b/inc/classes/forum/board-service-traits/trait-aksrt-forum-board-service-part-3.php new file mode 100644 index 00000000..2954e3e3 --- /dev/null +++ b/inc/classes/forum/board-service-traits/trait-aksrt-forum-board-service-part-3.php @@ -0,0 +1,137 @@ +forum->is_forum_admin($user_id)) { + return true; + } + $moderator_ids = get_term_meta($cat_id, '_aksrt_cat_moderator', true); + if (!is_array($moderator_ids)) { + return false; + } + return in_array($user_id, array_map('intval', $moderator_ids), true); + } + + /** + * Get plates sorted by a specified dimension (§7.2 多维排序). + */ + public function get_sorted_plates(string $orderby = 'menu_order', string $order = 'ASC', int $limit = 50): array { + $allowed = [ + 'name' => 'title', + 'date' => 'date', + 'modified' => 'modified', + 'menu_order' => 'menu_order', + 'last_post' => '_aksrt_last_post', + 'last_reply' => '_aksrt_last_reply', + 'posts_count' => '_aksrt_posts_count', + 'reply_count' => '_aksrt_reply_count', + 'today_reply_count' => '_aksrt_today_reply_count', + 'views' => '_aksrt_views', + 'follow_count' => '_aksrt_follow_count', + 'rand' => 'rand', + ]; + + $query_args = [ + 'post_type' => 'aksrt_forum_plate', + 'post_status' => 'publish', + 'posts_per_page' => $limit, + 'order' => strtoupper($order) === 'ASC' ? 'ASC' : 'DESC', + ]; + + if (isset($allowed[$orderby])) { + $meta_key = $allowed[$orderby]; + if (str_starts_with($meta_key, '_aksrt_')) { + $query_args['orderby'] = 'meta_value_num'; + $query_args['meta_key'] = $meta_key; + } else { + $query_args['orderby'] = $meta_key; + } + } else { + $query_args['orderby'] = 'menu_order'; + } + + $posts = get_posts($query_args); + $plates = []; + foreach ($posts as $post) { + $plates[] = $this->build_plate_payload($post); + } + return $plates; + } + + public function maybe_migrate_boards(): void { + if (get_option('aksrt_forum_boards_migrated')) { + return; + } + + $old_boards = get_option('aksrt_forum_boards', []); + if (!is_array($old_boards) || empty($old_boards)) { + update_option('aksrt_forum_boards_migrated', time()); + return; + } + + foreach ($old_boards as $board) { + $name = sanitize_text_field((string) ($board['name'] ?? '')); + if ($name === '') { + continue; + } + + $existing = get_terms([ + 'taxonomy' => 'aksrt_forum_board', + 'name' => $name, + 'hide_empty' => false, + 'fields' => 'ids', + ]); + + if (!is_wp_error($existing) && !empty($existing)) { + $term_id = (int) $existing[0]; + } else { + $result = wp_insert_term($name, 'aksrt_forum_board', [ + 'description' => sanitize_textarea_field((string) ($board['description'] ?? '')), + ]); + if (is_wp_error($result)) { + continue; + } + $term_id = (int) $result['term_id']; + } + + $icon = sanitize_text_field((string) ($board['icon'] ?? '')); + if ($icon !== '') { + update_term_meta($term_id, '_aksrt_icon', $icon); + } + + update_term_meta($term_id, '_aksrt_sort_order', (int) ($board['sort_order'] ?? $term_id)); + + if (!empty($board['moderators']) && is_array($board['moderators'])) { + update_term_meta($term_id, '_aksrt_moderators', array_map('intval', $board['moderators'])); + } + } + + update_option('aksrt_forum_boards_migrated', time()); + } + + public function ajax_follow_plate(): void { + check_ajax_referer('aksrt_forum_action', '_nonce'); + + $user_id = get_current_user_id(); + if ($user_id <= 0) { + wp_send_json_error(['message' => __('请先登录', 'aksrt')]); + } + + $plate_id = (int) ($_POST['plate_id'] ?? 0); + if ($plate_id <= 0) { + wp_send_json_error(['message' => __('版块不存在', 'aksrt')]); + } + + $result = $this->toggle_follow_plate($user_id, $plate_id); + wp_send_json_success($result); + } +} diff --git a/inc/classes/forum/class-aksrt-forum-board-service.php b/inc/classes/forum/class-aksrt-forum-board-service.php new file mode 100644 index 00000000..7bba77f0 --- /dev/null +++ b/inc/classes/forum/class-aksrt-forum-board-service.php @@ -0,0 +1,399 @@ +forum = $forum; + } + + /** + * Get board list (plate CPT). + * + * 读取 aksrt_forum_plate CPT,支持层级查询。 + * Fallback:若 plate CPT 不存在或迁移未完成,降级读取 taxonomy。 + */ + public function get_boards(array $args = []): array { + // 优先使用 plate CPT + if ($this->forum->is_plate_cpt_active()) { + return $this->get_boards_from_cpt($args); + } + + // Fallback: 读取旧 taxonomy(向后兼容) + return $this->get_boards_from_taxonomy(); + } + + /** + * 从 plate CPT 读取板块列表(新实现)。 + */ + private function get_boards_from_cpt(array $args = []): array { + $defaults = [ + 'post_type' => 'aksrt_forum_plate', + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'orderby' => 'menu_order', + 'order' => 'ASC', + 'hierarchical' => true, + ]; + + $query_args = wp_parse_args($args, $defaults); + $plates = get_posts($query_args); + + if (empty($plates)) { + return []; + } + + $boards = []; + foreach ($plates as $plate) { + $plate_id = (int) $plate->ID; + $moderators = $this->forum->get_plate_moderators($plate_id); + $applications = $this->forum->get_plate_moderator_applications($plate_id); + + $boards[] = [ + 'id' => $plate_id, + 'name' => (string) $plate->post_title, + 'description' => (string) $plate->post_excerpt, + 'content' => (string) $plate->post_content, + 'icon' => (string) get_post_meta($plate_id, '_aksrt_icon', true), + 'sort_order' => (int) $plate->menu_order, + 'post_count' => (int) get_post_meta($plate_id, '_aksrt_posts_count', true), + 'reply_count' => (int) get_post_meta($plate_id, '_aksrt_reply_count', true), + 'today_reply_count' => (int) get_post_meta($plate_id, '_aksrt_today_reply_count', true), + 'views' => (int) get_post_meta($plate_id, '_aksrt_views', true), + 'follow_count' => (int) get_post_meta($plate_id, '_aksrt_follow_count', true), + 'last_reply' => (string) get_post_meta($plate_id, '_aksrt_last_reply', true), + 'last_post' => (string) get_post_meta($plate_id, '_aksrt_last_post', true), + 'moderator_count' => count($moderators), + 'pending_application_count' => count(array_filter($applications, static function ($item): bool { + return (string) ($item['status'] ?? '') === AKSRT_Forum::APPLICATION_PENDING; + })), + 'url' => (string) get_permalink($plate), + 'author_id' => (int) $plate->post_author, + 'parent_id' => (int) $plate->post_parent, + 'created_at' => (string) $plate->post_date, + 'thumbnail_url' => (string) get_the_post_thumbnail_url($plate, 'thumbnail'), + ]; + } + + return $this->build_hierarchical_boards($boards); + } + + /** + * 从 taxonomy 读取板块(向后兼容)。 + */ + private function get_boards_from_taxonomy(): array { + $terms = get_terms([ + 'taxonomy' => 'aksrt_forum_board', + 'hide_empty' => false, + 'orderby' => 'meta_value_num', + 'meta_key' => '_aksrt_sort_order', + 'order' => 'ASC', + ]); + + if (is_wp_error($terms) || empty($terms)) { + return []; + } + + $boards = []; + foreach ($terms as $term) { + $board_id = (int) $term->term_id; + $moderators = $this->forum->get_board_moderators($board_id); + $applications = $this->forum->get_board_moderator_applications($board_id); + $board_link = get_term_link($term); + + $boards[] = [ + 'id' => $board_id, + 'name' => (string) $term->name, + 'description' => (string) $term->description, + 'content' => '', + 'icon' => (string) get_term_meta($board_id, '_aksrt_icon', true), + 'sort_order' => (int) get_term_meta($board_id, '_aksrt_sort_order', true), + 'post_count' => (int) $term->count, + 'reply_count' => 0, + 'today_reply_count' => 0, + 'views' => 0, + 'follow_count' => 0, + 'last_reply' => '', + 'last_post' => '', + 'moderator_count' => count($moderators), + 'pending_application_count' => count(array_filter($applications, static function ($item): bool { + return (string) ($item['status'] ?? '') === AKSRT_Forum::APPLICATION_PENDING; + })), + 'url' => !is_wp_error($board_link) ? (string) $board_link : '', + 'author_id' => 0, + 'parent_id' => (int) $term->parent, + 'created_at' => '', + 'thumbnail_url' => '', + ]; + } + + return $boards; + } + + /** + * 构建层级板块树(子板块嵌套)。 + */ + private function build_hierarchical_boards(array $boards): array { + $by_id = []; + foreach ($boards as $board) { + $by_id[$board['id']] = $board; + $by_id[$board['id']]['children'] = []; + } + + $roots = []; + foreach ($boards as $board) { + if ($board['parent_id'] > 0 && isset($by_id[$board['parent_id']])) { + $by_id[$board['parent_id']]['children'][] = &$by_id[$board['id']]; + } else { + $roots[] = &$by_id[$board['id']]; + } + } + + return $roots; + } + + /** + * Create board (统一入口,自动选择 CPT/taxonomy). + */ + public function create_board(array $data): int { + if ($this->forum->is_plate_cpt_active()) { + return $this->create_plate($data); + } + + // Fallback: taxonomy + $name = sanitize_text_field((string) ($data['name'] ?? '')); + if ($name === '') { + return 0; + } + + $result = wp_insert_term($name, 'aksrt_forum_board', [ + 'description' => sanitize_textarea_field((string) ($data['description'] ?? '')), + ]); + + if (is_wp_error($result)) { + return 0; + } + + $term_id = (int) $result['term_id']; + $icon = sanitize_text_field((string) ($data['icon'] ?? '')); + $sort_order = (int) ($data['sort_order'] ?? $term_id); + + if ($icon !== '') { + update_term_meta($term_id, '_aksrt_icon', $icon); + } + + update_term_meta($term_id, '_aksrt_sort_order', $sort_order); + + return $term_id; + } + + /** + * Update board (统一入口,自动选择 CPT/taxonomy). + */ + public function update_board(int $board_id, array $data): bool { + if ($this->forum->is_plate_cpt_active()) { + return $this->update_plate($board_id, $data); + } + + // Fallback: taxonomy + $term = get_term($board_id, 'aksrt_forum_board'); + if (!$term || is_wp_error($term)) { + return false; + } + + $update = ['taxonomy' => 'aksrt_forum_board']; + + if (isset($data['name'])) { + $name = sanitize_text_field((string) $data['name']); + if ($name !== '') { + $update['name'] = $name; + } + } + + if (isset($data['description'])) { + $update['description'] = sanitize_textarea_field((string) $data['description']); + } + + $result = wp_update_term($board_id, 'aksrt_forum_board', $update); + if (is_wp_error($result)) { + return false; + } + + if (isset($data['icon'])) { + update_term_meta($board_id, '_aksrt_icon', sanitize_text_field((string) $data['icon'])); + } + + if (isset($data['sort_order'])) { + update_term_meta($board_id, '_aksrt_sort_order', (int) $data['sort_order']); + } + + return true; + } + + /** + * Delete board (统一入口,自动选择 CPT/taxonomy). + */ + public function delete_board(int $board_id): bool { + if ($this->forum->is_plate_cpt_active()) { + return $this->delete_plate($board_id); + } + + // Fallback: taxonomy + $term = get_term($board_id, 'aksrt_forum_board'); + if (!$term || is_wp_error($term)) { + return false; + } + + $result = wp_delete_term($board_id, 'aksrt_forum_board'); + return !is_wp_error($result) && !empty($result); + } + + /** + * Reorder boards (统一入口). + */ + public function reorder_boards(array $order_map): bool { + if ($this->forum->is_plate_cpt_active()) { + return $this->reorder_plates($order_map); + } + + foreach ($order_map as $term_id => $sort_order) { + $term_id = (int) $term_id; + if ($term_id <= 0) { + continue; + } + + update_term_meta($term_id, '_aksrt_sort_order', (int) $sort_order); + } + + return true; + } + + // ================================================================ + // §7.0 Plate CPT methods —板块 CPT 化 CRUD + // ================================================================ + + /** + * Get all plates (板块 CPT posts). + * + * @return array> + */ + public function get_plates(array $args = []): array { + $query_args = [ + 'post_type' => 'aksrt_forum_plate', + 'post_status' => 'publish', + 'posts_per_page' => (int) ($args['limit'] ?? 100), + 'orderby' => 'menu_order', + 'order' => 'ASC', + ]; + + if (!empty($args['parent'])) { + $query_args['post_parent'] = (int) $args['parent']; + } + + if (!empty($args['plate_cat'])) { + $query_args['tax_query'] = [[ + 'taxonomy' => 'aksrt_plate_cat', + 'field' => 'term_id', + 'terms' => (int) $args['plate_cat'], + ]]; + } + + $posts = get_posts($query_args); + $plates = []; + + foreach ($posts as $post) { + $plates[] = $this->build_plate_payload($post); + } + + return $plates; + } + + /** + * Get single plate by ID. + */ + public function get_plate(int $plate_id): ?array { + $post = get_post($plate_id); + if (!$post || $post->post_type !== 'aksrt_forum_plate') { + return null; + } + + return $this->build_plate_payload($post); + } + + /** + * Build plate data payload from WP_Post. + */ + public function build_plate_payload(\WP_Post $post): array { + $plate_id = (int) $post->ID; + $moderators = get_post_meta($plate_id, '_aksrt_moderators', true); + if (!is_array($moderators)) { + $moderators = []; + } + + $cover_url = (string) get_the_post_thumbnail_url($plate_id, 'medium'); + if ($cover_url === '') { + $config = $this->forum->get_config(); + $default_thumbs = $config['other']['bbs_term_thumb'] ?? []; + if (!empty($default_thumbs) && is_array($default_thumbs)) { + $cover_url = $default_thumbs[array_rand($default_thumbs)]; + } + } + + $plate_cats = wp_get_object_terms($plate_id, 'aksrt_plate_cat', ['fields' => 'all']); + $plate_cat = (!is_wp_error($plate_cats) && !empty($plate_cats)) ? [ + 'id' => (int) $plate_cats[0]->term_id, + 'name' => (string) $plate_cats[0]->name, + 'slug' => (string) $plate_cats[0]->slug, + ] : null; + + return [ + 'id' => $plate_id, + 'name' => (string) $post->post_title, + 'description' => (string) $post->post_excerpt, + 'content' => (string) $post->post_content, + 'icon' => (string) get_post_meta($plate_id, '_aksrt_icon', true), + 'cover_url' => $cover_url, + 'sort_order' => (int) $post->menu_order, + 'parent_id' => (int) $post->post_parent, + 'author_id' => (int) $post->post_author, + 'post_count' => (int) get_post_meta($plate_id, '_aksrt_posts_count', true), + 'reply_count' => (int) get_post_meta($plate_id, '_aksrt_reply_count', true), + 'today_reply_count' => (int) get_post_meta($plate_id, '_aksrt_today_reply_count', true), + 'views' => (int) get_post_meta($plate_id, '_aksrt_views', true), + 'follow_count' => (int) get_post_meta($plate_id, '_aksrt_follow_count', true), + 'last_reply' => (string) get_post_meta($plate_id, '_aksrt_last_reply', true), + 'last_post' => (string) get_post_meta($plate_id, '_aksrt_last_post', true), + 'moderator_count' => count($moderators), + 'moderators' => $moderators, + 'url' => (string) get_permalink($plate_id), + 'created_at' => (string) $post->post_date, + 'allow_view' => (string) (get_post_meta($plate_id, '_aksrt_allow_view', true) ?: 'public'), + 'add_limit' => (string) (get_post_meta($plate_id, '_aksrt_add_limit', true) ?: 'all'), + 'is_hot' => $this->forum->is_hot_plate($plate_id), + 'plate_cat' => $plate_cat, + // Alias fields for backward compat with get_board_view consumer + 'topic_count' => (int) get_post_meta($plate_id, '_aksrt_posts_count', true), + 'thumbnail_url' => $cover_url, + ]; + } + + /** + * Create plate (CPT post). + */ +} diff --git a/inc/classes/forum/class-aksrt-forum-comment-service.php b/inc/classes/forum/class-aksrt-forum-comment-service.php new file mode 100644 index 00000000..225412e0 --- /dev/null +++ b/inc/classes/forum/class-aksrt-forum-comment-service.php @@ -0,0 +1,583 @@ +forum = $forum; + } + + // ── Hook: forum comment posted ─────────────────────────────────── + + public function on_forum_comment_posted(int $comment_id, $approved, array $commentdata = []): void { + $comment = get_comment($comment_id); + if (!$comment) return; + $post = get_post((int) $comment->comment_post_ID); + if (!$post || $post->post_type !== 'aksrt_forum_topic') return; + if ((int) $comment->comment_approved !== 1) return; + $commenter_id = (int) $comment->user_id; + if ($commenter_id <= 0) return; + $this->forum->notify_topic_reply((int) $post->ID, $commenter_id); + } + + // ── AJAX: get forum comments ───────────────────────────────────── + + /** + * AJAX: Get forum comments with sort/filter/pagination. + */ + public function ajax_forum_comments(): void { + check_ajax_referer('aksrt_public_nonce', 'nonce'); + + $topic_id = (int) ($_POST['topic_id'] ?? 0); + $sort = sanitize_key((string) ($_POST['sort'] ?? 'newest')); + $sort = in_array($sort, ['newest', 'oldest', 'hottest'], true) ? $sort : 'newest'; + $author_only = !empty($_POST['author_only']); + $page = max(1, (int) ($_POST['page'] ?? 1)); + $per_page = max(1, min(100, (int) ($_POST['per_page'] ?? 30))); + + if ($topic_id <= 0) { + wp_send_json_error(['message' => __('参数无效', 'aksrt')]); + } + + $result = $this->get_forum_comments($topic_id, $sort, $author_only, $page, $per_page); + + ob_start(); + $this->render_forum_comments($result['comments'], $topic_id, $result['adopted_id']); + $html = ob_get_clean(); + + wp_send_json_success([ + 'html' => $html, + 'total' => $result['total'], + 'max_pages' => $result['max_pages'], + 'page' => $result['page'], + 'has_more' => $result['page'] < $result['max_pages'], + ]); + } + + // ── AJAX: comment topping ──────────────────────────────────────── + + /** + * AJAX: Set comment topping level (moderator action). + */ + public function ajax_comment_topping(): void { + check_ajax_referer('aksrt_public_nonce', 'nonce'); + + $comment_id = (int) ($_POST['comment_id'] ?? 0); + $level = max(0, min(3, (int) ($_POST['level'] ?? 0))); + $user_id = get_current_user_id(); + + $comment = get_comment($comment_id); + if (!$comment) { + wp_send_json_error(['message' => __('评论不存在', 'aksrt')]); + } + + $topic_id = (int) $comment->comment_post_ID; + if (!$this->forum->forum_user_can($user_id, 'reply_top', $topic_id)) { + wp_send_json_error(['message' => __('权限不足', 'aksrt')]); + } + + if ($this->set_comment_topping($comment_id, $level)) { + wp_send_json_success([ + 'message' => $level > 0 ? __('已置顶', 'aksrt') : __('已取消置顶', 'aksrt'), + 'level' => $level, + ]); + } + + wp_send_json_error(['message' => __('操作失败', 'aksrt')]); + } + + // ── Render forum comments ──────────────────────────────────────── + + /** + * Render forum comments HTML for AJAX and initial load. + * + * @param WP_Comment[] $comments Comments (parents + children mixed). + * @param int $topic_id Topic post ID. + * @param int $adopted_id Adopted comment ID (0 if none). + */ + public function render_forum_comments(array $comments, int $topic_id, int $adopted_id = 0): void { + $post = get_post($topic_id); + $post_author_id = $post ? (int) $post->post_author : 0; + $current_user_id = get_current_user_id(); + $is_question = 'question' === (string) get_post_meta($topic_id, '_aksrt_bbs_type', true); + $can_adopt = $is_question && $current_user_id > 0 + && ($current_user_id === $post_author_id || $this->forum->forum_user_can($current_user_id, 'reply_adopt_other', $topic_id)); + $can_moderate = $this->forum->forum_user_can($current_user_id, 'topic_moderate', $topic_id); + + if (empty($comments)) { + echo '
' . esc_html__('暂无回复,来抢沙发吧!', 'aksrt') . '
'; + return; + } + + foreach ($comments as $comment) { + if (!($comment instanceof \WP_Comment)) continue; + if ((int) $comment->comment_parent !== 0) continue; + + $comment_id = (int) $comment->comment_ID; + $commenter_uid = (int) $comment->user_id; + $is_adopted = $comment_id === $adopted_id; + $is_hot = $this->is_hot_comment($comment_id); + $is_post_author = $commenter_uid > 0 && $commenter_uid === $post_author_id; + $topping = (int) get_comment_meta($comment_id, '_aksrt_comment_topping', true); + $badge = $this->get_commenter_badge($commenter_uid, $topic_id); + + $like_count = function_exists('aksrt_get_comment_likes') ? aksrt_get_comment_likes($comment_id) : 0; + $is_liked = function_exists('aksrt_is_comment_liked') ? aksrt_is_comment_liked($comment_id) : false; + + $classes = ['aksrt-forum-reply']; + if ($is_adopted) $classes[] = 'is-adopted'; + if ($is_hot) $classes[] = 'is-hot'; + if ($topping > 0) $classes[] = 'is-pinned'; + ?> +
+
+
+
+ + + + + + + + 0) : ?> + + + + + + + + + +
+ +
+
+ + + + + + + + + + + + + +
+ + + comment_parent === $comment_id; + }); + if (!empty($child_comments)) : ?> +
+ comment_ID; + $child_uid = (int) $child->user_id; + $child_badge = $this->get_commenter_badge($child_uid, $topic_id); + $child_is_author = $child_uid > 0 && $child_uid === $post_author_id; + $child_like_count = function_exists('aksrt_get_comment_likes') ? aksrt_get_comment_likes($child_id) : 0; + $child_is_liked = function_exists('aksrt_is_comment_liked') ? aksrt_is_comment_liked($child_id) : false; + $parent_comment = get_comment((int) $child->comment_parent); + $parent_name = $parent_comment ? get_comment_author((int) $parent_comment->comment_ID) : ''; + ?> +
+
+
+
+ + + + + + + + comment_parent !== $comment_id) : ?> + + + +
+
+
+ + + + +
+
+
+ +
+ +
+
+ post_type !== 'aksrt_forum_topic') { + return new \WP_Error('invalid_topic', __('帖子不存在', 'aksrt')); + } + + $bbs_type = sanitize_key((string) get_post_meta($topic_id, '_aksrt_bbs_type', true)); + if ($bbs_type !== 'question') { + return new \WP_Error('not_question', __('该帖不是提问类型', 'aksrt')); + } + + $author_id = (int) $post->post_author; + if ($operator_id !== $author_id && !$this->forum->can_moderate_topic($operator_id, $topic_id)) { + return new \WP_Error('forbidden', __('仅提问者或版主可采纳答案', 'aksrt')); + } + + $comment = get_comment($comment_id); + if (!$comment || (int) $comment->comment_post_ID !== $topic_id) { + return new \WP_Error('invalid_comment', __('回复不存在', 'aksrt')); + } + + $existing = get_post_meta($topic_id, '_aksrt_adopted_comment', true); + if ($existing && (int) $existing === $comment_id) { + return new \WP_Error('already_adopted', __('该回复已被采纳', 'aksrt')); + } + + if ($existing) { + delete_comment_meta((int) $existing, '_aksrt_adopted'); + } + + update_comment_meta($comment_id, '_aksrt_adopted', '1'); + update_post_meta($topic_id, '_aksrt_adopted_comment', $comment_id); + update_post_meta($topic_id, '_aksrt_question_status', 1); // QUESTION_SOLVED + + $adoptee_id = (int) $comment->user_id; + if ($adoptee_id > 0 && class_exists('AKSRT_Credits')) { + $config = $this->forum->get_config(); + $amount = (int) ($config['credits']['adopted'] ?? 20); + if ($amount > 0) { + $already = get_comment_meta($comment_id, '_aksrt_adopt_credited', true); + if (!$already) { + \AKSRT_Credits::add($adoptee_id, $amount, 'forum_adopted', [ + 'type' => 'comment', + 'id' => $comment_id, + ]); + update_comment_meta($comment_id, '_aksrt_adopt_credited', '1'); + } + } + + $this->forum->notify($adoptee_id, 'system', sprintf( + __('你的回复被采纳为最佳答案(帖子「%s」)', 'aksrt'), + get_the_title($topic_id) + ), [ + 'title' => __('回复被采纳', 'aksrt'), + 'target_url' => get_permalink($topic_id) . '#comment-' . $comment_id, + ]); + } + + return [ + 'message' => __('已采纳该回复为最佳答案', 'aksrt'), + 'comment_id' => $comment_id, + 'solved' => true, + ]; + } + + + + public function get_adopted_comment_id(int $topic_id): int { + return (int) get_post_meta($topic_id, '_aksrt_adopted_comment', true); + } + + // ── Get forum comments (query) ────────────────────────────────── + + /** + * Get forum comments with sort/filter support. + * + * @param int $topic_id Topic post ID. + * @param string $sort 'newest'|'oldest'|'hottest' + * @param bool $author_only Only show post-author replies. + * @param int $page Page number. + * @param int $per_page Items per page. + * @return array{comments:WP_Comment[],total:int,max_pages:int,page:int,adopted_id:int} + */ + public function get_forum_comments(int $topic_id, string $sort = 'newest', bool $author_only = false, int $page = 1, int $per_page = 30): array { + $post = get_post($topic_id); + if (!$post || $post->post_type !== 'aksrt_forum_topic') { + return ['comments' => [], 'total' => 0, 'max_pages' => 1, 'page' => 1, 'adopted_id' => 0]; + } + + $args = [ + 'post_id' => $topic_id, + 'status' => 'approve', + 'parent' => 0, + 'number' => $per_page, + 'offset' => ($page - 1) * $per_page, + ]; + + if ($author_only) { + $args['user_id'] = (int) $post->post_author; + } + + if ($sort === 'hottest') { + $args['meta_key'] = 'aksrt_comment_likes'; + $args['orderby'] = 'meta_value_num'; + $args['order'] = 'DESC'; + } elseif ($sort === 'oldest') { + $args['orderby'] = 'comment_date_gmt'; + $args['order'] = 'ASC'; + } else { + $args['orderby'] = 'comment_date_gmt'; + $args['order'] = 'DESC'; + } + + // Total count for pagination + $count_args = $args; + unset($count_args['number'], $count_args['offset']); + $count_args['count'] = true; + $total = (int) get_comments($count_args); + + $top_level = get_comments($args); + + + // Fetch child comments for these parents + if (!empty($top_level)) { + $parent_ids = wp_list_pluck($top_level, 'comment_ID'); + $children = get_comments([ + 'post_id' => $topic_id, + 'status' => 'approve', + 'parent__in' => $parent_ids, + 'orderby' => 'comment_date_gmt', + 'order' => 'ASC', + 'number' => 200, + ]); + } else { + $children = []; + } + + // Float pinned top-level comments + $pinned = []; + $normal = []; + foreach ($top_level as $c) { + $topping = (int) get_comment_meta((int) $c->comment_ID, '_aksrt_comment_topping', true); + if ($topping > 0) { + $c->aksrt_topping = $topping; + $pinned[] = $c; + } else { + $normal[] = $c; + } + } + usort($pinned, function ($a, $b) { + return ($b->aksrt_topping ?? 0) <=> ($a->aksrt_topping ?? 0); + }); + $comments = array_merge($pinned, $normal, $children); + + return [ + 'comments' => $comments, + 'total' => $total, + 'max_pages' => max(1, (int) ceil($total / $per_page)), + 'page' => $page, + 'adopted_id' => $this->get_adopted_comment_id($topic_id), + ]; + } + + // ── Set comment topping ───────────────────────────────────────── + + /** + * Set comment topping level. + * + * @param int $comment_id Comment ID. + * @param int $level 0=unpin, 1=normal pin, 2=hot pin, 3=best answer pin. + */ + public function set_comment_topping(int $comment_id, int $level): bool { + $comment = get_comment($comment_id); + if (!$comment) { + return false; + } + if ($level <= 0) { + delete_comment_meta($comment_id, '_aksrt_comment_topping'); + delete_comment_meta($comment_id, '_aksrt_comment_pinned'); + } else { + update_comment_meta($comment_id, '_aksrt_comment_topping', $level); + update_comment_meta($comment_id, '_aksrt_comment_pinned', '1'); + } + return true; + } + + + + // ── Maybe mark hot comment ────────────────────────────────────── + + /** + * Check and mark a comment as hot ("神评") if it crosses the like threshold. + * + * @return bool True if newly marked as hot. + */ + public function maybe_mark_hot_comment(int $comment_id): bool { + $comment = get_comment($comment_id); + if (!$comment || '1' !== (string) $comment->comment_approved) { + return false; + } + + $post = get_post((int) $comment->comment_post_ID); + if (!$post || $post->post_type !== 'aksrt_forum_topic') { + return false; + } + + $already_hot = '1' === (string) get_comment_meta($comment_id, '_aksrt_is_hot', true); + if ($already_hot) { + return false; + } + + $config = $this->forum->get_config(); + $threshold = max(1, (int) ($config['reply']['hot_threshold'] ?? 10)); + $likes = max(0, (int) get_comment_meta($comment_id, 'aksrt_comment_likes', true)); + + if ($likes < $threshold) { + return false; + } + + update_comment_meta($comment_id, '_aksrt_is_hot', '1'); + + // Credit the comment author + $author_id = (int) $comment->user_id; + if ($author_id > 0 && class_exists('AKSRT_Credits')) { + $amount = (int) ($config['credits']['comment_like'] ?? 1); + if ($amount > 0) { + \AKSRT_Credits::add($author_id, $amount, 'forum_hot_comment', [ + 'type' => 'comment', + 'id' => $comment_id, + ]); + } + } + + // Notify the author + if ($author_id > 0) { + $this->forum->notify($author_id, 'system', sprintf( + __('你的回复成为了神评(帖子「%s」)', 'aksrt'), + get_the_title((int) $comment->comment_post_ID) + ), [ + 'title' => __('恭喜获得神评', 'aksrt'), + 'target_url' => get_permalink((int) $comment->comment_post_ID) . '#comment-' . $comment_id, + ]); + } + + return true; + } + + // ── Is hot comment ────────────────────────────────────────────── + + /** + * Check if a comment is a hot comment ("神评"). + */ + public function is_hot_comment(int $comment_id): bool { + return '1' === (string) get_comment_meta($comment_id, '_aksrt_is_hot', true); + } + + // ── Get commenter badge ───────────────────────────────────────── + + /** + * Get the user's moderator tier label for badge rendering in comments. + * + * @return array{tier:int,label:string,class:string}|null + */ + public function get_commenter_badge(int $user_id, int $topic_id): ?array { + if ($user_id <= 0) { + return null; + } + + if ($this->forum->is_forum_admin($user_id)) { + return [ + 'tier' => AKSRT_Forum::TIER_FORUM_ADMIN, + 'label' => __('管理员', 'aksrt'), + 'class' => 'aksrt-forum-reply__role-badge--admin', + ]; + } + + $plate_id = $this->forum->get_topic_plate_id($topic_id); + if ($plate_id > 0) { + $tier = $this->forum->get_user_plate_tier($user_id, $plate_id); + if ($tier >= AKSRT_Forum::TIER_CAT_MOD) { + $names = $this->forum->get_config()['names'] ?? []; + return [ + 'tier' => $tier, + 'label' => $names['cat_moderator'] ?? __('分区版主', 'aksrt'), + 'class' => 'aksrt-forum-reply__role-badge--catmod', + ]; + } + if ($tier >= AKSRT_Forum::TIER_PLATE_AUTHOR) { + $names = $this->forum->get_config()['names'] ?? []; + return [ + 'tier' => $tier, + 'label' => $names['plate_author'] ?? __('超级版主', 'aksrt'), + 'class' => 'aksrt-forum-reply__role-badge--super', + ]; + } + if ($tier >= AKSRT_Forum::TIER_MODERATOR) { + $names = $this->forum->get_config()['names'] ?? []; + return [ + 'tier' => $tier, + 'label' => $names['plate_moderator'] ?? __('版主', 'aksrt'), + 'class' => 'aksrt-forum-reply__role-badge--mod', + ]; + } + } + + return null; + } +} diff --git a/inc/classes/forum/class-aksrt-forum-draft-service.php b/inc/classes/forum/class-aksrt-forum-draft-service.php new file mode 100644 index 00000000..43df7cb7 --- /dev/null +++ b/inc/classes/forum/class-aksrt-forum-draft-service.php @@ -0,0 +1,409 @@ +forum = $forum; + } + + public function save_draft(int $user_id, array $data) { + if ($user_id <= 0) { + return new WP_Error('not_logged_in', __('请先登录', 'aksrt')); + } + + $draft_id = (int) ($data['draft_id'] ?? 0); + + $post_arr = [ + 'post_type' => 'aksrt_forum_topic', + 'post_status' => 'draft', + 'post_author' => $user_id, + 'post_title' => sanitize_text_field((string) ($data['title'] ?? '')), + 'post_content' => wp_kses_post((string) ($data['content'] ?? '')), + ]; + + if ($draft_id > 0) { + $existing = get_post($draft_id); + if (!$existing || $existing->post_type !== 'aksrt_forum_topic' + || (int) $existing->post_author !== $user_id + || $existing->post_status !== 'draft') { + return new WP_Error('invalid_draft', __('草稿不存在或无权编辑', 'aksrt')); + } + $post_arr['ID'] = $draft_id; + $result = wp_update_post($post_arr, true); + } else { + $result = wp_insert_post($post_arr, true); + } + + if (is_wp_error($result)) { + return $result; + } + + $post_id = (int) $result; + + $board_id = (int) ($data['board_id'] ?? 0); + if ($board_id > 0) { + if ($this->forum->is_plate_cpt_active()) { + update_post_meta($post_id, '_aksrt_plate_id', $board_id); + } else { + wp_set_object_terms($post_id, [$board_id], 'aksrt_forum_board'); + } + } + + $tags = is_array($data['tags'] ?? null) ? $data['tags'] : []; + if (!empty($tags)) { + $clean_tags = array_slice(array_values(array_filter(array_map('sanitize_text_field', $tags))), 0, 5); + wp_set_object_terms($post_id, $clean_tags, 'aksrt_forum_tag'); + } + + $meta_fields = [ + 'visibility' => '_aksrt_visibility', + 'bbs_type' => '_aksrt_bbs_type', + 'special_topic' => '_aksrt_special_topic', + 'cover_type' => '_aksrt_cover_type', + ]; + foreach ($meta_fields as $key => $meta_key) { + if (isset($data[$key])) { + update_post_meta($post_id, $meta_key, sanitize_key((string) $data[$key])); + } + } + + if (isset($data['credits_cost'])) { + update_post_meta($post_id, '_aksrt_credits_cost', max(0, (int) $data['credits_cost'])); + } + if (isset($data['pay_price'])) { + update_post_meta($post_id, '_aksrt_pay_price', sanitize_text_field((string) $data['pay_price'])); + } + if (isset($data['visibility_roles']) && is_array($data['visibility_roles'])) { + update_post_meta($post_id, '_aksrt_visibility_roles', array_map('sanitize_key', $data['visibility_roles'])); + } + + $this->forum->save_cover_meta($post_id, $data); + + if (!empty($data['vote_enabled'])) { + $vote_meta = $this->forum->prepare_vote_meta($data); + $this->forum->save_vote_meta($post_id, $vote_meta); + } + + if (isset($data['topic_cat'])) { + $topic_cat = sanitize_text_field((string) $data['topic_cat']); + if ($topic_cat !== '' && taxonomy_exists('aksrt_forum_topic_cat')) { + wp_set_object_terms($post_id, [$topic_cat], 'aksrt_forum_topic_cat'); + } + } + + return [ + 'post_id' => $post_id, + 'message' => __('草稿已保存', 'aksrt'), + ]; + } + + public function get_user_draft_count(int $user_id): int { + if ($user_id <= 0) { + return 0; + } + global $wpdb; + return (int) $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'aksrt_forum_topic' AND post_status = 'draft' AND post_author = %d", + $user_id + )); + } + + /** + * @return array + */ + public function get_user_drafts(int $user_id, int $limit = 20): array { + if ($user_id <= 0) { + return []; + } + + $query = new WP_Query([ + 'post_type' => 'aksrt_forum_topic', + 'post_status' => 'draft', + 'author' => $user_id, + 'posts_per_page' => $limit, + 'orderby' => 'modified', + 'order' => 'DESC', + 'no_found_rows' => true, + ]); + + $drafts = []; + while ($query->have_posts()) { + $query->the_post(); + $post = get_post(); + if (!$post) { + continue; + } + + $board_name = ''; + if ($this->forum->is_plate_cpt_active()) { + $plate_id = (int) get_post_meta($post->ID, '_aksrt_plate_id', true); + if ($plate_id > 0) { + $board_name = get_the_title($plate_id); + } + } else { + $terms = wp_get_post_terms($post->ID, 'aksrt_forum_board', ['fields' => 'names']); + if (!is_wp_error($terms) && !empty($terms)) { + $board_name = $terms[0]; + } + } + + $drafts[] = [ + 'id' => $post->ID, + 'title' => $post->post_title ?: __('(无标题)', 'aksrt'), + 'date' => $post->post_modified, + 'board_name' => $board_name, + ]; + } + wp_reset_postdata(); + + return $drafts; + } + + public function delete_draft(int $user_id, int $draft_id): bool { + if ($user_id <= 0 || $draft_id <= 0) { + return false; + } + $post = get_post($draft_id); + if (!$post || $post->post_type !== 'aksrt_forum_topic' + || (int) $post->post_author !== $user_id + || $post->post_status !== 'draft') { + return false; + } + return (bool) wp_delete_post($draft_id, true); + } + + /** + * Load a draft's full data for the compose form. + */ + public function load_draft_data(int $user_id, int $draft_id): ?array { + if ($user_id <= 0 || $draft_id <= 0) { + return null; + } + $post = get_post($draft_id); + if (!$post || $post->post_type !== 'aksrt_forum_topic' + || (int) $post->post_author !== $user_id + || $post->post_status !== 'draft') { + return null; + } + + $board_id = 0; + if ($this->forum->is_plate_cpt_active()) { + $board_id = (int) get_post_meta($draft_id, '_aksrt_plate_id', true); + } else { + $terms = wp_get_post_terms($draft_id, 'aksrt_forum_board', ['fields' => 'ids']); + if (!is_wp_error($terms) && !empty($terms)) { + $board_id = (int) $terms[0]; + } + } + + $tags = wp_get_post_terms($draft_id, 'aksrt_forum_tag', ['fields' => 'names']); + if (is_wp_error($tags)) { + $tags = []; + } + + $topic_cat = ''; + if (taxonomy_exists('aksrt_forum_topic_cat')) { + $cats = wp_get_post_terms($draft_id, 'aksrt_forum_topic_cat', ['fields' => 'names']); + if (!is_wp_error($cats) && !empty($cats)) { + $topic_cat = $cats[0]; + } + } + + $slide_raw = get_post_meta($draft_id, '_aksrt_featured_slide', true); + + return [ + 'draft_id' => $draft_id, + 'title' => $post->post_title, + 'content' => $post->post_content, + 'board_id' => $board_id, + 'tags' => implode(', ', $tags), + 'visibility' => get_post_meta($draft_id, '_aksrt_visibility', true) ?: AKSRT_Forum::VISIBILITY_PUBLIC, + 'bbs_type' => get_post_meta($draft_id, '_aksrt_bbs_type', true) ?: AKSRT_Forum::BBS_TYPE_STANDARD, + 'special_topic' => get_post_meta($draft_id, '_aksrt_special_topic', true) ?: '', + 'cover_type' => get_post_meta($draft_id, '_aksrt_cover_type', true) ?: '', + 'cover_image' => get_post_meta($draft_id, '_aksrt_cover_image', true) ?: '', + 'featured_slide' => is_array($slide_raw) ? $slide_raw : [], + 'featured_video' => get_post_meta($draft_id, '_aksrt_featured_video', true) ?: '', + 'credits_cost' => (int) get_post_meta($draft_id, '_aksrt_credits_cost', true), + 'pay_price' => get_post_meta($draft_id, '_aksrt_pay_price', true) ?: '', + 'visibility_roles' => get_post_meta($draft_id, '_aksrt_visibility_roles', true) ?: [], + 'topic_cat' => $topic_cat, + 'vote_enabled' => get_post_meta($draft_id, '_aksrt_vote_enabled', true) === '1', + 'vote_title' => get_post_meta($draft_id, '_aksrt_vote_title', true) ?: '', + 'vote_multiple' => get_post_meta($draft_id, '_aksrt_vote_multiple', true) === '1', + 'vote_type' => get_post_meta($draft_id, '_aksrt_vote_type', true) ?: 'standard', + 'vote_expiry_days' => (int) get_post_meta($draft_id, '_aksrt_vote_expiry_days', true), + 'vote_options' => $this->get_vote_options_text($draft_id), + ]; + } + + private function get_vote_options_text(int $post_id): string { + $options = get_post_meta($post_id, '_aksrt_vote_options', true); + if (!is_array($options)) { + return ''; + } + return implode("\n", array_column($options, 'label')); + } + + // ────────────────────────────────────────────────────────── + // AJAX handlers + // ────────────────────────────────────────────────────────── + + public function ajax_save_draft(): void { + check_ajax_referer('aksrt_forum_compose', '_nonce'); + $user_id = get_current_user_id(); + if ($user_id <= 0) { + wp_send_json_error(['message' => __('请先登录', 'aksrt')]); + } + + $data = [ + 'draft_id' => (int) ($_POST['draft_id'] ?? 0), + 'board_id' => (int) ($_POST['board_id'] ?? 0), + 'title' => sanitize_text_field((string) ($_POST['title'] ?? '')), + 'content' => wp_kses_post((string) ($_POST['content'] ?? '')), + 'tags' => $this->forum->split_tags((string) ($_POST['tags'] ?? '')), + 'special_topic' => sanitize_text_field((string) ($_POST['special_topic'] ?? '')), + 'visibility' => sanitize_key((string) ($_POST['visibility'] ?? AKSRT_Forum::VISIBILITY_PUBLIC)), + 'bbs_type' => sanitize_key((string) ($_POST['bbs_type'] ?? AKSRT_Forum::BBS_TYPE_STANDARD)), + 'topic_cat' => sanitize_text_field((string) ($_POST['topic_cat'] ?? '')), + 'vote_enabled' => !empty($_POST['vote_enabled']), + 'vote_title' => sanitize_text_field((string) ($_POST['vote_title'] ?? '')), + 'vote_options_text' => sanitize_textarea_field((string) ($_POST['vote_options'] ?? '')), + 'vote_multiple' => !empty($_POST['vote_multiple']), + 'vote_type' => sanitize_key((string) ($_POST['vote_type'] ?? 'standard')), + 'vote_expiry_days' => (int) ($_POST['vote_expiry_days'] ?? 0), + 'cover_type' => sanitize_key((string) ($_POST['cover_type'] ?? '')), + 'cover_image' => esc_url_raw((string) ($_POST['cover_image'] ?? '')), + 'featured_slide' => (string) ($_POST['featured_slide'] ?? ''), + 'featured_video' => esc_url_raw((string) ($_POST['featured_video'] ?? '')), + 'credits_cost' => (int) ($_POST['credits_cost'] ?? 0), + 'pay_price' => sanitize_text_field((string) ($_POST['pay_price'] ?? '')), + 'visibility_roles' => isset($_POST['visibility_roles']) && is_array($_POST['visibility_roles']) + ? array_map('sanitize_key', $_POST['visibility_roles']) : [], + ]; + + $result = $this->save_draft($user_id, $data); + if (is_wp_error($result)) { + wp_send_json_error(['message' => $result->get_error_message()]); + } + + wp_send_json_success($result); + } + + public function ajax_load_draft(): void { + check_ajax_referer('aksrt_forum_compose', '_nonce'); + $user_id = get_current_user_id(); + $draft_id = (int) ($_GET['draft_id'] ?? $_POST['draft_id'] ?? 0); + + if ($user_id <= 0) { + wp_send_json_error(['message' => __('请先登录', 'aksrt')]); + } + + $data = $this->load_draft_data($user_id, $draft_id); + if (!$data) { + wp_send_json_error(['message' => __('草稿不存在', 'aksrt')]); + } + + wp_send_json_success($data); + } + + public function ajax_delete_draft(): void { + check_ajax_referer('aksrt_forum_compose', '_nonce'); + $user_id = get_current_user_id(); + $draft_id = (int) ($_POST['draft_id'] ?? 0); + + if ($user_id <= 0) { + wp_send_json_error(['message' => __('请先登录', 'aksrt')]); + } + + if (!$this->delete_draft($user_id, $draft_id)) { + wp_send_json_error(['message' => __('删除失败', 'aksrt')]); + } + + wp_send_json_success(['message' => __('草稿已删除', 'aksrt')]); + } + + // ────────────────────────────────────────────────────────── + // Publish draft (transition from draft → publish/pending) + // ────────────────────────────────────────────────────────── + + public function publish_draft(int $user_id, int $draft_id, array $data) { + if ($user_id <= 0) { + return new WP_Error('not_logged_in', __('请先登录', 'aksrt')); + } + $post = get_post($draft_id); + if (!$post || $post->post_type !== 'aksrt_forum_topic' + || (int) $post->post_author !== $user_id + || $post->post_status !== 'draft') { + return new WP_Error('invalid_draft', __('草稿不存在', 'aksrt')); + } + + $data['draft_id'] = $draft_id; + $save_result = $this->save_draft($user_id, $data); + if (is_wp_error($save_result)) { + return $save_result; + } + + $board_id = (int) ($data['board_id'] ?? 0); + $title = sanitize_text_field((string) ($data['title'] ?? '')); + $content = wp_kses_post((string) ($data['content'] ?? '')); + + if ($board_id <= 0 || $title === '' || $content === '') { + return new WP_Error('invalid_payload', __('请填写版块、标题和内容', 'aksrt')); + } + + $can_publish = $this->forum->can_user_publish_directly($user_id, $board_id); + $post_status = $can_publish ? 'publish' : 'pending'; + $review_status = $can_publish ? AKSRT_Forum::REVIEW_APPROVED : AKSRT_Forum::REVIEW_PENDING; + + wp_update_post([ + 'ID' => $draft_id, + 'post_status' => $post_status, + ]); + + update_post_meta($draft_id, '_aksrt_review_status', $review_status); + update_post_meta($draft_id, '_aksrt_sticky', AKSRT_Forum::STICKY_NONE); + update_post_meta($draft_id, '_aksrt_featured', '0'); + update_post_meta($draft_id, '_aksrt_locked', '0'); + update_post_meta($draft_id, '_aksrt_views', 0); + + $bbs_type = get_post_meta($draft_id, '_aksrt_bbs_type', true) ?: AKSRT_Forum::BBS_TYPE_STANDARD; + if ($bbs_type === AKSRT_Forum::BBS_TYPE_QUESTION) { + update_post_meta($draft_id, '_aksrt_question_status', AKSRT_Forum::QUESTION_UNSOLVED); + } + + if ($post_status === 'publish') { + $this->forum->sync_board_count($board_id); + if (class_exists('AKSRT_Credits_Earn')) { + do_action('aksrt_forum_topic_created', $user_id, $draft_id); + } + } + + if ($post_status === 'pending') { + $this->forum->notify_topic_pending($draft_id, $user_id); + } + + return [ + 'post_id' => $draft_id, + 'message' => $post_status === 'publish' + ? __('主题已发布', 'aksrt') + : __('主题已提交审核,审核通过后对外展示', 'aksrt'), + 'redirect' => $post_status === 'publish' + ? get_permalink($draft_id) + : add_query_arg('compose_status', 'pending', home_url('/forums/')), + ]; + } +} diff --git a/inc/classes/forum/class-aksrt-forum-moderation-service.php b/inc/classes/forum/class-aksrt-forum-moderation-service.php new file mode 100644 index 00000000..d3b39218 --- /dev/null +++ b/inc/classes/forum/class-aksrt-forum-moderation-service.php @@ -0,0 +1,720 @@ +forum = $forum; + } + + public function apply_moderator(int $user_id, int $board_id, string $reason) { + if ($user_id <= 0) { + return new WP_Error('not_logged_in', __('请先登录', 'aksrt')); + } + + if ($this->forum->is_plate_cpt_active()) { + $post = get_post($board_id); + if (!$post || $post->post_type !== 'aksrt_forum_plate') { + return new WP_Error('invalid_board', __('版块不存在', 'aksrt')); + } + } else { + $board = get_term($board_id, 'aksrt_forum_board'); + if (!$board || is_wp_error($board)) { + return new WP_Error('invalid_board', __('版块不存在', 'aksrt')); + } + } + + if ($this->is_board_moderator($user_id, $board_id)) { + return new WP_Error('already_moderator', __('你已经是该版块版主', 'aksrt')); + } + + $applications = $this->get_board_moderator_applications($board_id); + foreach ($applications as $application) { + if ((int) ($application['user_id'] ?? 0) === $user_id && (string) ($application['status'] ?? '') === AKSRT_Forum::APPLICATION_PENDING) { + return new WP_Error('application_exists', __('你已经提交过申请,等待处理即可', 'aksrt')); + } + } + + $reason = trim($reason); + if ($reason === '') { + return new WP_Error('empty_reason', __('请填写申请理由', 'aksrt')); + } + + $application = [ + 'id' => wp_generate_uuid4(), + 'board_id' => $board_id, + 'user_id' => $user_id, + 'reason' => sanitize_textarea_field($reason), + 'status' => AKSRT_Forum::APPLICATION_PENDING, + 'created_at' => current_time('mysql'), + 'reviewed_at' => '', + 'review_note' => '', + 'reviewer_id' => 0, + ]; + + $applications[] = $application; + if ($this->forum->is_plate_cpt_active()) { + update_post_meta($board_id, '_aksrt_moderator_applications', $applications); + } else { + update_term_meta($board_id, '_aksrt_moderator_applications', $applications); + } + + return [ + 'message' => __('版主申请已提交', 'aksrt'), + 'application' => $application, + ]; + } + + /** + * Review a moderator application. + * + * @return array|WP_Error + */ + public function review_moderator_application(string $application_id, string $status, string $note, int $reviewer_id) { + if ($reviewer_id <= 0 || !$this->forum->forum_user_can($reviewer_id, 'moderator_review')) { + return new WP_Error('forbidden', __('权限不足', 'aksrt')); + } + + if (!in_array($status, [AKSRT_Forum::APPLICATION_APPROVED, AKSRT_Forum::APPLICATION_REJECTED], true)) { + return new WP_Error('invalid_status', __('申请处理状态无效', 'aksrt')); + } + + $use_cpt = $this->forum->is_plate_cpt_active(); + $boards = $this->forum->get_boards(); + $flat_boards = $this->flatten_boards($boards); + + foreach ($flat_boards as $board) { + $board_id = (int) ($board['id'] ?? 0); + $applications = $this->get_board_moderator_applications($board_id); + $updated = false; + + foreach ($applications as &$application) { + if ((string) ($application['id'] ?? '') !== $application_id) { + continue; + } + + $application['status'] = $status; + $application['review_note'] = sanitize_textarea_field($note); + $application['reviewed_at'] = current_time('mysql'); + $application['reviewer_id'] = $reviewer_id; + $updated = true; + + if ($status === AKSRT_Forum::APPLICATION_APPROVED) { + $this->add_moderator((int) ($application['user_id'] ?? 0), $board_id); + } + + break; + } + unset($application); + + if ($updated) { + if ($use_cpt) { + update_post_meta($board_id, '_aksrt_moderator_applications', $applications); + } else { + update_term_meta($board_id, '_aksrt_moderator_applications', $applications); + } + + $reviewed_application = $this->find_application_by_id($application_id); + if ($reviewed_application) { + $reviewed_user_id = (int) ($reviewed_application['user_id'] ?? 0); + if ($reviewed_user_id > 0 && $reviewed_user_id !== $reviewer_id) { + $board_name = (string) ($board['name'] ?? __('当前版块', 'aksrt')); + $status_label = $status === AKSRT_Forum::APPLICATION_APPROVED + ? __('已通过', 'aksrt') + : __('已拒绝', 'aksrt'); + $payload = [ + 'title' => __('版主申请结果', 'aksrt'), + 'target_url' => isset($board['url']) ? (string) $board['url'] : home_url('/forums/'), + ]; + $clean_note = sanitize_textarea_field($note); + if ($clean_note !== '') { + $payload['reason'] = $clean_note; + } + $this->forum->notify( + $reviewed_user_id, + 'system', + sprintf(__('你在「%1$s」提交的版主申请%2$s', 'aksrt'), $board_name, $status_label), + $payload + ); + } + } + + return [ + 'message' => __('申请状态已更新', 'aksrt'), + 'application' => $reviewed_application, + ]; + } + } + + return new WP_Error('application_not_found', __('申请记录不存在', 'aksrt')); + } + + /** + * Flatten hierarchical boards array (collapse children into flat list). + */ + private function flatten_boards(array $boards): array { + $flat = []; + foreach ($boards as $board) { + $children = $board['children'] ?? []; + unset($board['children']); + $flat[] = $board; + if (!empty($children)) { + $flat = array_merge($flat, $this->flatten_boards($children)); + } + } + return $flat; + } + + /** + * Get flattened moderator application list. + */ + public function get_moderator_application_list(array $args = []): array { + $status = sanitize_key((string) ($args['status'] ?? '')); + $board_id = (int) ($args['board_id'] ?? 0); + $keyword = sanitize_text_field((string) ($args['keyword'] ?? '')); + $items = []; + + foreach ($this->forum->get_boards() as $board) { + $current_board_id = (int) ($board['id'] ?? 0); + if ($board_id > 0 && $current_board_id !== $board_id) { + continue; + } + + foreach ($this->get_board_moderator_applications($current_board_id) as $application) { + if ($status !== '' && (string) ($application['status'] ?? '') !== $status) { + continue; + } + + $user = get_userdata((int) ($application['user_id'] ?? 0)); + $row = [ + 'id' => (string) ($application['id'] ?? ''), + 'board_id' => $current_board_id, + 'board_name' => (string) ($board['name'] ?? ''), + 'user_id' => (int) ($application['user_id'] ?? 0), + 'display_name' => $user ? $user->display_name : '', + 'user_email' => $user ? $user->user_email : '', + 'avatar_url' => $user ? $this->forum->get_user_avatar_url((int) $user->ID) : '', + 'reason' => (string) ($application['reason'] ?? ''), + 'status' => (string) ($application['status'] ?? ''), + 'status_label' => $this->get_application_status_label((string) ($application['status'] ?? '')), + 'created_at' => (string) ($application['created_at'] ?? ''), + 'reviewed_at' => (string) ($application['reviewed_at'] ?? ''), + 'review_note' => (string) ($application['review_note'] ?? ''), + 'reviewer' => $this->forum->build_user_summary((int) ($application['reviewer_id'] ?? 0)), + ]; + + if ($keyword !== '') { + $haystack = strtolower(implode(' ', [ + (string) $row['board_name'], + (string) $row['display_name'], + (string) $row['user_email'], + (string) $row['reason'], + ])); + if (strpos($haystack, strtolower($keyword)) === false) { + continue; + } + } + + $items[] = $row; + } + } + + usort($items, static function ($left, $right): int { + return strcmp((string) ($right['created_at'] ?? ''), (string) ($left['created_at'] ?? '')); + }); + + return $items; + } + + /** + * Sync board/plate topic count + reply count. + */ + public function sync_board_count(int $board_id): void { + if ($board_id <= 0) { + return; + } + + if ($this->forum->is_plate_cpt_active()) { + $this->sync_plate_counts($board_id); + return; + } + + $count = new WP_Query([ + 'post_type' => 'aksrt_forum_topic', + 'posts_per_page' => 1, + 'fields' => 'ids', + 'tax_query' => [[ + 'taxonomy' => 'aksrt_forum_board', + 'field' => 'term_id', + 'terms' => $board_id, + ]], + 'post_status' => 'publish', + ]); + + update_term_meta($board_id, '_aksrt_topic_count', (int) $count->found_posts); + } + + /** + * Sync plate multi-dimensional counters (§7.2 去规范化统计). + */ + public function sync_plate_counts(int $plate_id): void { + global $wpdb; + + $posts_count = (int) $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->postmeta} pm + INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id + WHERE pm.meta_key = '_aksrt_plate_id' AND pm.meta_value = %s + AND p.post_type = 'aksrt_forum_topic' AND p.post_status = 'publish'", + (string) $plate_id + )); + + $reply_count = (int) $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->comments} c + INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = c.comment_post_ID + WHERE pm.meta_key = '_aksrt_plate_id' AND pm.meta_value = %s + AND c.comment_approved = '1'", + (string) $plate_id + )); + + $today_start = current_time('Y-m-d') . ' 00:00:00'; + $today_reply_count = (int) $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM {$wpdb->comments} c + INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = c.comment_post_ID + WHERE pm.meta_key = '_aksrt_plate_id' AND pm.meta_value = %s + AND c.comment_approved = '1' AND c.comment_date >= %s", + (string) $plate_id, + $today_start + )); + + $last_post = $wpdb->get_var($wpdb->prepare( + "SELECT p.post_date FROM {$wpdb->posts} p + INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID + WHERE pm.meta_key = '_aksrt_plate_id' AND pm.meta_value = %s + AND p.post_type = 'aksrt_forum_topic' AND p.post_status = 'publish' + ORDER BY p.post_date DESC LIMIT 1", + (string) $plate_id + )); + + $last_reply = $wpdb->get_var($wpdb->prepare( + "SELECT c.comment_date FROM {$wpdb->comments} c + INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = c.comment_post_ID + WHERE pm.meta_key = '_aksrt_plate_id' AND pm.meta_value = %s + AND c.comment_approved = '1' + ORDER BY c.comment_date DESC LIMIT 1", + (string) $plate_id + )); + + update_post_meta($plate_id, '_aksrt_posts_count', $posts_count); + update_post_meta($plate_id, '_aksrt_reply_count', $reply_count); + update_post_meta($plate_id, '_aksrt_today_reply_count', $today_reply_count); + update_post_meta($plate_id, '_aksrt_last_post', (string) ($last_post ?: '')); + update_post_meta($plate_id, '_aksrt_last_reply', (string) ($last_reply ?: '')); + } + + /** + * Get board moderator IDs (统一入口,自动检测 CPT/taxonomy). + */ + public function get_board_moderators(int $board_id): array { + if ($this->forum->is_plate_cpt_active()) { + return $this->get_plate_moderators($board_id); + } + + // Fallback: taxonomy + $moderators = get_term_meta($board_id, '_aksrt_moderators', true); + if (!is_array($moderators)) { + return []; + } + + return array_values(array_unique(array_filter(array_map('intval', $moderators)))); + } + + /** + * Get plate moderators from CPT meta. + */ + public function get_plate_moderators(int $plate_id): array { + if (!post_type_exists('aksrt_forum_plate')) { + return []; + } + + $moderators = get_post_meta($plate_id, '_aksrt_moderators', true); + if (!is_array($moderators)) { + return []; + } + + return array_values(array_unique(array_filter(array_map('intval', $moderators)))); + } + + /** + * Check if a user is a forum-level admin (bbs_admin_users setting). + * + * Forum admins have cross-board governance privileges and bypass bans. + */ + public function is_forum_admin(int $user_id): bool { + if ($user_id <= 0) { + return false; + } + + if (user_can($user_id, 'manage_options')) { + return true; + } + + $forum = AKSRT_Settings::get_forum_settings(); + $admin_users = $forum['admin_users'] ?? []; + + return in_array($user_id, $admin_users, true); + } + + /** + * Check if a user is a board moderator. + */ + public function is_board_moderator(int $user_id, int $board_id): bool { + if ($user_id <= 0) { + return false; + } + + if ($this->is_forum_admin($user_id)) { + return true; + } + + return in_array($user_id, $this->get_board_moderators($board_id), true); + } + + /** + * Get board moderator profile rows. + */ + public function get_board_moderator_profiles(int $board_id): array { + $profiles = []; + foreach ($this->get_board_moderators($board_id) as $user_id) { + $user = get_userdata($user_id); + if (!$user) { + continue; + } + + $profiles[] = [ + 'id' => $user_id, + 'display_name' => $user->display_name, + 'user_email' => $user->user_email, + 'avatar_url' => $this->forum->get_user_avatar_url($user_id), + ]; + } + + return $profiles; + } + + /** + * Get board moderator profiles with role information. + */ + public function get_board_moderator_profiles_with_roles(int $board_id): array { + $roles_map = $this->get_moderator_roles($board_id); + $profiles = []; + foreach ($this->get_board_moderators($board_id) as $user_id) { + $user = get_userdata($user_id); + if (!$user) continue; + $profiles[] = [ + 'id' => $user_id, + 'display_name' => $user->display_name, + 'user_email' => $user->user_email, + 'avatar_url' => $this->forum->get_user_avatar_url($user_id), + 'role' => $roles_map[$user_id] ?? 'moderator', + ]; + } + return $profiles; + } + + /** + * Get moderator role map for a board: [user_id => role]. + * + * In CPT mode, also maps plate author → 'plate_author' (超级版主). + */ + public function get_moderator_roles(int $board_id): array { + if ($this->forum->is_plate_cpt_active()) { + $roles = get_post_meta($board_id, '_aksrt_moderator_roles', true); + $roles = is_array($roles) ? $roles : []; + $post = get_post($board_id); + if ($post && $post->post_author > 0) { + $roles[(int) $post->post_author] = 'plate_author'; + } + return $roles; + } + $roles = get_term_meta($board_id, '_aksrt_moderator_roles', true); + return is_array($roles) ? $roles : []; + } + + /** + * Set a moderator's role on a board. + */ + public function set_moderator_role(int $user_id, int $board_id, string $role): bool { + $allowed = ['super_moderator', 'moderator', 'junior_moderator', 'plate_author', 'cat_moderator']; + if (!in_array($role, $allowed, true)) { + return false; + } + if (!in_array($user_id, $this->get_board_moderators($board_id), true)) { + return false; + } + $roles = $this->get_moderator_roles($board_id); + $roles[$user_id] = $role; + if ($this->forum->is_plate_cpt_active()) { + return (bool) update_post_meta($board_id, '_aksrt_moderator_roles', $roles); + } + return (bool) update_term_meta($board_id, '_aksrt_moderator_roles', $roles); + } + + /** + * Add a moderator to a board with optional role. + */ + public function add_moderator(int $user_id, int $board_id, string $role = 'moderator'): bool { + $current = $this->get_board_moderators($board_id); + if (in_array($user_id, $current, true)) { + $this->set_moderator_role($user_id, $board_id, $role); + return true; + } + $current[] = $user_id; + $new_list = array_values(array_unique(array_map('intval', $current))); + if ($this->forum->is_plate_cpt_active()) { + $result = update_post_meta($board_id, '_aksrt_moderators', $new_list); + } else { + $result = update_term_meta($board_id, '_aksrt_moderators', $new_list); + } + if ($result !== false) { + $this->set_moderator_role($user_id, $board_id, $role); + } + return $result !== false; + } + + /** + * Remove a moderator from a board. + */ + public function remove_moderator(int $user_id, int $board_id): bool { + $current = $this->get_board_moderators($board_id); + $current = array_values(array_diff($current, [$user_id])); + $roles = $this->get_moderator_roles($board_id); + unset($roles[$user_id]); + if ($this->forum->is_plate_cpt_active()) { + $result = update_post_meta($board_id, '_aksrt_moderators', $current); + update_post_meta($board_id, '_aksrt_moderator_roles', $roles); + } else { + $result = update_term_meta($board_id, '_aksrt_moderators', $current); + update_term_meta($board_id, '_aksrt_moderator_roles', $roles); + } + return $result !== false; + } + + /** + * Get all boards a user moderates (with roles). + */ + public function get_user_moderator_boards(int $user_id): array { + $boards = []; + foreach ($this->forum->get_boards() as $board) { + $board_id = (int) ($board['id'] ?? 0); + if ($board_id <= 0) continue; + $moderators = $this->get_board_moderators($board_id); + if (in_array($user_id, $moderators, true)) { + $roles = $this->get_moderator_roles($board_id); + $boards[] = [ + 'board_id' => $board_id, + 'board_name' => (string) ($board['name'] ?? ''), + 'role' => $roles[$user_id] ?? 'moderator', + ]; + } + } + return $boards; + } + + /** + * Check if a user has a specific minimum tier on a board/plate. + * + * Tier hierarchy (aligned with zibll 4-level): + * forum_admin(4) > cat_moderator(3) > plate_author(2) > moderator(1) + * + * @param string $min_role 'moderator'|'plate_author'|'cat_moderator'|'forum_admin' + */ + public function has_moderator_role(int $user_id, int $board_id, string $min_role = 'moderator'): bool { + $tier_map = [ + 'junior_moderator' => AKSRT_Forum::TIER_MODERATOR, // backward-compat alias + 'moderator' => AKSRT_Forum::TIER_MODERATOR, + 'plate_author' => AKSRT_Forum::TIER_PLATE_AUTHOR, + 'super_moderator' => AKSRT_Forum::TIER_PLATE_AUTHOR, // backward-compat alias + 'cat_moderator' => AKSRT_Forum::TIER_CAT_MOD, + 'forum_admin' => AKSRT_Forum::TIER_FORUM_ADMIN, + ]; + $required = $tier_map[$min_role] ?? AKSRT_Forum::TIER_MODERATOR; + return $this->forum->get_user_plate_tier($user_id, $board_id) >= $required; + } + + /** + * Get board moderator applications (统一入口,自动检测 CPT/taxonomy). + */ + public function get_board_moderator_applications(int $board_id): array { + if ($this->forum->is_plate_cpt_active()) { + return $this->get_plate_moderator_applications($board_id); + } + + // Fallback: taxonomy + $applications = get_term_meta($board_id, '_aksrt_moderator_applications', true); + if (!is_array($applications)) { + return []; + } + + return array_values(array_filter(array_map(function ($application) use ($board_id): array { + if (!is_array($application)) { + $application = []; + } + + return [ + 'id' => (string) ($application['id'] ?? wp_generate_uuid4()), + 'board_id' => $board_id, + 'user_id' => (int) ($application['user_id'] ?? 0), + 'reason' => (string) ($application['reason'] ?? ''), + 'status' => (string) ($application['status'] ?? AKSRT_Forum::APPLICATION_PENDING), + 'created_at' => (string) ($application['created_at'] ?? ''), + 'reviewed_at' => (string) ($application['reviewed_at'] ?? ''), + 'review_note' => (string) ($application['review_note'] ?? ''), + 'reviewer_id' => (int) ($application['reviewer_id'] ?? 0), + ]; + }, $applications))); + } + + /** + * Get plate moderator applications from CPT meta. + */ + public function get_plate_moderator_applications(int $plate_id): array { + if (!post_type_exists('aksrt_forum_plate')) { + return []; + } + + $applications = get_post_meta($plate_id, '_aksrt_moderator_applications', true); + if (!is_array($applications)) { + return []; + } + + return array_values(array_filter(array_map(function ($application) use ($plate_id): array { + if (!is_array($application)) { + $application = []; + } + + return [ + 'id' => (string) ($application['id'] ?? wp_generate_uuid4()), + 'board_id' => $plate_id, + 'user_id' => (int) ($application['user_id'] ?? 0), + 'reason' => (string) ($application['reason'] ?? ''), + 'status' => (string) ($application['status'] ?? AKSRT_Forum::APPLICATION_PENDING), + 'created_at' => (string) ($application['created_at'] ?? ''), + 'reviewed_at' => (string) ($application['reviewed_at'] ?? ''), + 'review_note' => (string) ($application['review_note'] ?? ''), + 'reviewer_id' => (int) ($application['reviewer_id'] ?? 0), + ]; + }, $applications))); + } + + /** + * Get current user's board application. + */ + public function get_board_application_for_user(int $user_id, int $board_id): ?array { + $applications = $this->get_board_moderator_applications($board_id); + $matched = array_values(array_filter($applications, static function ($application) use ($user_id): bool { + return (int) ($application['user_id'] ?? 0) === $user_id; + })); + + if (empty($matched)) { + return null; + } + + usort($matched, static function ($left, $right): int { + return strcmp((string) ($right['created_at'] ?? ''), (string) ($left['created_at'] ?? '')); + }); + + $application = $matched[0]; + $application['status_label'] = $this->get_application_status_label((string) ($application['status'] ?? '')); + + return $application; + } + + public function find_application_by_id(string $application_id): ?array { + foreach ($this->get_moderator_application_list() as $application) { + if ((string) ($application['id'] ?? '') === $application_id) { + return $application; + } + } + + return null; + } + + public function get_application_status_label(string $status): string { + $map = [ + AKSRT_Forum::APPLICATION_PENDING => __('待处理', 'aksrt'), + AKSRT_Forum::APPLICATION_APPROVED => __('已通过', 'aksrt'), + AKSRT_Forum::APPLICATION_REJECTED => __('已拒绝', 'aksrt'), + ]; + + return $map[$status] ?? $map[AKSRT_Forum::APPLICATION_PENDING]; + } + + public function on_topic_saved_sync_plate(int $post_id, \WP_Post $post): void { + if (!$this->forum->is_plate_cpt_active()) { + return; + } + $plate_id = (int) get_post_meta($post_id, '_aksrt_plate_id', true); + if ($plate_id > 0) { + $this->sync_plate_counts($plate_id); + } + } + + public function on_comment_sync_plate_reply(int $comment_id, $approved, array $commentdata): void { + if (!$this->forum->is_plate_cpt_active()) { + return; + } + $post_id = (int) ($commentdata['comment_post_ID'] ?? 0); + if ($post_id <= 0) { + return; + } + $post = get_post($post_id); + if (!$post || $post->post_type !== 'aksrt_forum_topic') { + return; + } + $plate_id = (int) get_post_meta($post_id, '_aksrt_plate_id', true); + if ($plate_id > 0) { + $this->sync_plate_counts($plate_id); + } + } + + public function is_moderator(int $user_id, int $topic_id): bool { + if ($user_id <= 0) { + return false; + } + + if ($this->is_forum_admin($user_id)) { + return true; + } + + // CPT mode: resolve plate from topic meta + if ($this->forum->is_plate_cpt_active()) { + $plate_id = $this->forum->get_topic_plate_id($topic_id); + if ($plate_id > 0) { + return $this->forum->get_user_plate_tier($user_id, $plate_id) >= self::TIER_MODERATOR; + } + } + + // Fallback: taxonomy-based boards + $boards = get_the_terms($topic_id, 'aksrt_forum_board'); + if ($boards && !is_wp_error($boards)) { + foreach ($boards as $board) { + if ($this->is_board_moderator($user_id, (int) $board->term_id)) { + return true; + } + } + } + + return false; + } +} diff --git a/inc/classes/forum/class-aksrt-forum-permission-service.php b/inc/classes/forum/class-aksrt-forum-permission-service.php new file mode 100644 index 00000000..edc53a9b --- /dev/null +++ b/inc/classes/forum/class-aksrt-forum-permission-service.php @@ -0,0 +1,416 @@ + cap_key => bool */ + private static array $cap_cache = []; + /** @var array "uid:plate_id" => tier */ + private static array $tier_cache = []; + + /** @var AKSRT_Forum */ + private $forum; + + public function __construct($forum) { + $this->forum = $forum; + } + + // ── Capability matrix ───────────────────────────────────────── + + /** + * Capability definitions matrix. + * + * Keys: + * 'min_tier' — minimum TIER_* constant required + * 'context' — 'topic'|'plate'|'global'|'any' + * 'self' — true = only applies to own content + * 'logged' — shorthand: any logged-in user + * 'settings_key' — optional forum settings permissions key + */ + private function cap_defs(): array { + return [ + // ── Topic viewing ───────────────────────────────────────────── + 'topic_view_pending' => ['min_tier' => self::TIER_MODERATOR, 'context' => 'plate'], + 'topic_view_hidden' => ['min_tier' => self::TIER_MODERATOR, 'context' => 'plate'], + 'topic_view_deleted' => ['min_tier' => self::TIER_FORUM_ADMIN, 'context' => 'global'], + + // ── Topic editing ───────────────────────────────────────────── + 'topic_edit' => ['min_tier' => self::TIER_NONE, 'self' => true, 'context' => 'topic'], + 'topic_edit_other' => ['min_tier' => self::TIER_MODERATOR, 'context' => 'plate'], + 'topic_delete' => ['min_tier' => self::TIER_NONE, 'self' => true, 'context' => 'topic'], + 'topic_delete_other' => ['min_tier' => self::TIER_MODERATOR, 'context' => 'plate'], + + // ── Topic governance ────────────────────────────────────────── + 'topic_sticky_plate' => ['min_tier' => self::TIER_MODERATOR, 'context' => 'plate'], + 'topic_sticky_super' => ['min_tier' => self::TIER_PLATE_AUTHOR, 'context' => 'plate'], + 'topic_sticky_global' => ['min_tier' => self::TIER_FORUM_ADMIN, 'context' => 'global'], + 'topic_featured' => ['min_tier' => self::TIER_MODERATOR, 'context' => 'plate'], + 'topic_lock' => ['min_tier' => self::TIER_NONE, 'self' => true, 'context' => 'topic'], + 'topic_lock_other' => ['min_tier' => self::TIER_MODERATOR, 'context' => 'plate'], + 'topic_highlight' => ['min_tier' => self::TIER_MODERATOR, 'context' => 'plate'], + 'topic_score_other' => ['min_tier' => self::TIER_MODERATOR, 'context' => 'plate'], + 'topic_move' => ['min_tier' => self::TIER_MODERATOR, 'context' => 'plate'], + 'topic_moderate' => ['min_tier' => self::TIER_MODERATOR, 'context' => 'plate'], + + // ── Topic interaction (logged users) ────────────────────────── + 'topic_vote' => ['min_tier' => self::TIER_NONE, 'logged' => true], + 'topic_favorite' => ['min_tier' => self::TIER_NONE, 'logged' => true], + 'topic_score' => ['min_tier' => self::TIER_NONE, 'logged' => true], + 'topic_report' => ['min_tier' => self::TIER_NONE, 'logged' => true], + + // ── Reply governance ────────────────────────────────────────── + 'reply_adopt' => ['min_tier' => self::TIER_NONE, 'self' => true, 'context' => 'topic'], + 'reply_adopt_other' => ['min_tier' => self::TIER_MODERATOR, 'context' => 'plate'], + 'reply_delete_other' => ['min_tier' => self::TIER_MODERATOR, 'context' => 'plate'], + 'reply_edit_other' => ['min_tier' => self::TIER_MODERATOR, 'context' => 'plate'], + 'reply_top' => ['min_tier' => self::TIER_MODERATOR, 'context' => 'plate'], + // ── Reply interaction ───────────────────────────────────────── + 'reply_like' => ['min_tier' => self::TIER_NONE, 'logged' => true], + 'reply_score' => ['min_tier' => self::TIER_NONE, 'logged' => true], + + // ── Plate management ────────────────────────────────────────── + 'plate_follow' => ['min_tier' => self::TIER_NONE, 'logged' => true], + 'plate_create' => ['min_tier' => self::TIER_FORUM_ADMIN, 'context' => 'global'], + 'plate_manage' => ['min_tier' => self::TIER_PLATE_AUTHOR, 'context' => 'plate'], + 'plate_delete' => ['min_tier' => self::TIER_FORUM_ADMIN, 'context' => 'global'], + + // ── Moderator system ────────────────────────────────────────── + 'moderator_manage' => ['min_tier' => self::TIER_CAT_MOD, 'context' => 'plate'], + 'moderator_apply' => ['min_tier' => self::TIER_NONE, 'logged' => true], + 'moderator_review' => ['min_tier' => self::TIER_CAT_MOD, 'context' => 'plate'], + + // ── User governance ─────────────────────────────────────────── + 'user_ban' => ['min_tier' => self::TIER_CAT_MOD, 'context' => 'global'], + 'user_report' => ['min_tier' => self::TIER_NONE, 'logged' => true], + + // ── Compose capabilities ────────────────────────────────────── + 'compose_post' => ['min_tier' => self::TIER_NONE, 'logged' => true, 'settings_key' => ''], + 'compose_post_no_review' => ['min_tier' => self::TIER_MODERATOR, 'context' => 'plate'], + 'compose_upload_image' => ['min_tier' => self::TIER_NONE, 'logged' => true, 'settings_key' => 'posts_upload_img'], + 'compose_upload_video' => ['min_tier' => self::TIER_NONE, 'logged' => true, 'settings_key' => 'posts_upload_video'], + 'compose_upload_file' => ['min_tier' => self::TIER_NONE, 'logged' => true, 'settings_key' => 'posts_upload_file'], + 'compose_iframe_video' => ['min_tier' => self::TIER_NONE, 'logged' => true, 'settings_key' => 'posts_iframe_video'], + 'compose_set_type' => ['min_tier' => self::TIER_NONE, 'logged' => true], + 'compose_set_visibility' => ['min_tier' => self::TIER_NONE, 'logged' => true], + 'compose_set_cover' => ['min_tier' => self::TIER_NONE, 'logged' => true, 'settings_key' => 'posts_upload_img'], + 'compose_set_vote' => ['min_tier' => self::TIER_NONE, 'logged' => true], + 'compose_set_paid' => ['min_tier' => self::TIER_PLATE_AUTHOR, 'context' => 'plate', 'settings_key' => 'paid_content'], + 'compose_set_hide' => ['min_tier' => self::TIER_PLATE_AUTHOR, 'context' => 'plate', 'settings_key' => 'paid_content'], + ]; + } + + // ── Tier resolution ─────────────────────────────────────────── + + /** + * Return the user's highest tier for a given plate. + * + * Result is cached per request on "$user_id:$plate_id". + */ + public function get_user_plate_tier(int $user_id, int $plate_id): int { + if ($user_id <= 0) { + return self::TIER_NONE; + } + + $cache_key = "$user_id:$plate_id"; + if (isset(self::$tier_cache[$cache_key])) { + return self::$tier_cache[$cache_key]; + } + + $tier = $this->compute_user_plate_tier($user_id, $plate_id); + self::$tier_cache[$cache_key] = $tier; + return $tier; + } + + private function compute_user_plate_tier(int $user_id, int $plate_id): int { + // Tier 4: WP admin or bbs_admin_users list + if ($this->forum->is_forum_admin($user_id)) { + return self::TIER_FORUM_ADMIN; + } + + // Tier 3: category moderator (plate_cat term meta 'moderator') + $cats = get_the_terms($plate_id, 'aksrt_plate_cat'); + if ($cats && !is_wp_error($cats)) { + foreach ($cats as $cat) { + if ($this->forum->is_cat_moderator($user_id, (int) $cat->term_id)) { + return self::TIER_CAT_MOD; + } + } + } + + // Tier 2: plate_author (plate CPT post_author) + if ($this->forum->is_plate_cpt_active()) { + $post = get_post($plate_id); + if ($post && (int) $post->post_author === $user_id) { + return self::TIER_PLATE_AUTHOR; + } + + // Tier 1: in plate _aksrt_moderators list + $mods = get_post_meta($plate_id, '_aksrt_moderators', true); + if (is_array($mods) && in_array($user_id, $mods, true)) { + return self::TIER_MODERATOR; + } + + return self::TIER_NONE; + } + // Fallback: taxonomy board + $roles = get_term_meta($plate_id, '_aksrt_moderator_roles', true); + $roles = is_array($roles) ? $roles : []; + $role = $roles[$user_id] ?? ''; + if ($role === 'super_moderator' || $role === 'plate_author') { + return self::TIER_PLATE_AUTHOR; + } + $mods = get_term_meta($plate_id, '_aksrt_moderators', true); + if (is_array($mods) && in_array($user_id, $mods, true)) { + return self::TIER_MODERATOR; + } + + return self::TIER_NONE; + } + + // ── Capability check ────────────────────────────────────────── + + /** + * Unified capability resolver. + * + * @param int $user_id User to check (0 = guest). + * @param string $cap Capability id from cap_defs(). + * @param int $context_id topic_id for 'topic' context, plate_id for 'plate' context. + * @return bool + */ + public function forum_user_can(int $user_id, string $cap, int $context_id = 0): bool { + $cache_key = "$user_id:$cap:$context_id"; + if (array_key_exists($cache_key, self::$cap_cache)) { + return self::$cap_cache[$cache_key]; + } + + $result = $this->compute_forum_cap($user_id, $cap, $context_id); + self::$cap_cache[$cache_key] = $result; + return $result; + } + + private function compute_forum_cap(int $user_id, string $cap, int $context_id): bool { + $defs = $this->cap_defs(); + if (!isset($defs[$cap])) { + return false; + } + $def = $defs[$cap]; + + $logged_only = !empty($def['logged']) && ($def['min_tier'] ?? self::TIER_NONE) === self::TIER_NONE; + $self_only = !empty($def['self']); + $context = $def['context'] ?? 'any'; + $min_tier = (int) ($def['min_tier'] ?? self::TIER_NONE); + $settings_key = $def['settings_key'] ?? ''; + // Guest short-circuit + if ($user_id <= 0) { + return false; + } + + // Forum-wide admin bypasses everything + if ($this->forum->is_forum_admin($user_id)) { + // Honour settings-gated compose caps even for admins when the feature is globally off + if ($settings_key !== '') { + $perms = $this->forum->get_config()['permissions'] ?? []; + if (isset($perms[$settings_key]) && $perms[$settings_key] === false) { + return false; + } + } + return true; + } + + // 'logged' / any-user caps: just need to be logged in + if ($logged_only || ($min_tier === self::TIER_NONE && $context === 'any')) { + if ($settings_key !== '') { + $perms = $this->forum->get_config()['permissions'] ?? []; + if (array_key_exists($settings_key, $perms) && empty($perms[$settings_key])) { + return false; + } + } + return true; + } + + // 'self' caps: caller passes topic_id as context; check authorship + if ($self_only) { + if ($context_id <= 0) { + return false; + } + $post = get_post($context_id); + return $post && (int) $post->post_author === $user_id; + } + + // Global caps (no specific plate/topic context) + if ($context === 'global') { + if ($min_tier <= self::TIER_CAT_MOD) { + return $this->is_cat_moderator_anywhere($user_id); + } + return false; + } + + // Plate-context caps + if ($context === 'plate') { + $plate_id = $this->resolve_plate_id($context_id, $cap); + if ($plate_id <= 0) { + return false; + } + $tier = $this->get_user_plate_tier($user_id, $plate_id); + + // Check optional settings gate + if ($settings_key !== '') { + $perms = $this->forum->get_config()['permissions'] ?? []; + if (array_key_exists($settings_key, $perms) && empty($perms[$settings_key])) { + return $tier >= self::TIER_PLATE_AUTHOR; + } + } + + return $tier >= $min_tier; + } + + return false; + } + + // ── Helper methods ──────────────────────────────────────────── + + /** + * Resolve a plate_id from either a plate_id or topic_id, depending on the cap context. + */ + private function resolve_plate_id(int $context_id, string $cap): int { + if ($context_id <= 0) { + return 0; + } + $post = get_post($context_id); + if (!$post) { + return 0; + } + if ($post->post_type === 'aksrt_forum_plate') { + return $context_id; + } + if ($post->post_type === 'aksrt_forum_topic') { + return $this->forum->get_topic_plate_id($context_id); + } + return 0; + } + + /** + * Check if the user is a cat_moderator on ANY plate category (for global caps). + */ + private function is_cat_moderator_anywhere(int $user_id): bool { + if ($user_id <= 0) { + return false; + } + if ($this->forum->is_forum_admin($user_id)) { + return true; + } + $cats = get_terms(['taxonomy' => 'aksrt_plate_cat', 'hide_empty' => false, 'fields' => 'ids']); + if (is_wp_error($cats) || empty($cats)) { + return false; + } + foreach ((array) $cats as $cat_id) { + if ($this->forum->is_cat_moderator($user_id, (int) $cat_id)) { + return true; + } + } + return false; + } + + // ── Compose capabilities ────────────────────────────────────── + + /** + * Get per-feature capabilities for the compose/edit form. + * + * @return array + */ + public function get_compose_capabilities(int $user_id): array { + $keys = [ + 'can_post', 'can_set_type', 'can_set_visibility', + 'can_set_cover', 'can_set_cover_image', 'can_set_cover_slide', 'can_set_cover_video', + 'can_set_vote', 'can_set_paid', 'can_set_hide', + 'can_upload_image', 'can_upload_video', 'can_upload_file', + 'can_iframe_video', + ]; + if ($user_id <= 0) { + return array_fill_keys($keys, false); + } + + return [ + 'can_post' => $this->forum_user_can($user_id, 'compose_post'), + 'can_set_type' => $this->forum_user_can($user_id, 'compose_set_type'), + 'can_set_visibility' => $this->forum_user_can($user_id, 'compose_set_visibility'), + 'can_set_cover' => $this->forum_user_can($user_id, 'compose_set_cover'), + 'can_set_cover_image' => $this->forum_user_can($user_id, 'compose_set_cover'), + 'can_set_cover_slide' => $this->forum_user_can($user_id, 'compose_set_cover'), + 'can_set_cover_video' => $this->forum_user_can($user_id, 'compose_upload_video'), + 'can_set_vote' => $this->forum_user_can($user_id, 'compose_set_vote'), + 'can_set_paid' => $this->forum_user_can($user_id, 'compose_set_paid'), + 'can_set_hide' => $this->forum_user_can($user_id, 'compose_set_hide'), + 'can_upload_image' => $this->forum_user_can($user_id, 'compose_upload_image'), + 'can_upload_video' => $this->forum_user_can($user_id, 'compose_upload_video'), + 'can_upload_file' => $this->forum_user_can($user_id, 'compose_upload_file'), + 'can_iframe_video' => $this->forum_user_can($user_id, 'compose_iframe_video'), + ]; + } + + // ── Cache invalidation ──────────────────────────────────────── + + public function flush_cap_cache(int $user_id = 0): void { + if ($user_id > 0) { + foreach (array_keys(self::$cap_cache) as $k) { + if (strncmp($k, "$user_id:", strlen("$user_id:")) === 0) { + unset(self::$cap_cache[$k]); + } + } + foreach (array_keys(self::$tier_cache) as $k) { + if (strncmp($k, "$user_id:", strlen("$user_id:")) === 0) { + unset(self::$tier_cache[$k]); + } + } + } else { + self::$cap_cache = []; + self::$tier_cache = []; + } + } + + public function cap_cache_on_login(string $user_login, \WP_User $user): void { + $this->flush_cap_cache((int) $user->ID); + } + + /** Flush cache when plate moderator meta changes. */ + public function cap_cache_on_plate_meta(int $meta_id, int $object_id, string $meta_key): void { + if (in_array($meta_key, ['_aksrt_moderators', '_aksrt_moderator_roles'], true)) { + self::$cap_cache = []; + self::$tier_cache = []; + } + } + + /** Flush cache when plate_cat moderator term meta changes. */ + public function cap_cache_on_cat_meta(int $meta_id, int $object_id, string $meta_key): void { + if ($meta_key === 'moderator') { + self::$cap_cache = []; + self::$tier_cache = []; + } + } + + /** Flush cache when a plate CPT post is saved (plate_author may change). */ + public function cap_cache_on_plate_save(int $post_id): void { + foreach (array_keys(self::$tier_cache) as $k) { + if (substr($k, -strlen(":$post_id")) === ":$post_id") { + unset(self::$tier_cache[$k]); + } + } + self::$cap_cache = []; + } +} diff --git a/inc/classes/forum/class-aksrt-forum-score-service.php b/inc/classes/forum/class-aksrt-forum-score-service.php new file mode 100644 index 00000000..4b154be0 --- /dev/null +++ b/inc/classes/forum/class-aksrt-forum-score-service.php @@ -0,0 +1,430 @@ +forum = $forum; + } + + // ================================================================ + // Favorite (§7.3) + // ================================================================ + + public function is_favorited(int $user_id, int $topic_id): bool { + if ($user_id <= 0) { + return false; + } + $favorites = get_user_meta($user_id, 'aksrt_favorite_forum_posts', true); + $favorites = is_array($favorites) ? $favorites : []; + return in_array($topic_id, $favorites, true); + } + + public function toggle_favorite(int $user_id, int $topic_id) { + if ($user_id <= 0) { + return new WP_Error('not_logged_in', __('请先登录', 'aksrt')); + } + + $post = get_post($topic_id); + if (!$post || $post->post_type !== 'aksrt_forum_topic') { + return new WP_Error('invalid_topic', __('帖子不存在', 'aksrt')); + } + + $favorites = get_user_meta($user_id, 'aksrt_favorite_forum_posts', true); + $favorites = is_array($favorites) ? $favorites : []; + $was_favorited = in_array($topic_id, $favorites, true); + + if ($was_favorited) { + $favorites = array_values(array_diff($favorites, [$topic_id])); + } else { + $favorites[] = $topic_id; + } + + update_user_meta($user_id, 'aksrt_favorite_forum_posts', $favorites); + + $count = (int) get_post_meta($topic_id, '_aksrt_favorite_count', true); + $count = $was_favorited ? max(0, $count - 1) : $count + 1; + update_post_meta($topic_id, '_aksrt_favorite_count', $count); + $this->refresh_recommend_score($topic_id); + + return [ + 'favorited' => !$was_favorited, + 'count' => $count, + 'message' => $was_favorited + ? __('已取消收藏', 'aksrt') + : __('收藏成功', 'aksrt'), + ]; + } + + // ================================================================ + // Score (§7.3) + // ================================================================ + + public function score_topic(int $user_id, int $topic_id, int $amount) { + if ($user_id <= 0) { + return new WP_Error('not_logged_in', __('请先登录', 'aksrt')); + } + + $post = get_post($topic_id); + if (!$post || $post->post_type !== 'aksrt_forum_topic') { + return new WP_Error('invalid_topic', __('帖子不存在', 'aksrt')); + } + + if ($amount === 0) { + return new WP_Error('invalid_amount', __('评分值不能为零', 'aksrt')); + } + + $author_id = (int) $post->post_author; + if ($author_id === $user_id) { + return new WP_Error('self_score', __('不能给自己的帖子评分', 'aksrt')); + } + + $config = $this->forum->get_config(); + $max_extra = (int) ($config['post_page']['score_extra_max'] ?? 5); + $max_deduct = (int) ($config['post_page']['score_deduct_max'] ?? 3); + + if ($amount > 0 && $amount > $max_extra) { + $amount = $max_extra; + } + if ($amount < 0 && abs($amount) > $max_deduct) { + $amount = -$max_deduct; + } + + $detail = get_post_meta($topic_id, '_aksrt_score_detail', true); + $detail = is_array($detail) ? $detail : []; + + $user_key = (string) $user_id; + if (isset($detail[$user_key])) { + return new WP_Error('already_scored', __('你已经对该帖评过分', 'aksrt')); + } + + $detail[$user_key] = [ + 'amount' => $amount, + 'time' => current_time('mysql'), + ]; + update_post_meta($topic_id, '_aksrt_score_detail', $detail); + + $total = (int) get_post_meta($topic_id, '_aksrt_score', true); + $total += $amount; + update_post_meta($topic_id, '_aksrt_score', $total); + + $reputation = (int) get_user_meta($author_id, 'aksrt_reputation', true); + $reputation += $amount; + update_user_meta($author_id, 'aksrt_reputation', $reputation); + + if ($amount > 0 && class_exists('AKSRT_Credits')) { + $credits_amount = (int) ($config['credits']['score_extra'] ?? 3); + if ($credits_amount > 0) { + $daily = $this->today_credits_count($author_id, 'forum_score_extra'); + $daily_max = (int) ($config['credits']['daily_max'] ?? 50); + if ($daily < $daily_max) { + AKSRT_Credits::add($author_id, $credits_amount, 'forum_score_extra', sprintf( + __('帖子「%s」被加分', 'aksrt'), + get_the_title($topic_id) + )); + } + } + } + + $this->refresh_recommend_score($topic_id); + $this->notify_topic_scored($topic_id, $user_id, $amount); + + return [ + 'score' => $total, + 'message' => $amount > 0 + ? __('加分成功', 'aksrt') + : __('减分成功', 'aksrt'), + ]; + } + + public function get_score_detail(int $topic_id): array { + $detail = get_post_meta($topic_id, '_aksrt_score_detail', true); + $detail = is_array($detail) ? $detail : []; + + $entries = []; + foreach ($detail as $uid => $entry) { + $user = get_userdata((int) $uid); + $entries[] = [ + 'user_id' => (int) $uid, + 'display_name' => $user ? $user->display_name : '', + 'amount' => (int) ($entry['amount'] ?? 0), + 'time' => (string) ($entry['time'] ?? ''), + ]; + } + + return $entries; + } + + // ================================================================ + // Recommend score (§7.3) + // ================================================================ + + public function calculate_recommend_score(int $topic_id): float { + $post = get_post($topic_id); + if (!$post || $post->post_type !== 'aksrt_forum_topic') { + return 0.0; + } + + $views = (int) get_post_meta($topic_id, '_aksrt_views', true); + $favorites = (int) get_post_meta($topic_id, '_aksrt_favorite_count', true); + $comments = (int) get_comments([ + 'post_id' => $topic_id, + 'count' => true, + 'status' => 'approve', + ]); + $score = (int) get_post_meta($topic_id, '_aksrt_score', true); + + $w_views = 1.0; + $w_favorites = 5.0; + $w_comments = 3.0; + $w_score = 2.0; + + $raw = ($views * $w_views) + + ($favorites * $w_favorites) + + ($comments * $w_comments) + + (max(0, $score) * $w_score); + + $post_time = strtotime($post->post_date_gmt); + $age_hours = max(1, (time() - $post_time) / 3600); + $decay = pow($age_hours, 1.5); + $recommend = ($raw + 1) / $decay * 10000; + + return round($recommend, 2); + } + + public function refresh_recommend_score(int $topic_id): float { + $score = $this->calculate_recommend_score($topic_id); + update_post_meta($topic_id, '_aksrt_recommend_score', $score); + $this->maybe_reward_hot_topic($topic_id, $score); + return $score; + } + + private function maybe_reward_hot_topic(int $topic_id, float $score): void { + $hot_threshold = 500.0; + if ($score < $hot_threshold) { + return; + } + if (get_post_meta($topic_id, '_aksrt_hot_rewarded', true)) { + return; + } + $post = get_post($topic_id); + if (!$post || $post->post_type !== 'aksrt_forum_topic') { + return; + } + $author_id = (int) $post->post_author; + if ($author_id <= 0) { + return; + } + update_post_meta($topic_id, '_aksrt_hot_rewarded', '1'); + $config = $this->forum->get_config(); + $amount = (int) ($config['credits']['posts_hot'] ?? 5); + if ($amount > 0 && class_exists('AKSRT_Credits')) { + AKSRT_Credits::add($author_id, $amount, 'forum_hot_topic', [ + 'type' => 'post', 'id' => $topic_id, + ]); + } + $this->notify($author_id, 'hot', sprintf( + __('你的帖子「%s」成为热帖!', 'aksrt'), get_the_title($topic_id) + ), [ + 'title' => __('热帖通知', 'aksrt'), + 'target_url' => get_permalink($topic_id), + ]); + } + + public function batch_refresh_recommend_scores(int $limit = 100): int { + $query = new WP_Query([ + 'post_type' => 'aksrt_forum_topic', + 'post_status' => 'publish', + 'posts_per_page' => $limit, + 'orderby' => 'modified', + 'order' => 'DESC', + 'fields' => 'ids', + 'no_found_rows' => true, + ]); + + $count = 0; + foreach ($query->posts as $post_id) { + $this->refresh_recommend_score((int) $post_id); + $count++; + } + + return $count; + } + + public function maybe_refresh_recommend_on_view(): void { + if (!is_singular('aksrt_forum_topic')) { + return; + } + $topic_id = get_the_ID(); + if (!$topic_id) { + return; + } + $last = (int) get_post_meta($topic_id, '_aksrt_recommend_refreshed', true); + if ((time() - $last) > 300) { + $this->refresh_recommend_score($topic_id); + update_post_meta($topic_id, '_aksrt_recommend_refreshed', time()); + } + } + + private function get_bbs_type_label(string $bbs_type): string { + $config = $this->forum->get_config(); + $names = $config['names'] ?? []; + $map = [ + AKSRT_Forum::BBS_TYPE_STANDARD => $names['posts'] ?? __('帖子', 'aksrt'), + AKSRT_Forum::BBS_TYPE_QUESTION => $names['question_badge'] ?? __('提问', 'aksrt'), + ]; + return $map[$bbs_type] ?? $map[AKSRT_Forum::BBS_TYPE_STANDARD]; + } + + private function get_question_status_label(int $status): string { + $config = $this->forum->get_config(); + $names = $config['names'] ?? []; + return $status === AKSRT_Forum::QUESTION_SOLVED + ? ($names['question_ok_badge'] ?? __('已解决', 'aksrt')) + : ($names['question_badge'] ?? __('提问', 'aksrt')); + } + + // ================================================================ + // Notifications (§7.8) + // ================================================================ + + public function notify(int $user_id, string $type, string $content, array $data = []): void { + if ($user_id <= 0 || !class_exists('AKSRT_Message')) { + return; + } + AKSRT_Message::get_instance()->send($user_id, $type, $content, $data); + } + + public function notify_topic_pending(int $topic_id, int $author_id): void { + $title = get_the_title($topic_id); + $config = $this->forum->get_config(); + $admin_users = $config['admin_users'] ?? []; + + foreach ($admin_users as $admin_id) { + $this->notify((int) $admin_id, 'posts', sprintf( + __('帖子「%s」待审核', 'aksrt'), $title + ), [ + 'title' => __('待审核帖子', 'aksrt'), + 'target_url' => get_permalink($topic_id), + ]); + } + } + + public function notify_moderation_result(int $topic_id, string $action, int $operator_id): void { + $post = get_post($topic_id); + if (!$post) return; + $author_id = (int) $post->post_author; + if ($author_id <= 0 || $author_id === $operator_id) return; + + $title = get_the_title($topic_id); + $action_labels = [ + 'review' => __('审核状态已变更', 'aksrt'), + 'sticky' => __('置顶状态已变更', 'aksrt'), + 'featured' => __('精华状态已变更', 'aksrt'), + 'locked' => __('锁定状态已变更', 'aksrt'), + 'close' => __('帖子已关闭', 'aksrt'), + 'hide' => __('帖子已隐藏', 'aksrt'), + 'delete' => __('帖子已删除', 'aksrt'), + 'undelete' => __('帖子已恢复', 'aksrt'), + ]; + + if ($action === 'review') { + $review_status = (string) get_post_meta($topic_id, '_aksrt_review_status', true); + if ($review_status === AKSRT_Forum::REVIEW_REJECTED) { + $action_labels['review'] = __('审核未通过', 'aksrt'); + } elseif ($review_status === AKSRT_Forum::REVIEW_PENDING) { + $action_labels['review'] = __('已转为待审核', 'aksrt'); + } elseif ($review_status === AKSRT_Forum::REVIEW_APPROVED) { + $action_labels['review'] = __('审核已通过', 'aksrt'); + } + } + + $label = $action_labels[$action] ?? __('治理动作已执行', 'aksrt'); + $target_url = $action === 'delete' ? home_url('/forums/') : get_permalink($topic_id); + $payload = [ + 'title' => $label, + 'target_url' => $target_url, + ]; + + $note = (string) get_post_meta($topic_id, '_aksrt_moderation_note', true); + if ($note !== '') { + $payload['reason'] = $note; + } + + $this->notify($author_id, 'system', sprintf( + __('你的帖子「%1$s」%2$s', 'aksrt'), $title, $label + ), $payload); + } + + public function notify_topic_reply(int $topic_id, int $commenter_id): void { + $post = get_post($topic_id); + if (!$post) return; + $author_id = (int) $post->post_author; + if ($author_id <= 0 || $author_id === $commenter_id) return; + + $commenter = get_userdata($commenter_id); + $name = $commenter ? $commenter->display_name : __('有人', 'aksrt'); + $title = get_the_title($topic_id); + + $this->notify($author_id, 'comment', sprintf( + __('%1$s 回复了你的帖子「%2$s」', 'aksrt'), $name, $title + ), [ + 'title' => __('新回复', 'aksrt'), + 'target_url' => get_permalink($topic_id), + ]); + } + + public function notify_topic_scored(int $topic_id, int $scorer_id, int $amount): void { + $post = get_post($topic_id); + if (!$post) return; + $author_id = (int) $post->post_author; + if ($author_id <= 0 || $author_id === $scorer_id) return; + + $title = get_the_title($topic_id); + $this->notify($author_id, 'system', sprintf( + __('你的帖子「%1$s」被%2$s %3$d 分', 'aksrt'), + $title, + $amount > 0 ? __('加', 'aksrt') : __('减', 'aksrt'), + abs($amount) + ), [ + 'title' => $amount > 0 ? __('帖子被加分', 'aksrt') : __('帖子被减分', 'aksrt'), + 'target_url' => get_permalink($topic_id), + ]); + } + + // ================================================================ + // Private helpers + // ================================================================ + + private function today_credits_count(int $user_id, string $reason): int { + if (!class_exists('AKSRT_DB_Migrator') || $user_id <= 0) { + return 0; + } + + $table = AKSRT_DB_Migrator::table('credit_log'); + if (!$table) { + return 0; + } + + global $wpdb; + $today = current_time('Y-m-d'); + return (int) $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM {$table} WHERE user_id = %d AND reason = %s AND DATE(created_at) = %s", + $user_id, $reason, $today + )); + } +} diff --git a/inc/classes/forum/class-aksrt-forum-topic-service.php b/inc/classes/forum/class-aksrt-forum-topic-service.php new file mode 100644 index 00000000..ced1f4ce --- /dev/null +++ b/inc/classes/forum/class-aksrt-forum-topic-service.php @@ -0,0 +1,212 @@ +forum = $forum; + } + + // ================================================================ + // Topic CRUD + // ================================================================ + + /** + * Update topic content and editable governance metadata. + * + * @return bool|WP_Error + */ + public function update_topic(int $topic_id, array $data) { + $post = get_post($topic_id); + if (!$post || $post->post_type !== 'aksrt_forum_topic') { + return new WP_Error('invalid_topic', __('帖子不存在', 'aksrt')); + } + + $update = ['ID' => $topic_id]; + + if (isset($data['title'])) { + $title = sanitize_text_field((string) $data['title']); + if ($title === '') { + return new WP_Error('empty_title', __('标题不能为空', 'aksrt')); + } + $update['post_title'] = $title; + } + + if (isset($data['content'])) { + $content = wp_kses_post((string) $data['content']); + if ($content === '') { + return new WP_Error('empty_content', __('内容不能为空', 'aksrt')); + } + $update['post_content'] = $content; + } + + if (isset($data['excerpt'])) { + $update['post_excerpt'] = sanitize_textarea_field((string) $data['excerpt']); + } + + $result = wp_update_post($update, true); + if (is_wp_error($result)) { + return $result; + } + + if (isset($data['tags']) && is_array($data['tags'])) { + $tags = array_values(array_filter(array_map('sanitize_text_field', $data['tags']))); + wp_set_object_terms($topic_id, $tags, 'aksrt_forum_tag'); + } + + $this->save_topic_governance_meta($topic_id, $data); + + return true; + } + + /** + * Create topic. + * + * @return array|WP_Error + */ + public function create_topic(int $user_id, array $data) { + if ($user_id <= 0) { + return new WP_Error('not_logged_in', __('请先登录', 'aksrt')); + } + + $board_id = (int) ($data['board_id'] ?? 0); + $title = sanitize_text_field((string) ($data['title'] ?? '')); + $content = wp_kses_post((string) ($data['content'] ?? '')); + $visibility = sanitize_key((string) ($data['visibility'] ?? AKSRT_Forum::VISIBILITY_PUBLIC)); + $special_topic = sanitize_text_field((string) ($data['special_topic'] ?? '')); + $tags = is_array($data['tags'] ?? null) ? $data['tags'] : []; + $bbs_type = sanitize_key((string) ($data['bbs_type'] ?? AKSRT_Forum::BBS_TYPE_STANDARD)); + if (!in_array($bbs_type, [AKSRT_Forum::BBS_TYPE_STANDARD, AKSRT_Forum::BBS_TYPE_QUESTION], true)) { + $bbs_type = AKSRT_Forum::BBS_TYPE_STANDARD; + } + + if ($board_id <= 0 || $title === '' || $content === '') { + return new WP_Error('invalid_payload', __('请填写所有必填项', 'aksrt')); + } + + // §7.3: Strip unauthorized media from content based on compose capabilities + $content = $this->sanitize_topic_content($user_id, $content); + + $board = get_term($board_id, 'aksrt_forum_board'); + if (!$board || is_wp_error($board)) { + return new WP_Error('invalid_board', __('版块不存在', 'aksrt')); + } + + if (!isset($this->get_visibility_map()[$visibility])) { + $visibility = AKSRT_Forum::VISIBILITY_PUBLIC; + } + + $can_publish = $this->can_user_publish_directly($user_id, $board_id); + $post_status = $can_publish ? 'publish' : 'pending'; + $review_status = $can_publish ? AKSRT_Forum::REVIEW_APPROVED : AKSRT_Forum::REVIEW_PENDING; + + $post_id = wp_insert_post([ + 'post_title' => $title, + 'post_content' => $content, + 'post_status' => $post_status, + 'post_author' => $user_id, + 'post_type' => 'aksrt_forum_topic', + ], true); + + if (is_wp_error($post_id)) { + return $post_id; + } + + wp_set_object_terms($post_id, [$board_id], 'aksrt_forum_board'); + + $clean_tags = array_values(array_filter(array_map('sanitize_text_field', $tags))); + if (count($clean_tags) > 5) { + $clean_tags = array_slice($clean_tags, 0, 5); + } + if (!empty($clean_tags)) { + wp_set_object_terms($post_id, $clean_tags, 'aksrt_forum_tag'); + } + + $topic_cat = sanitize_text_field((string) ($data['topic_cat'] ?? '')); + if ($topic_cat !== '' && taxonomy_exists('aksrt_forum_topic_cat')) { + wp_set_object_terms($post_id, [$topic_cat], 'aksrt_forum_topic_cat'); + } + + update_post_meta($post_id, '_aksrt_visibility', $visibility); + update_post_meta($post_id, '_aksrt_review_status', $review_status); + + if ($visibility === AKSRT_Forum::VISIBILITY_CREDITS) { + $credits_cost = max(0, (int) ($data['credits_cost'] ?? 0)); + update_post_meta($post_id, '_aksrt_credits_cost', $credits_cost); + } + if ($visibility === AKSRT_Forum::VISIBILITY_PAY) { + $pay_price = sanitize_text_field((string) ($data['pay_price'] ?? '')); + update_post_meta($post_id, '_aksrt_pay_price', $pay_price); + } + if ($visibility === AKSRT_Forum::VISIBILITY_ROLE) { + $roles = isset($data['visibility_roles']) && is_array($data['visibility_roles']) + ? array_map('sanitize_key', $data['visibility_roles']) : []; + update_post_meta($post_id, '_aksrt_visibility_roles', $roles); + } + + update_post_meta($post_id, '_aksrt_special_topic', $special_topic); + // 置顶分级 0~3(§7.3):新建主题默认不置顶。 + update_post_meta($post_id, '_aksrt_sticky', AKSRT_Forum::STICKY_NONE); + update_post_meta($post_id, '_aksrt_featured', '0'); + update_post_meta($post_id, '_aksrt_locked', '0'); + update_post_meta($post_id, '_aksrt_views', 0); + update_post_meta($post_id, '_aksrt_bbs_type', $bbs_type); + if ($bbs_type === AKSRT_Forum::BBS_TYPE_QUESTION) { + update_post_meta($post_id, '_aksrt_question_status', AKSRT_Forum::QUESTION_UNSOLVED); + } + + $this->save_cover_meta($post_id, $data); + + $vote_meta = $this->prepare_vote_meta($data); + $this->save_vote_meta($post_id, $vote_meta); + + if ($post_status === 'publish') { + $this->forum->sync_board_count($board_id); + } + + if (class_exists('AKSRT_Credits_Earn') && $post_status === 'publish') { + do_action('aksrt_forum_topic_created', $user_id, $post_id); + } + + if ($post_status === 'pending') { + $this->forum->notify_topic_pending($post_id, $user_id); + } + + return [ + 'post_id' => $post_id, + 'message' => $post_status === 'publish' + ? __('主题已发布', 'aksrt') + : __('主题已提交审核,审核通过后对外展示', 'aksrt'), + 'redirect' => $post_status === 'publish' + ? get_permalink($post_id) + : add_query_arg('compose_status', 'pending', home_url('/forums/')), + ]; + } + + // ================================================================ + // Topic moderation + // ================================================================ + + /** + * Moderate topic. + * + * @return array|WP_Error + */ +} diff --git a/inc/classes/forum/class-aksrt-forum-view-builder.php b/inc/classes/forum/class-aksrt-forum-view-builder.php new file mode 100644 index 00000000..84e340de --- /dev/null +++ b/inc/classes/forum/class-aksrt-forum-view-builder.php @@ -0,0 +1,419 @@ +forum = $forum; + } + + public function get_topic_list(array $args = []): array { + $board_id = (int) ($args['board_id'] ?? 0); + $limit = max(1, min(100, (int) ($args['limit'] ?? ($args['per_page'] ?? 20)))); + $page = max(1, (int) ($args['page'] ?? $args['paged'] ?? 1)); + $offset = array_key_exists('offset', $args) + ? max(0, (int) $args['offset']) + : ($page - 1) * $limit; + $status = sanitize_key((string) ($args['status'] ?? 'publish')); + $keyword = sanitize_text_field((string) ($args['keyword'] ?? '')); + $visibility = sanitize_key((string) ($args['visibility'] ?? '')); + $review_status = sanitize_key((string) ($args['review_status'] ?? '')); + $author_id = (int) ($args['author_id'] ?? 0); + $orderby = sanitize_key((string) ($args['orderby'] ?? 'date')); + $viewer_user_id = (int) ($args['viewer_user_id'] ?? get_current_user_id()); + $include_hidden = !empty($args['include_hidden']); + + $query_args = [ + 'post_type' => 'aksrt_forum_topic', + 'posts_per_page' => $limit, + 'offset' => $offset, + 'post_status' => $this->forum->normalize_post_status_query($status), + 'order' => 'DESC', + 'ignore_sticky_posts' => true, + ]; + + if ($orderby === 'views') { + $query_args['orderby'] = 'meta_value_num'; + $query_args['meta_key'] = '_aksrt_views'; + } elseif ($orderby === 'recommend') { + $query_args['orderby'] = 'meta_value_num'; + $query_args['meta_key'] = '_aksrt_recommend_score'; + } elseif ($orderby === 'comment_count') { + $query_args['orderby'] = 'comment_count'; + } elseif ($orderby === 'score') { + $query_args['orderby'] = 'meta_value_num'; + $query_args['meta_key'] = '_aksrt_score'; + } elseif ($orderby === 'favorites') { + $query_args['orderby'] = 'meta_value_num'; + $query_args['meta_key'] = '_aksrt_favorite_count'; + } else { + $query_args['orderby'] = 'date'; + } + + if ($keyword !== '') { + $query_args['s'] = $keyword; + } + + if ($author_id > 0) { + $query_args['author'] = $author_id; + } + + $plate_id = (int) ($args['plate_id'] ?? 0); + $plate_ids = $args['plate_ids'] ?? []; + if (!empty($plate_ids) && is_array($plate_ids)) { + $query_args['meta_query'][] = [ + 'key' => '_aksrt_plate_id', + 'value' => array_map('intval', $plate_ids), + 'type' => 'NUMERIC', + 'compare' => 'IN', + ]; + } elseif ($plate_id > 0) { + $query_args['meta_query'][] = [ + 'key' => '_aksrt_plate_id', + 'value' => $plate_id, + 'type' => 'NUMERIC', + 'compare' => '=', + ]; + } elseif ($board_id > 0) { + $query_args['tax_query'] = [[ + 'taxonomy' => 'aksrt_forum_board', + 'field' => 'term_id', + 'terms' => $board_id, + ]]; + } + + $meta_query = []; + if ($visibility !== '') { + $meta_query[] = [ + 'key' => '_aksrt_visibility', + 'value' => $visibility, + 'compare' => '=', + ]; + } + + if ($review_status !== '') { + $meta_query[] = [ + 'key' => '_aksrt_review_status', + 'value' => $review_status, + 'compare' => '=', + ]; + } + + if (!empty($meta_query)) { + $query_args['meta_query'] = $meta_query; + } + + $query = new WP_Query($query_args); + $items = []; + + while ($query->have_posts()) { + $query->the_post(); + $post = get_post(get_the_ID()); + if (!$post) { + continue; + } + + $topic = $this->forum->build_topic_payload($post, $viewer_user_id); + if (!$include_hidden && !$topic['can_view']) { + continue; + } + + $items[] = $topic; + } + + wp_reset_postdata(); + + return [ + 'items' => $items, + 'total' => (int) $query->found_posts, + 'max_pages' => (int) $query->max_num_pages, + 'page' => $page, + ]; + } + + /** + * Get forum homepage view. + */ + public function get_home_view(int $user_id = 0): array { + $boards = $this->forum->get_boards(); + $stats = $this->get_stats(); + $recent = $this->get_topic_list([ + 'limit' => 10, + 'status' => 'publish', + 'viewer_user_id' => $user_id, + ]); + $hot = $this->get_topic_list([ + 'limit' => 8, + 'status' => 'publish', + 'viewer_user_id' => $user_id, + 'orderby' => 'views', + ]); + + $applications = []; + if ($user_id > 0) { + foreach ($boards as $board) { + $applications[(int) $board['id']] = $this->forum->get_board_application_for_user($user_id, (int) $board['id']); + } + } + + $config = $this->forum->get_config(); + $home_tabs = $config['homepage']['tabs'] ?? [ + ['type' => 'follow', 'label' => '关注'], + ['type' => 'synthesis', 'label' => '综合'], + ['type' => 'plate', 'label' => '版块'], + ]; + $active_index = (int) ($config['homepage']['tab_active_index'] ?? 1); + $quick_posts = !empty($config['homepage']['quick_posts']); + + return [ + 'boards' => $boards, + 'recent_topics' => $recent['items'], + 'hot_topics' => $hot['items'], + 'stats' => $stats, + 'permissions' => [ + 'can_compose' => $user_id > 0 && $this->forum->forum_user_can($user_id, 'compose_post'), + 'can_manage' => $this->forum->is_forum_admin($user_id), + 'can_create_plate' => $this->forum->forum_user_can($user_id, 'plate_create'), + 'user_tier' => $user_id > 0 ? $this->forum->get_user_plate_tier($user_id, 0) : AKSRT_Forum::TIER_NONE, + ], + 'applications' => $applications, + 'home_tabs' => $home_tabs, + 'active_index' => $active_index, + 'quick_posts' => $quick_posts, + ]; + } + + // ── §7.10 Tab data streams ────────────────────────────────────────── + + /** + * Get topics from plates the user follows. + * + * @return array{items:array,total:int,max_pages:int} + */ + public function get_follow_stream(int $user_id, int $page = 1, int $limit = 15): array { + if ($user_id <= 0) { + return ['items' => [], 'total' => 0, 'max_pages' => 1]; + } + $follow_ids = get_user_meta($user_id, 'aksrt_follow_plate', true); + $follow_ids = is_array($follow_ids) ? array_filter(array_map('intval', $follow_ids)) : []; + if (empty($follow_ids)) { + return ['items' => [], 'total' => 0, 'max_pages' => 1]; + } + + return $this->get_topic_list([ + 'plate_ids' => $follow_ids, + 'page' => $page, + 'limit' => $limit, + 'status' => 'publish', + 'viewer_user_id' => $user_id, + ]); + } + + /** + * Get synthesis (all topics) stream sorted by date. + * + * @return array{items:array,total:int,max_pages:int} + */ + public function get_synthesis_stream(int $page = 1, int $limit = 15, int $viewer = 0): array { + return $this->get_topic_list([ + 'page' => $page, + 'limit' => $limit, + 'status' => 'publish', + 'viewer_user_id' => $viewer, + ]); + } + + /** + * Get pending-review topics for moderators. + * + * @return array{items:array,total:int,max_pages:int} + */ + public function get_pending_stream(int $user_id, int $page = 1, int $limit = 15): array { + if ($user_id <= 0 || !$this->forum->is_forum_admin($user_id)) { + return ['items' => [], 'total' => 0, 'max_pages' => 1]; + } + return $this->get_topic_list([ + 'page' => $page, + 'limit' => $limit, + 'status' => 'pending', + 'viewer_user_id' => $user_id, + 'include_hidden' => true, + ]); + } + + /** + * AJAX: Load a forum home tab stream. + */ + public function ajax_home_tab(): void { + check_ajax_referer('aksrt_public_nonce', 'nonce'); + + $tab_type = sanitize_key((string) ($_POST['tab_type'] ?? 'synthesis')); + $page = max(1, (int) ($_POST['page'] ?? 1)); + $user_id = get_current_user_id(); + + switch ($tab_type) { + case 'follow': + $result = $this->get_follow_stream($user_id, $page); + break; + case 'pending': + $result = $this->get_pending_stream($user_id, $page); + break; + case 'plate': + wp_send_json_success([ + 'html' => '', + 'type' => 'plate', + 'boards' => $this->forum->get_boards(), + 'total' => 0, + 'max_pages' => 1, + 'page' => 1, + ]); + return; + default: + $result = $this->get_synthesis_stream($page, 15, $user_id); + } + + ob_start(); + if (!empty($result['items'])) { + foreach ($result['items'] as $topic) { + $this->render_topic_row($topic); + } + } else { + echo '

' . esc_html__('暂无内容', 'aksrt') . '

'; + } + $html = ob_get_clean(); + + wp_send_json_success([ + 'html' => $html, + 'type' => $tab_type, + 'total' => $result['total'], + 'max_pages' => $result['max_pages'], + 'page' => $page, + 'has_more' => $page < $result['max_pages'], + ]); + } + + /** + * Render a single topic row (reusable for AJAX and initial render). + */ + public function render_topic_row(array $topic): void { + $board = !empty($topic['boards'][0]) ? $topic['boards'][0] : null; + $names = $this->forum->get_config()['names'] ?? []; + ?> +
+
+ + + +
+
+
+ + + + + + + + + +

+ +

+
+
+ + + + + + + +
+
+
+ 'aksrt_forum_topic', + 'author' => $user_id, + 'post_status' => $status, + 'posts_per_page' => $limit, + 'paged' => $page, + 'orderby' => 'date', + 'order' => 'DESC', + ]; + + $query = new \WP_Query($query_args); + $items = []; + foreach ($query->posts as $post) { + $items[] = $this->forum->build_topic_payload($post, $user_id); + } + + return [ + 'items' => $items, + 'total' => (int) $query->found_posts, + 'max_pages' => (int) $query->max_num_pages, + ]; + } + + /** + * Get a user's favorited forum topics. + * + * @return array{items:array,total:int,max_pages:int} + */ + public function get_user_favorite_topics(int $user_id, int $page = 1, int $limit = 10): array { + $fav_ids = get_user_meta($user_id, 'aksrt_favorite_forum_posts', true); + $fav_ids = is_array($fav_ids) ? array_map('intval', array_filter($fav_ids)) : []; + + if (empty($fav_ids)) { + return ['items' => [], 'total' => 0, 'max_pages' => 1]; + } + + $query = new \WP_Query([ + 'post_type' => 'aksrt_forum_topic', + 'post__in' => $fav_ids, + 'post_status' => 'publish', + 'posts_per_page' => $limit, + 'paged' => $page, + 'orderby' => 'post__in', + ]); + + $items = []; + foreach ($query->posts as $post) { + $items[] = $this->forum->build_topic_payload($post, $user_id); + } + + return [ + 'items' => $items, + 'total' => (int) $query->found_posts, + 'max_pages' => (int) $query->max_num_pages, + ]; + } + + /** + * Get a user's managed plates (plates where user is moderator/author). + * + * @return array{owned:array,moderating:array} + */ +} diff --git a/inc/classes/forum/topic-service-traits/trait-aksrt-forum-topic-service-part-1.php b/inc/classes/forum/topic-service-traits/trait-aksrt-forum-topic-service-part-1.php new file mode 100644 index 00000000..659c48b6 --- /dev/null +++ b/inc/classes/forum/topic-service-traits/trait-aksrt-forum-topic-service-part-1.php @@ -0,0 +1,413 @@ +post_type !== 'aksrt_forum_topic') { + return new WP_Error('invalid_topic', __('帖子不存在', 'aksrt')); + } + + if (!$this->forum->can_moderate_topic($operator_id, $topic_id)) { + return new WP_Error('forbidden', __('无权执行该治理动作', 'aksrt')); + } + + $action = sanitize_key($action); + $note = sanitize_textarea_field((string) ($payload['note'] ?? '')); + + switch ($action) { + case 'move_board': + $target_board_id = (int) ($payload['target_board_id'] ?? 0); + $target_board = get_term($target_board_id, 'aksrt_forum_board'); + if (!$target_board || is_wp_error($target_board)) { + return new WP_Error('invalid_board', __('目标版块不存在', 'aksrt')); + } + + $current_board_ids = wp_get_post_terms($topic_id, 'aksrt_forum_board', ['fields' => 'ids']); + wp_set_object_terms($topic_id, [$target_board_id], 'aksrt_forum_board'); + foreach ($current_board_ids as $current_board_id) { + $this->forum->sync_board_count((int) $current_board_id); + } + $this->forum->sync_board_count($target_board_id); + break; + + case 'visibility': + $visibility = sanitize_key((string) ($payload['visibility'] ?? '')); + if (!isset($this->get_visibility_map()[$visibility])) { + return new WP_Error('invalid_visibility', __('可见性参数无效', 'aksrt')); + } + update_post_meta($topic_id, '_aksrt_visibility', $visibility); + if ($visibility === AKSRT_Forum::VISIBILITY_CREDITS) { + update_post_meta($topic_id, '_aksrt_credits_cost', max(0, (int) ($payload['credits_cost'] ?? 0))); + delete_post_meta($topic_id, '_aksrt_pay_price'); + delete_post_meta($topic_id, '_aksrt_visibility_roles'); + } elseif ($visibility === AKSRT_Forum::VISIBILITY_PAY) { + update_post_meta($topic_id, '_aksrt_pay_price', sanitize_text_field((string) ($payload['pay_price'] ?? ''))); + delete_post_meta($topic_id, '_aksrt_credits_cost'); + delete_post_meta($topic_id, '_aksrt_visibility_roles'); + } elseif ($visibility === AKSRT_Forum::VISIBILITY_ROLE) { + $roles = isset($payload['visibility_roles']) && is_array($payload['visibility_roles']) + ? array_values(array_filter(array_map('sanitize_key', $payload['visibility_roles']))) : []; + update_post_meta($topic_id, '_aksrt_visibility_roles', $roles); + delete_post_meta($topic_id, '_aksrt_credits_cost'); + delete_post_meta($topic_id, '_aksrt_pay_price'); + } else { + delete_post_meta($topic_id, '_aksrt_credits_cost'); + delete_post_meta($topic_id, '_aksrt_pay_price'); + delete_post_meta($topic_id, '_aksrt_visibility_roles'); + } + break; + + case 'review': + $review_status = sanitize_key((string) ($payload['review_status'] ?? '')); + if (!isset($this->get_review_map()[$review_status])) { + return new WP_Error('invalid_review_status', __('审核状态参数无效', 'aksrt')); + } + + $board_ids = wp_get_post_terms($topic_id, 'aksrt_forum_board', ['fields' => 'ids']); + update_post_meta($topic_id, '_aksrt_review_status', $review_status); + if ($review_status === AKSRT_Forum::REVIEW_APPROVED) { + wp_update_post(['ID' => $topic_id, 'post_status' => 'publish']); + } elseif ($review_status === AKSRT_Forum::REVIEW_PENDING) { + wp_update_post(['ID' => $topic_id, 'post_status' => 'pending']); + } else { + wp_update_post(['ID' => $topic_id, 'post_status' => 'draft']); + } + + foreach ($board_ids as $board_id) { + $this->forum->sync_board_count((int) $board_id); + } + break; + + case 'sticky': { + $raw = isset($payload['value']) ? $payload['value'] : null; + if ($raw === false || $raw === null) { + $level = AKSRT_Forum::STICKY_NONE; + } elseif (is_numeric($raw)) { + $level = (int) $raw; + } else { + $level = !empty($raw) ? AKSRT_Forum::STICKY_PLATE : AKSRT_Forum::STICKY_NONE; + } + + $level = max(AKSRT_Forum::STICKY_NONE, min(AKSRT_Forum::STICKY_GLOBAL, $level)); + update_post_meta($topic_id, '_aksrt_sticky', $level); + break; + } + + case 'featured': { + $prev = get_post_meta($topic_id, '_aksrt_featured', true) === '1'; + $next = !empty($payload['value']); + update_post_meta($topic_id, '_aksrt_featured', $next ? '1' : '0'); + + // 精华联动:从「未设精华」→「设为精华」时触发积分奖励 + 站内信通知(§7.3/§7.7/§7.8)。 + // 从「精华」→「取消」时不回收积分,仅不重复发放。 + if ($next && !$prev) { + $this->reward_essence($topic_id, $operator_id); + } + break; + } + + case 'locked': { + $locked = !empty($payload['value']); + update_post_meta($topic_id, '_aksrt_locked', $locked ? '1' : '0'); + wp_update_post([ + 'ID' => $topic_id, + 'comment_status' => $locked ? 'closed' : 'open', + ]); + break; + } + + case 'question_status': { + $bbs_type = sanitize_key((string) get_post_meta($topic_id, '_aksrt_bbs_type', true)); + if ($bbs_type !== AKSRT_Forum::BBS_TYPE_QUESTION) { + return new WP_Error('not_question', __('该帖不是提问类型', 'aksrt')); + } + $status = (int) ($payload['value'] ?? AKSRT_Forum::QUESTION_UNSOLVED); + $status = in_array($status, [AKSRT_Forum::QUESTION_UNSOLVED, AKSRT_Forum::QUESTION_SOLVED], true) + ? $status : AKSRT_Forum::QUESTION_UNSOLVED; + update_post_meta($topic_id, '_aksrt_question_status', $status); + break; + } + + case 'close': { + $closed = !empty($payload['value']); + if ($closed) { + update_post_meta($topic_id, '_aksrt_closed', '1'); + wp_update_post(['ID' => $topic_id, 'comment_status' => 'closed']); + } else { + delete_post_meta($topic_id, '_aksrt_closed'); + $locked = get_post_meta($topic_id, '_aksrt_locked', true) === '1'; + if (!$locked) { + wp_update_post(['ID' => $topic_id, 'comment_status' => 'open']); + } + } + break; + } + + case 'hide': { + $hidden = !empty($payload['value']); + if ($hidden) { + update_post_meta($topic_id, '_aksrt_hidden', '1'); + $board_ids = wp_get_post_terms($topic_id, 'aksrt_forum_board', ['fields' => 'ids']); + wp_update_post(['ID' => $topic_id, 'post_status' => 'private']); + foreach ($board_ids as $bid) { + $this->forum->sync_board_count((int) $bid); + } + } else { + delete_post_meta($topic_id, '_aksrt_hidden'); + $board_ids = wp_get_post_terms($topic_id, 'aksrt_forum_board', ['fields' => 'ids']); + wp_update_post(['ID' => $topic_id, 'post_status' => 'publish']); + foreach ($board_ids as $bid) { + $this->forum->sync_board_count((int) $bid); + } + } + break; + } + + case 'delete': { + $board_ids = wp_get_post_terms($topic_id, 'aksrt_forum_board', ['fields' => 'ids']); + wp_trash_post($topic_id); + foreach ($board_ids as $bid) { + $this->forum->sync_board_count((int) $bid); + } + $author_id = (int) $post->post_author; + if ($author_id > 0 && $author_id !== $operator_id) { + $this->forum->notify($author_id, 'system', sprintf( + __('你的帖子「%s」已被删除', 'aksrt'), + $post->post_title + ), [ + 'title' => __('帖子已删除', 'aksrt'), + 'target_url' => home_url('/forums/'), + 'reason' => $note, + ]); + } + break; + } + + case 'undelete': { + if ($post->post_status !== 'trash') { + return new WP_Error('not_trashed', __('该帖不在回收站中', 'aksrt')); + } + wp_untrash_post($topic_id); + $review_status = (string) get_post_meta($topic_id, '_aksrt_review_status', true); + if ($review_status === AKSRT_Forum::REVIEW_APPROVED) { + wp_update_post(['ID' => $topic_id, 'post_status' => 'publish']); + } else { + wp_update_post(['ID' => $topic_id, 'post_status' => 'pending']); + } + $board_ids = wp_get_post_terms($topic_id, 'aksrt_forum_board', ['fields' => 'ids']); + foreach ($board_ids as $bid) { + $this->forum->sync_board_count((int) $bid); + } + break; + } + + default: + return new WP_Error('invalid_action', __('不支持的治理动作', 'aksrt')); + } + + if ($note !== '') { + update_post_meta($topic_id, '_aksrt_moderation_note', $note); + } + + update_post_meta($topic_id, '_aksrt_moderated_by', $operator_id); + update_post_meta($topic_id, '_aksrt_moderated_at', current_time('mysql')); + + $this->forum->notify_moderation_result($topic_id, $action, $operator_id); + + return [ + 'message' => __('治理动作已执行', 'aksrt'), + 'topic' => $this->build_topic_payload(get_post($topic_id), $operator_id), + ]; + } + + /** + * 精华联动奖励(§7.3 联动 + §7.7 被设精华奖励 + §7.8 精华通知)。 + * + * - 触发积分奖励:读 `credits.essence` 配置,有每日上限与去重 meta(防止重复发放)。 + * - 触发站内信通知作者(依赖 AKSRT_Message inbox 子系统)。 + * - 缺失依赖(积分模块未启用/未配置)时静默跳过,不阻塞治理动作。 + */ + public function reward_essence(int $topic_id, int $operator_id): void { + $post = get_post($topic_id); + if (!$post || $post->post_type !== 'aksrt_forum_topic') { + return; + } + + $author_id = (int) $post->post_author; + if ($author_id <= 0) { + return; + } + + // 去重:同一帖已发过精华奖励则跳过(取消再加不重复发)。 + if (get_post_meta($topic_id, '_aksrt_essence_rewarded', true) === '1') { + return; + } + + // 积分奖励(§7.7):读 `credits.essence`,带每日上限。 + if (class_exists('AKSRT_Credits') && class_exists('AKSRT_Settings')) { + $cfg = AKSRT_Settings::get_forum_settings(); + $amount = (int) ($cfg['credits']['essence'] ?? 0); + if ($amount > 0) { + $daily_max = (int) ($cfg['credits']['daily_max'] ?? 50); + if ($daily_max > 0 && $this->today_credits_count($author_id, 'forum_essence') >= $daily_max) { + $amount = 0; + } + + if ($amount > 0) { + AKSRT_Credits::add($author_id, $amount, 'forum_essence', [ + 'type' => 'post', + 'id' => $topic_id, + ], __('主题被设为精华', 'aksrt')); + + update_post_meta($topic_id, '_aksrt_essence_rewarded', '1'); + update_post_meta($topic_id, '_aksrt_essence_rewarded_at', current_time('mysql')); + } + } + } + + // 站内信通知作者(§7.8)。 + if (class_exists('AKSRT_Message')) { + $title = get_the_title($topic_id); + $permalink = (string) get_permalink($topic_id); + AKSRT_Message::get_instance()->send( + $author_id, + 'forum_essence', + sprintf(__('你的主题「%s」被设为精华', 'aksrt'), $title), + [ + 'title' => __('主题被设为精华', 'aksrt'), + 'target_url' => $permalink, + ] + ); + } + + do_action('aksrt_forum_topic_featured', $topic_id, $author_id, $operator_id); + } + + /** + * 今日某 reason 的积分发放次数(用于精华/被加分等每日上限判断)。 + */ + public function today_credits_count(int $user_id, string $reason): int { + if (!class_exists('AKSRT_DB_Migrator') || $user_id <= 0) { + return 0; + } + + $table = AKSRT_DB_Migrator::table('credit_log'); + if (!$table) { + return 0; + } + + global $wpdb; + $today = current_time('Y-m-d'); + return (int) $wpdb->get_var($wpdb->prepare( + "SELECT COUNT(*) FROM {$table} WHERE user_id = %d AND reason = %s AND DATE(created_at) = %s", + $user_id, $reason, $today + )); + } + + /** + * 置顶分级 label(对标 zibll zib_bbs_get_topping_badge)。 + */ + public function get_sticky_label(int $level): string { + switch ($level) { + case AKSRT_Forum::STICKY_GLOBAL: + return __('全局置顶', 'aksrt'); + case AKSRT_Forum::STICKY_SUPER: + return __('超级置顶', 'aksrt'); + case AKSRT_Forum::STICKY_PLATE: + return __('置顶', 'aksrt'); + default: + return ''; + } + } + + /** + * 读取并规范化置顶分级 meta。 + * + * 兼容历史 0/1 二态 meta("1" → STICKY_PLATE),新数据存 0~3 整数。 + */ + public function resolve_sticky_level(int $topic_id): int { + $raw = get_post_meta($topic_id, '_aksrt_sticky', true); + if ($raw === '' || $raw === null) { + return AKSRT_Forum::STICKY_NONE; + } + + if (is_numeric($raw)) { + return max(AKSRT_Forum::STICKY_NONE, min(AKSRT_Forum::STICKY_GLOBAL, (int) $raw)); + } + + return $raw === '1' ? AKSRT_Forum::STICKY_PLATE : AKSRT_Forum::STICKY_NONE; + } + + // ================================================================ + // Voting system + // ================================================================ + + /** + * Submit vote. + * + * @return array|WP_Error + */ + public function submit_vote(int $topic_id, int $user_id, array $option_ids) { + if ($user_id <= 0) { + return new WP_Error('not_logged_in', __('请先登录后投票', 'aksrt')); + } + + $post = get_post($topic_id); + if (!$post || $post->post_type !== 'aksrt_forum_topic') { + return new WP_Error('invalid_topic', __('帖子不存在', 'aksrt')); + } + + $vote = $this->get_vote_payload($topic_id, $user_id); + if (empty($vote['enabled'])) { + return new WP_Error('vote_disabled', __('当前主题未开启投票', 'aksrt')); + } + + if (!empty($vote['expired'])) { + return new WP_Error('vote_expired', __('投票已结束', 'aksrt')); + } + + if (!empty($vote['has_voted'])) { + return new WP_Error('already_voted', __('你已经参与过本次投票', 'aksrt')); + } + + $selected = array_values(array_unique(array_filter(array_map('sanitize_text_field', $option_ids)))); + if (empty($selected)) { + return new WP_Error('empty_selection', __('请选择至少一个投票选项', 'aksrt')); + } + + if (empty($vote['multiple']) && count($selected) > 1) { + return new WP_Error('too_many_selection', __('当前投票仅支持单选', 'aksrt')); + } + + $options = get_post_meta($topic_id, '_aksrt_vote_options', true); + $options = is_array($options) ? $options : []; + $valid_option_ids = wp_list_pluck($options, 'id'); + + foreach ($selected as $selected_id) { + if (!in_array($selected_id, $valid_option_ids, true)) { + return new WP_Error('invalid_option', __('投票选项不存在', 'aksrt')); + } + } + + $records = get_post_meta($topic_id, '_aksrt_vote_records', true); + $records = is_array($records) ? $records : []; + $records[(string) $user_id] = $selected; + update_post_meta($topic_id, '_aksrt_vote_records', $records); + + foreach ($options as &$option) { + if (in_array((string) ($option['id'] ?? ''), $selected, true)) { + $option['count'] = max(0, (int) ($option['count'] ?? 0)) + 1; + } + } + unset($option); + + update_post_meta($topic_id, '_aksrt_vote_options', $options); + + return [ + 'message' => __('投票成功', 'aksrt'), + 'vote' => $this->get_vote_payload($topic_id, $user_id), + ]; + } +} diff --git a/inc/classes/forum/topic-service-traits/trait-aksrt-forum-topic-service-part-2.php b/inc/classes/forum/topic-service-traits/trait-aksrt-forum-topic-service-part-2.php new file mode 100644 index 00000000..0d3d85d8 --- /dev/null +++ b/inc/classes/forum/topic-service-traits/trait-aksrt-forum-topic-service-part-2.php @@ -0,0 +1,376 @@ +forum->get_config(); + $vote_max = (int) ($config['other']['vote_max'] ?? 8); + if ($vote_max > 0 && count($labels) > $vote_max) { + $labels = array_slice($labels, 0, $vote_max); + } + + $vote_type = sanitize_key((string) ($data['vote_type'] ?? 'standard')); + if ($vote_type === 'pk' && count($labels) !== 2) { + $vote_type = 'standard'; + } + if ($vote_type === 'pk') { + $multiple = false; + } + + if (count($labels) < 2) { + $enabled = false; + } + + $expiry_days = (int) ($data['vote_expiry_days'] ?? 0); + + $options = []; + foreach ($labels as $index => $label) { + $options[] = [ + 'id' => 'option_' . ($index + 1), + 'label' => $label, + 'count' => 0, + ]; + } + + return [ + 'enabled' => $enabled, + 'title' => $title, + 'multiple' => $multiple, + 'vote_type' => $vote_type, + 'expiry_days' => $expiry_days, + 'options' => $options, + ]; + } + + /** + * Save vote meta while preserving counts when possible. + */ + public function save_vote_meta(int $topic_id, array $vote_meta): void { + $existing_options = get_post_meta($topic_id, '_aksrt_vote_options', true); + $existing_options = is_array($existing_options) ? $existing_options : []; + $existing_by_label = []; + foreach ($existing_options as $existing_option) { + if (!empty($existing_option['label'])) { + $existing_by_label[(string) $existing_option['label']] = $existing_option; + } + } + + $options = []; + foreach ($vote_meta['options'] as $index => $option) { + $label = (string) ($option['label'] ?? ''); + if ($label === '') { + continue; + } + + $existing = $existing_by_label[$label] ?? null; + $options[] = [ + 'id' => $existing['id'] ?? ('option_' . ($index + 1)), + 'label' => $label, + 'count' => (int) ($existing['count'] ?? 0), + ]; + } + + update_post_meta($topic_id, '_aksrt_vote_enabled', !empty($vote_meta['enabled']) ? '1' : '0'); + update_post_meta($topic_id, '_aksrt_vote_title', sanitize_text_field((string) ($vote_meta['title'] ?? ''))); + update_post_meta($topic_id, '_aksrt_vote_multiple', !empty($vote_meta['multiple']) ? '1' : '0'); + update_post_meta($topic_id, '_aksrt_vote_options', $options); + $vote_type = in_array($vote_meta['vote_type'] ?? '', ['standard', 'pk'], true) ? $vote_meta['vote_type'] : 'standard'; + update_post_meta($topic_id, '_aksrt_vote_type', $vote_type); + $expiry_days = (int) ($vote_meta['expiry_days'] ?? 0); + if (!in_array($expiry_days, [0, 1, 7, 30], true)) { + $expiry_days = 0; + } + update_post_meta($topic_id, '_aksrt_vote_expiry_days', $expiry_days); + if ($expiry_days > 0 && !get_post_meta($topic_id, '_aksrt_vote_started_at', true)) { + update_post_meta($topic_id, '_aksrt_vote_started_at', current_time('mysql')); + } + + if (empty($vote_meta['enabled'])) { + delete_post_meta($topic_id, '_aksrt_vote_records'); + } + } + + /** + * Get vote payload for a topic. + */ + public function get_vote_payload(int $topic_id, int $user_id = 0): array { + $enabled = get_post_meta($topic_id, '_aksrt_vote_enabled', true) === '1'; + $options = get_post_meta($topic_id, '_aksrt_vote_options', true); + $options = is_array($options) ? $options : []; + $records = get_post_meta($topic_id, '_aksrt_vote_records', true); + $records = is_array($records) ? $records : []; + + $vote_type = sanitize_key((string) get_post_meta($topic_id, '_aksrt_vote_type', true)); + if (!in_array($vote_type, ['standard', 'pk'], true)) { + $vote_type = 'standard'; + } + $expiry_days = (int) get_post_meta($topic_id, '_aksrt_vote_expiry_days', true); + $started_at = (string) get_post_meta($topic_id, '_aksrt_vote_started_at', true); + $expired = false; + if ($expiry_days > 0 && $started_at !== '') { + $expire_time = strtotime($started_at) + ($expiry_days * DAY_IN_SECONDS); + $expired = time() >= $expire_time; + } + + $config = $this->forum->get_config(); + $display_mode = $config['other']['vote_number_type'] ?? 'percent'; + + $selected = $user_id > 0 && isset($records[(string) $user_id]) && is_array($records[(string) $user_id]) + ? array_values(array_map('strval', $records[(string) $user_id])) + : []; + $total_votes = 0; + + foreach ($options as &$option) { + $count = max(0, (int) ($option['count'] ?? 0)); + $option['count'] = $count; + $option['selected'] = in_array((string) ($option['id'] ?? ''), $selected, true); + $total_votes += $count; + } + unset($option); + + return [ + 'enabled' => $enabled && !empty($options), + 'title' => (string) get_post_meta($topic_id, '_aksrt_vote_title', true), + 'multiple' => get_post_meta($topic_id, '_aksrt_vote_multiple', true) === '1', + 'vote_type' => $vote_type, + 'expiry_days' => $expiry_days, + 'expired' => $expired, + 'display_mode' => $display_mode, + 'options' => $options, + 'total_votes' => $total_votes, + 'has_voted' => !empty($selected), + 'selected' => $selected, + ]; + } + + // ================================================================ + // Content processing + // ================================================================ + + /** + * Save governance meta fields from an editable payload. + */ + public function save_topic_governance_meta(int $topic_id, array $data): void { + if (isset($data['visibility'])) { + $visibility = sanitize_key((string) $data['visibility']); + if (isset($this->get_visibility_map()[$visibility])) { + update_post_meta($topic_id, '_aksrt_visibility', $visibility); + } + } + + if (array_key_exists('special_topic', $data)) { + update_post_meta($topic_id, '_aksrt_special_topic', sanitize_text_field((string) $data['special_topic'])); + } + + if (array_key_exists('vote_enabled', $data) || array_key_exists('vote_options', $data) || array_key_exists('vote_options_text', $data)) { + $this->save_vote_meta($topic_id, $this->prepare_vote_meta($data)); + } + } + + public function save_cover_meta(int $post_id, array $data): void { + $cover_type = sanitize_key((string) ($data['cover_type'] ?? '')); + if (!in_array($cover_type, ['image', 'slide', 'video'], true)) { + delete_post_meta($post_id, '_aksrt_cover_type'); + return; + } + + update_post_meta($post_id, '_aksrt_cover_type', $cover_type); + + if ($cover_type === 'image') { + $url = esc_url_raw((string) ($data['cover_image'] ?? '')); + update_post_meta($post_id, '_aksrt_cover_image', $url); + } elseif ($cover_type === 'slide') { + $raw = $data['featured_slide'] ?? ''; + $slides = is_array($raw) ? $raw : json_decode((string) $raw, true); + if (!is_array($slides)) { + $slides = []; + } + $slides = array_values(array_filter(array_map('esc_url_raw', array_slice($slides, 0, 9)))); + update_post_meta($post_id, '_aksrt_featured_slide', $slides); + } elseif ($cover_type === 'video') { + $url = esc_url_raw((string) ($data['featured_video'] ?? '')); + update_post_meta($post_id, '_aksrt_featured_video', $url); + } + } + + /** + * Sanitize topic content based on user's compose capabilities. + * + * Strips , '; + } + + echo '
'; + + echo $args['after_widget']; + } + + /** + * 解析 YouTube / Bilibili 链接为嵌入 URL + */ + private function get_embed_url(string $url): string { + // YouTube + if (preg_match('/(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]+)/', $url, $m)) { + return 'https://www.youtube.com/embed/' . $m[1]; + } + + // Bilibili + if (preg_match('/bilibili\.com\/video\/(BV[a-zA-Z0-9]+)/', $url, $m)) { + return 'https://player.bilibili.com/player.html?bvid=' . $m[1] . '&high_quality=1'; + } + + // 腾讯视频 + if (preg_match('/v\.qq\.com\/x\/page\/([a-zA-Z0-9]+)/', $url, $m)) { + return 'https://v.qq.com/txp/iframe/player.html?vid=' . $m[1]; + } + + return ''; + } + + public function form($instance): void { + $title = !empty($instance['title']) ? $instance['title'] : ''; + $video_url = !empty($instance['video_url']) ? $instance['video_url'] : ''; + $poster = !empty($instance['poster']) ? $instance['poster'] : ''; + $autoplay = !empty($instance['autoplay']); + $aspect_ratio = !empty($instance['aspect_ratio']) ? $instance['aspect_ratio'] : '16:9'; + ?> +

+ + +

+

+ + + +

+

+ + +

+

+ + +

+

+ +

+ __('显示随机一言/每日语录,支持自动轮播。', 'aksrt')] + ); + } + + public function widget($args, $instance): void { + $title = !empty($instance['title']) ? $instance['title'] : ''; + $count = !empty($instance['count']) ? max(3, min(20, (int) $instance['count'])) : 5; + $interval = !empty($instance['interval']) ? max(3, min(30, (int) $instance['interval'])) : 5; + $type = !empty($instance['type']) ? sanitize_text_field($instance['type']) : 'a'; + $icon = !empty($instance['icon']) ? $instance['icon'] : 'fas fa-quote-left'; + + echo $args['before_widget']; + + if ($title) { + echo $args['before_title'] . esc_html($title) . $args['after_title']; + } + + $types_map = [ + 'a' => '动画', + 'b' => '漫画', + 'c' => '游戏', + 'd' => '文学', + 'e' => '原创', + 'f' => '来自网络', + 'g' => '其他', + 'h' => '影视', + 'i' => '诗词', + 'j' => '网易云', + 'k' => '哲学', + 'l' => '抖机灵', + ]; + + $type_label = $types_map[$type] ?? '综合'; + + ?> +
+
+ +

加载中…

+

+
+ +
+ +

+ + +

+

+ + +

+

+ + +

+

+ + +

+

+ + +

+ array( + 'name' => __('Banners', 'aksrt'), + 'singular_name' => __('Banner', 'aksrt'), + ), + 'public' => false, + 'show_ui' => false, + 'show_in_rest' => true, + 'supports' => array('title'), + 'menu_icon' => 'dashicons-images-alt2', + 'capability_type' => 'post', + 'capabilities' => array('create_posts' => 'manage_options'), + 'map_meta_cap' => true, + )); +} +add_action('init', 'aksrt_register_banner_cpt'); + +// ============================================================ +// §3.7 Register Topics taxonomy for posts (专题分类) +// ============================================================ +function aksrt_register_topics_taxonomy() { + register_taxonomy('topics', 'post', [ + 'labels' => [ + 'name' => __('专题', 'aksrt'), + 'singular_name' => __('专题', 'aksrt'), + 'search_items' => __('搜索专题', 'aksrt'), + 'all_items' => __('所有专题', 'aksrt'), + 'edit_item' => __('编辑专题', 'aksrt'), + 'add_new_item' => __('添加专题', 'aksrt'), + 'menu_name' => __('专题', 'aksrt'), + ], + 'hierarchical' => true, + 'public' => true, + 'show_in_rest' => true, + 'show_admin_column' => true, + 'rewrite' => ['slug' => 'topics'], + ]); +} +add_action('init', 'aksrt_register_topics_taxonomy'); + +// ============================================================ +// 注册自定义文章类型 - Forum Topic & 分类法 - Forum Board +// ============================================================ + +function aksrt_register_forum_cpt() { + // Respect bbs_s switch — skip CPT registration when forum is disabled. + // Admin context always registers so the backend settings page works. + if (!is_admin() && class_exists('AKSRT_Settings')) { + $forum = AKSRT_Settings::get_forum_settings(); + if (empty($forum['enabled'])) { + return; + } + } + + // Read custom slugs from settings when available + $plate_slug = 'board'; + $topic_slug = 'topic'; + if (class_exists('AKSRT_Settings')) { + $forum = AKSRT_Settings::get_forum_settings(); + $plate_slug = !empty($forum['other']['plate_slug']) ? $forum['other']['plate_slug'] : 'board'; + $topic_slug = !empty($forum['other']['posts_slug']) ? $forum['other']['posts_slug'] : 'topic'; + } + + // Forum Board taxonomy + register_taxonomy('aksrt_forum_board', 'aksrt_forum_topic', [ + 'labels' => [ + 'name' => __('论坛版块', 'aksrt'), + 'singular_name' => __('版块', 'aksrt'), + 'search_items' => __('搜索版块', 'aksrt'), + 'all_items' => __('所有版块', 'aksrt'), + 'edit_item' => __('编辑版块', 'aksrt'), + 'add_new_item' => __('添加版块', 'aksrt'), + ], + 'hierarchical' => true, + 'public' => true, + 'show_in_rest' => true, + 'show_admin_column' => true, + 'rewrite' => ['slug' => $plate_slug], + ]); + + // Forum Tag taxonomy + register_taxonomy('aksrt_forum_tag', 'aksrt_forum_topic', [ + 'labels' => [ + 'name' => __('论坛标签', 'aksrt'), + 'singular_name' => __('标签', 'aksrt'), + 'search_items' => __('搜索标签', 'aksrt'), + 'all_items' => __('所有标签', 'aksrt'), + 'edit_item' => __('编辑标签', 'aksrt'), + 'add_new_item' => __('添加标签', 'aksrt'), + ], + 'hierarchical' => false, + 'public' => true, + 'show_in_rest' => true, + 'show_admin_column' => false, + 'rewrite' => ['slug' => 'forum-tag'], + ]); + + // Forum Topic taxonomy (话题,单选,区别于标签) + register_taxonomy('aksrt_forum_topic_cat', 'aksrt_forum_topic', [ + 'labels' => [ + 'name' => __('论坛话题', 'aksrt'), + 'singular_name' => __('话题', 'aksrt'), + 'search_items' => __('搜索话题', 'aksrt'), + 'all_items' => __('所有话题', 'aksrt'), + 'edit_item' => __('编辑话题', 'aksrt'), + 'add_new_item' => __('添加话题', 'aksrt'), + ], + 'hierarchical' => false, + 'public' => true, + 'show_in_rest' => true, + 'show_admin_column' => true, + 'rewrite' => ['slug' => 'forum-topic'], + ]); + + // Forum Topic CPT + register_post_type('aksrt_forum_topic', [ + 'labels' => [ + 'name' => __('论坛主题', 'aksrt'), + 'singular_name' => __('主题', 'aksrt'), + 'add_new_item' => __('发布新主题', 'aksrt'), + 'edit_item' => __('编辑主题', 'aksrt'), + 'view_item' => __('查看主题', 'aksrt'), + ], + 'public' => true, + 'show_in_rest' => true, + 'has_archive' => true, + 'menu_icon' => 'dashicons-comments', + 'supports' => ['title', 'editor', 'author', 'comments', 'thumbnail', 'excerpt'], + 'capability_type' => 'post', + 'rewrite' => ['slug' => $topic_slug], + ]); +} +add_action('init', 'aksrt_register_forum_cpt'); + +// ============================================================ +// 注册板块 CPT (aksrt_forum_plate) + 板块分区 taxonomy (aksrt_plate_cat) +// §7.0 plate CPT 脚手架:板块从 taxonomy 迁移为独立 CPT, +// 支持作者(超级版主)、正文/摘要/封面、层级父子、独立 meta 扩展位。 +// ============================================================ + +function aksrt_register_forum_plate_cpt() { + if (!is_admin() && class_exists('AKSRT_Settings')) { + $forum = AKSRT_Settings::get_forum_settings(); + if (empty($forum['enabled'])) { + return; + } + } + + $plate_slug = 'board'; + if (class_exists('AKSRT_Settings')) { + $forum = AKSRT_Settings::get_forum_settings(); + $plate_slug = !empty($forum['other']['plate_slug']) ? $forum['other']['plate_slug'] : 'board'; + } + + // Plate category taxonomy (分区) + register_taxonomy('aksrt_plate_cat', 'aksrt_forum_plate', [ + 'labels' => [ + 'name' => __('板块分区', 'aksrt'), + 'singular_name' => __('分区', 'aksrt'), + 'search_items' => __('搜索分区', 'aksrt'), + 'all_items' => __('所有分区', 'aksrt'), + 'edit_item' => __('编辑分区', 'aksrt'), + 'add_new_item' => __('添加分区', 'aksrt'), + ], + 'hierarchical' => true, + 'public' => true, + 'show_in_rest' => true, + 'show_admin_column' => true, + 'rewrite' => ['slug' => 'plate-cat'], + ]); + + // Plate CPT (板块) + register_post_type('aksrt_forum_plate', [ + 'labels' => [ + 'name' => __('论坛版块', 'aksrt'), + 'singular_name' => __('版块', 'aksrt'), + 'add_new_item' => __('创建版块', 'aksrt'), + 'edit_item' => __('编辑版块', 'aksrt'), + 'view_item' => __('查看版块', 'aksrt'), + 'search_items' => __('搜索版块', 'aksrt'), + 'not_found' => __('未找到版块', 'aksrt'), + 'not_found_in_trash' => __('回收站中未找到版块', 'aksrt'), + ], + 'public' => true, + 'hierarchical' => true, + 'show_in_rest' => true, + 'has_archive' => false, + 'menu_icon' => 'dashicons-grid-view', + 'supports' => ['title', 'editor', 'author', 'excerpt', 'thumbnail', 'page-attributes'], + 'capability_type' => 'post', + 'rewrite' => ['slug' => $plate_slug, 'with_front' => false], + 'show_in_menu' => false, + ]); + + // Keep aksrt_forum_topic assignable to plates via taxonomy bridge + register_taxonomy_for_object_type('aksrt_forum_board', 'aksrt_forum_topic'); +} +add_action('init', 'aksrt_register_forum_plate_cpt'); + +// ============================================================ +// §7.0 forum_post CPT 对齐:注册帖子 meta 扩展位地基 +// 为 aksrt_forum_topic 注册全部 meta key,提供类型安全 + REST 暴露。 +// ============================================================ + +function aksrt_register_forum_topic_meta() { + $post_type = 'aksrt_forum_topic'; + + $meta_fields = [ + // 基础治理 meta (已有,补注册) + '_aksrt_visibility' => ['type' => 'string', 'default' => 'public'], + '_aksrt_review_status' => ['type' => 'string', 'default' => ''], + '_aksrt_sticky' => ['type' => 'integer', 'default' => 0], + '_aksrt_featured' => ['type' => 'integer', 'default' => 0], + '_aksrt_locked' => ['type' => 'integer', 'default' => 0], + '_aksrt_highlight' => ['type' => 'string', 'default' => ''], + '_aksrt_views' => ['type' => 'integer', 'default' => 0], + '_aksrt_moderation_note' => ['type' => 'string', 'default' => ''], + '_aksrt_moderated_by' => ['type' => 'integer', 'default' => 0], + '_aksrt_moderated_at' => ['type' => 'string', 'default' => ''], + + // 帖子类型 + 问答状态 (Wave3) + '_aksrt_bbs_type' => ['type' => 'string', 'default' => 'standard'], + '_aksrt_question_status' => ['type' => 'integer', 'default' => 0], + '_aksrt_adopted_comment' => ['type' => 'integer', 'default' => 0], + + // 投票 (已有,补注册) + '_aksrt_vote_enabled' => ['type' => 'integer', 'default' => 0], + '_aksrt_vote_title' => ['type' => 'string', 'default' => ''], + '_aksrt_vote_multiple' => ['type' => 'integer', 'default' => 0], + '_aksrt_vote_type' => ['type' => 'string', 'default' => 'standard'], + '_aksrt_vote_expiry_days' => ['type' => 'integer', 'default' => 0], + + // 评分 (Wave3) + '_aksrt_score' => ['type' => 'integer', 'default' => 0], + '_aksrt_recommend_score' => ['type' => 'number', 'default' => 0], + '_aksrt_hot_rewarded' => ['type' => 'integer', 'default' => 0], + + // 收藏 (Wave3) + '_aksrt_favorite_count' => ['type' => 'integer', 'default' => 0], + + // 封面扩展位 (§7.3 待实现) + '_aksrt_cover_type' => ['type' => 'string', 'default' => ''], + '_aksrt_cover_image' => ['type' => 'string', 'default' => ''], + '_aksrt_featured_slide' => ['type' => 'array', 'default' => []], + '_aksrt_featured_video' => ['type' => 'string', 'default' => ''], + '_aksrt_cover_ratio' => ['type' => 'string', 'default' => '16:9'], + + // 付费内容扩展位 (§7.3 待实现) + '_aksrt_allow_view' => ['type' => 'string', 'default' => 'public'], + '_aksrt_pay_price' => ['type' => 'number', 'default' => 0], + '_aksrt_pay_vip_price' => ['type' => 'number', 'default' => 0], + '_aksrt_pay_credits' => ['type' => 'integer', 'default' => 0], + '_aksrt_pay_hide_part' => ['type' => 'integer', 'default' => 0], + + // 状态工作流扩展位 (Wave4) + '_aksrt_closed' => ['type' => 'integer', 'default' => 0], + '_aksrt_hidden' => ['type' => 'integer', 'default' => 0], + + // 板块 CPT 关联 (§7.0 新增) + '_aksrt_plate_id' => ['type' => 'integer', 'default' => 0], + ]; + + foreach ($meta_fields as $key => $args) { + $schema = [ + 'type' => $args['type'], + 'description' => $key, + 'single' => true, + 'default' => $args['default'], + 'show_in_rest' => false, + ]; + + if ($args['type'] === 'array') { + $schema['show_in_rest'] = false; + $schema['type'] = 'array'; + } + + register_post_meta($post_type, $key, $schema); + } + + // Plate CPT meta registration + $plate_meta = [ + '_aksrt_sort_order' => ['type' => 'integer', 'default' => 0], + '_aksrt_icon' => ['type' => 'string', 'default' => ''], + '_aksrt_moderators' => ['type' => 'array', 'default' => []], + '_aksrt_moderator_roles' => ['type' => 'array', 'default' => []], + '_aksrt_posts_count' => ['type' => 'integer', 'default' => 0], + '_aksrt_reply_count' => ['type' => 'integer', 'default' => 0], + '_aksrt_today_reply_count' => ['type' => 'integer', 'default' => 0], + '_aksrt_plate_views' => ['type' => 'integer', 'default' => 0], + '_aksrt_follow_count' => ['type' => 'integer', 'default' => 0], + '_aksrt_last_reply' => ['type' => 'string', 'default' => ''], + '_aksrt_last_post' => ['type' => 'string', 'default' => ''], + '_aksrt_allow_view' => ['type' => 'string', 'default' => 'public'], + '_aksrt_add_limit' => ['type' => 'integer', 'default' => 0], + ]; + + foreach ($plate_meta as $key => $args) { + register_post_meta('aksrt_forum_plate', $key, [ + 'type' => $args['type'] === 'array' ? 'array' : $args['type'], + 'description' => $key, + 'single' => true, + 'default' => $args['default'], + 'show_in_rest' => false, + ]); + } +} +add_action('init', 'aksrt_register_forum_topic_meta'); + +// Include forum topics in frontend search results (§7.10). +add_action('pre_get_posts', function ($query) { + if (!is_admin() && $query->is_main_query() && $query->is_search()) { + $post_types = $query->get('post_type'); + if (empty($post_types) || $post_types === 'post') { + $query->set('post_type', ['post', 'page', 'aksrt_forum_topic']); + } elseif (is_array($post_types) && !in_array('aksrt_forum_topic', $post_types, true)) { + $post_types[] = 'aksrt_forum_topic'; + $query->set('post_type', $post_types); + } + } +}); + +// ============================================================ \ No newline at end of file diff --git a/inc/core-hooks/part-2.php b/inc/core-hooks/part-2.php new file mode 100644 index 00000000..a08ecd46 --- /dev/null +++ b/inc/core-hooks/part-2.php @@ -0,0 +1,491 @@ +isSMTP(); + $phpmailer->Host = $mail['host']; + $phpmailer->Port = (int) (isset($mail['port']) ? $mail['port'] : 465); + $phpmailer->SMTPAuth = !empty($mail['username']); + $phpmailer->Username = $mail['username']; + $phpmailer->Password = $mail['password']; + $phpmailer->SMTPSecure = isset($mail['encryption']) ? $mail['encryption'] : 'ssl'; + + // 性能优化:超时 + 连接保持 + $phpmailer->Timeout = 10; // 连接/读取超时 10 秒 + $phpmailer->Timelimit = 15; // 总执行上限 15 秒 + + if (!empty($mail['from_address'])) { + $from_name = isset($mail['from_name']) ? $mail['from_name'] : ''; + // mail_showname 覆盖 from_name + if (class_exists('AKSRT_Settings')) { + $branding = AKSRT_Settings::get_mail_branding(); + if ($branding['showname'] !== '') { + $from_name = $branding['showname']; + } + } + $phpmailer->setFrom($mail['from_address'], $from_name); + } + if (!empty($mail['from_name'])) { + $phpmailer->FromName = $mail['from_name']; + } + // mail_showname 全局覆盖 FromName + if (class_exists('AKSRT_Settings')) { + $branding = AKSRT_Settings::get_mail_branding(); + if ($branding['showname'] !== '') { + $phpmailer->FromName = $branding['showname']; + } + } +}); + +// mail_title_prefix:为所有邮件主题添加前缀 +add_filter('wp_mail', function ($atts) { + if (!class_exists('AKSRT_Settings') || empty($atts['subject'])) return $atts; + $branding = AKSRT_Settings::get_mail_branding(); + if ($branding['title_prefix'] !== '' && strpos($atts['subject'], $branding['title_prefix']) !== 0) { + $atts['subject'] = $branding['title_prefix'] . ' ' . $atts['subject']; + } + return $atts; +}); + +// ============================================================ +// 分类 Term Meta 字段(封面图 + 推荐描述) +// ============================================================ + +/** + * 编辑分类页面 - 显示自定义字段 + */ +function aksrt_category_meta_fields($term) { + $cover_id = (int) get_term_meta($term->term_id, 'aksrt_category_cover_id', true); + $cover_url = $cover_id > 0 ? wp_get_attachment_url($cover_id) : ''; + $recommend_desc = (string) get_term_meta($term->term_id, 'aksrt_category_recommend_desc', true); + ?> + + + +
+ + + +
+ + + +

+ + + + + + +

+ + + +
+ +
+ + + +

+
+
+ + +

+
+ 0) { + update_term_meta($term_id, 'aksrt_category_cover_id', $cover_id); + } else { + delete_term_meta($term_id, 'aksrt_category_cover_id'); + } + + if ($recommend_desc !== '') { + update_term_meta($term_id, 'aksrt_category_recommend_desc', $recommend_desc); + } else { + delete_term_meta($term_id, 'aksrt_category_recommend_desc'); + } + + if (class_exists('AKSRT_Settings')) { + AKSRT_Settings::flush_category_recommend_cache(); + } +} + +add_action('edited_category', 'aksrt_save_category_meta', 10, 1); +add_action('created_category', 'aksrt_save_category_meta', 10, 1); + +/** + * 分类编辑页加载媒体库脚本(wp_enqueue_media 仅在 AKSRT 面板加载,此处补上) + */ +add_action('admin_enqueue_scripts', function (string $hook_suffix) { + if ($hook_suffix === 'edit-tags.php' || $hook_suffix === 'term.php') { + wp_enqueue_media(); + } +}); + +/** + * 分类编辑页内联 JS - 媒体选择器 + */ +function aksrt_category_meta_admin_js() { + $screen = get_current_screen(); + if (!$screen || $screen->taxonomy !== 'category') return; + ?> + + ID), 301); + exit; + } +}); + +add_action('pre_get_posts', function ($query) { + if (is_admin() || !$query->is_main_query()) return; + + $is_resource_archive = (bool) $query->get('aksrt_is_resource_archive') + || (post_type_exists('aksrt_resource') && $query->is_post_type_archive('aksrt_resource')); + + // 资源归档页:把查询锁定到「post + _aksrt_is_resource = 1」(含历史 CPT 兼容) + if ($is_resource_archive) { + $query->set('post_type', function_exists('aksrt_resource_post_types') ? aksrt_resource_post_types() : ['post']); + $query->set('post_status', 'publish'); + + $orderby = isset($_GET['orderby']) ? sanitize_key((string) $_GET['orderby']) : 'date'; + if ($orderby === 'downloads') { + $query->set('meta_key', AKSRT_Domain_Model::RESOURCE_DOWNLOAD_COUNT_KEY); + $query->set('orderby', 'meta_value_num'); + $query->set('order', 'DESC'); + } elseif ($orderby === 'views') { + $query->set('meta_key', AKSRT_Domain_Model::RESOURCE_VIEW_COUNT_KEY); + $query->set('orderby', 'meta_value_num'); + $query->set('order', 'DESC'); + } + + // 价格筛选:?price=free | paid | all + // 价格区间:?price_min=0&price_max=99 + $price_filter = isset($_GET['price']) ? sanitize_key((string) $_GET['price']) : ''; + $price_min = isset($_GET['price_min']) && $_GET['price_min'] !== '' ? (float) $_GET['price_min'] : null; + $price_max = isset($_GET['price_max']) && $_GET['price_max'] !== '' ? (float) $_GET['price_max'] : null; + + // 「是资源」基础约束:_aksrt_is_resource=1(兼容旧 CPT 时由 post_type 兜底) + $meta_query = [ + 'relation' => 'AND', + [ + 'relation' => 'OR', + // 新形态:post + meta=1 + [ + 'key' => AKSRT_Domain_Model::RESOURCE_MARKER_KEY, + 'value' => '1', + 'compare' => '=', + ], + // 历史 CPT 兼容:通过 post_type 已被包含,meta 缺失也算资源 + [ + 'key' => AKSRT_Domain_Model::RESOURCE_MARKER_KEY, + 'compare' => 'NOT EXISTS', + ], + ], + ]; + if ($price_filter === 'free') { + $meta_query[] = [ + 'relation' => 'OR', + [ + 'key' => AKSRT_Domain_Model::PAY_PRICE_KEY, + 'value' => 0, + 'compare' => '=', + 'type' => 'NUMERIC', + ], + [ + 'key' => AKSRT_Domain_Model::PAY_PRICE_KEY, + 'compare' => 'NOT EXISTS', + ], + ]; + } elseif ($price_filter === 'paid') { + $meta_query[] = [ + 'key' => AKSRT_Domain_Model::PAY_PRICE_KEY, + 'value' => 0, + 'compare' => '>', + 'type' => 'NUMERIC', + ]; + } + + if ($price_min !== null || $price_max !== null) { + if ($price_min !== null && $price_max !== null) { + $meta_query[] = [ + 'key' => AKSRT_Domain_Model::PAY_PRICE_KEY, + 'value' => [$price_min, $price_max], + 'compare' => 'BETWEEN', + 'type' => 'NUMERIC', + ]; + } elseif ($price_min !== null) { + $meta_query[] = [ + 'key' => AKSRT_Domain_Model::PAY_PRICE_KEY, + 'value' => $price_min, + 'compare' => '>=', + 'type' => 'NUMERIC', + ]; + } elseif ($price_max !== null) { + $meta_query[] = [ + 'key' => AKSRT_Domain_Model::PAY_PRICE_KEY, + 'value' => $price_max, + 'compare' => '<=', + 'type' => 'NUMERIC', + ]; + } + } + + // 仅在新形态(post)下加 _aksrt_is_resource=1 强约束; + // 旧 CPT 仍在站点时,post_type 已含 aksrt_resource,配合上面 OR 兼容逻辑即可。 + if (!post_type_exists('aksrt_resource')) { + // 替换 OR 兼容片段为强等于 + $meta_query[0] = [ + 'key' => AKSRT_Domain_Model::RESOURCE_MARKER_KEY, + 'value' => '1', + 'compare' => '=', + ]; + } + + if (!empty($meta_query)) { + $existing = $query->get('meta_query'); + if (!is_array($existing)) $existing = []; + $query->set('meta_query', array_merge($existing, $meta_query)); + } + + // 资源归档不要分类/标签限制 + $query->set('cat', 0); + $query->set('tag', ''); + } + + // 站内搜索:post 已默认覆盖(资源就是 post),仅在仍有旧 CPT 时合并 + if ($query->is_search() && empty($query->get('post_type'))) { + $search_type = sanitize_key((string) $query->get('aksrt_search_type')); + + if (in_array($search_type, ['category', 'tag'], true)) { + $taxonomy = $search_type === 'category' ? 'category' : 'post_tag'; + $term_ids = get_terms([ + 'taxonomy' => $taxonomy, + 'hide_empty' => false, + 'fields' => 'ids', + 'number' => 20, + 'search' => (string) $query->get('s'), + ]); + + $query->set('s', ''); + + if (is_wp_error($term_ids) || empty($term_ids)) { + $query->set('post__in', [0]); + } else { + $query->set('tax_query', [[ + 'taxonomy' => $taxonomy, + 'field' => 'term_id', + 'terms' => array_map('absint', $term_ids), + ]]); + } + } + + if (post_type_exists('aksrt_resource')) { + // 单一数据选择策略:搜索的内容类型与索引/推送共用同一 helper。 + $search_types = function_exists('aksrt_searchable_post_types') + ? aksrt_searchable_post_types() + : ['post']; + if (count($search_types) > 1) { + $query->set('post_type', $search_types); + } + } + } + + // 分类 / 标签归档:post 已自然包含资源(meta 标记),仅当历史 CPT 仍在时才需合并 post_type + if (($query->is_category() || $query->is_tag()) && post_type_exists('aksrt_resource')) { + $existing = $query->get('post_type'); + if (empty($existing)) { + $query->set('post_type', ['post', 'aksrt_resource']); + } + } + + // v4 起主题统一为 CMS / 资源站形态,所有归档都允许包含资源帖。 + // 历史的 blog/cms 双流分流(按 site_profile + term flow 排除资源)已移除。 +}); + +// 资源归档页:套 archive-aksrt_resource.php 模板(v3.4 起作为「资源虚拟归档」复用) +add_filter('template_include', function ($template) { + if (!get_query_var('aksrt_is_resource_archive')) return $template; + $candidate = locate_template(['archive-aksrt_resource.php']); + return $candidate ?: $template; +}); + +// 标记为资源的 post:单页改用 single-aksrt_resource.php 模板 +add_filter('single_template', function ($template) { + if (!is_singular('post')) return $template; + $post = get_queried_object(); + if (!($post instanceof WP_Post)) return $template; + if (!function_exists('aksrt_is_resource_post')) return $template; + if (!aksrt_is_resource_post($post)) return $template; + $candidate = locate_template(['single-aksrt_resource.php']); + return $candidate ?: $template; +}); + +// 资源归档页:标题 +add_filter('document_title_parts', function ($parts) { + if (get_query_var('aksrt_is_resource_archive')) { + $parts['title'] = '资源'; + } + return $parts; +}); + +// ============================================================ \ No newline at end of file diff --git a/inc/core-hooks/part-3.php b/inc/core-hooks/part-3.php new file mode 100644 index 00000000..6762538a --- /dev/null +++ b/inc/core-hooks/part-3.php @@ -0,0 +1,202 @@ + and + $enlighter_attrs = [ + 'data-enlighter-language' => true, + 'data-enlighter-theme' => true, + 'data-enlighter-group' => true, + 'data-enlighter-title' => true, + 'data-enlighter-linenumbers' => true, + 'data-enlighter-highlight' => true, + 'data-enlighter-lineoffset' => true, + ]; + + if (isset($allowed['pre'])) { + $allowed['pre'] = array_merge($allowed['pre'], $enlighter_attrs); + } + if (isset($allowed['code'])) { + $allowed['code'] = array_merge($allowed['code'], $enlighter_attrs); + } + + return $allowed; +}, 10, 2); + +// ============================================================ +// WP 后台文章列表增强列 — 浏览/点赞/收藏/下载 +// ============================================================ + +add_filter('manage_post_posts_columns', function ($columns) { + $new_columns = []; + foreach ($columns as $key => $label) { + $new_columns[$key] = $label; + if ($key === 'title') { + $new_columns['aksrt_stats'] = __('互动数据', 'aksrt'); + } + } + return $new_columns; +}); + +add_action('admin_enqueue_scripts', function ($hook) { + if ($hook !== 'edit.php') return; + wp_enqueue_style('font-awesome-5', 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css', [], '5.15.4'); +}); + +add_action('manage_post_posts_custom_column', function ($column, $post_id) { + if ($column !== 'aksrt_stats') return; + + $views = (int) get_post_meta($post_id, 'views', true); + $likes = (int) get_post_meta($post_id, 'like', true); + $favorites = (int) get_post_meta($post_id, 'favorite', true); + $downloads = (int) get_post_meta($post_id, 'download_count', true); + + $format = function (int $n): string { + if ($n >= 10000) return round($n / 10000, 1) . '万'; + if ($n >= 1000) return round($n / 1000, 1) . 'k'; + return (string) $n; + }; + + $parts = []; + $parts[] = ' ' . esc_html($format($views)) . ''; + $parts[] = ' ' . esc_html($format($likes)) . ''; + $parts[] = ' ' . esc_html($format($favorites)) . ''; + + if ($downloads > 0) { + $parts[] = ' ' . esc_html($format($downloads)) . ''; + } + + echo implode(' · ', $parts); +}, 10, 2); + +// Support sorting by views/likes/favorites in admin +add_action('pre_get_posts', function ($query) { + if (!is_admin() || !$query->is_main_query()) return; + + $orderby = $query->get('orderby'); + $allowed = ['views', 'like', 'favorite', 'download_count']; + + if (in_array($orderby, $allowed, true)) { + $query->set('meta_key', $orderby); + $query->set('orderby', 'meta_value_num'); + } +}); + +// ============================================================ +// 评论提交:单一合同 +// ------------------------------------------------------------ +// 评论提交统一走 WordPress 原生 wp-comments-post.php,由前端 +// main.js initCommentSystem() 以 AJAX 拦截:它负责人机验证、频率 +// 限制、审核管线,并在成功后通过 reloadCommentList() 重新拉取, +// 列表仍由 aksrt_comment_callback 这一规范渲染器输出。 +// 不再保留并行的 aksrt_submit_comment 动作(避免绕过验证码的第二套提交合同)。 +// 详见 comment-contract.md。 +// ============================================================ + +// ============================================================ +// Gutenberg 自定义块注册 +// ============================================================ + +add_action('enqueue_block_editor_assets', function () { + $base_uri = get_template_directory_uri(); + $version = wp_get_theme()->get('Version'); + + $blocks_js = get_template_directory() . '/assets/js/gutenberg-blocks.js'; + if (file_exists($blocks_js)) { + wp_enqueue_script( + 'aksrt-gutenberg-blocks', + $base_uri . '/assets/js/gutenberg-blocks.js', + ['wp-blocks', 'wp-element', 'wp-components', 'wp-block-editor', 'wp-hooks'], + (string) filemtime($blocks_js), + true + ); + } + + $blocks_css = get_template_directory() . '/assets/css/gutenberg-blocks.css'; + if (file_exists($blocks_css)) { + wp_enqueue_style( + 'aksrt-gutenberg-blocks', + $base_uri . '/assets/css/gutenberg-blocks.css', + [], + (string) filemtime($blocks_css) + ); + } +}); + +// Front-end styles for Gutenberg blocks +add_action('enqueue_block_assets', function () { + if (is_admin()) return; // Already loaded in editor + + $base_uri = get_template_directory_uri(); + $blocks_css = get_template_directory() . '/assets/css/gutenberg-blocks.css'; + if (file_exists($blocks_css)) { + wp_enqueue_style( + 'aksrt-gutenberg-blocks-front', + $base_uri . '/assets/css/gutenberg-blocks.css', + [], + (string) filemtime($blocks_css) + ); + } +}); + +// ============================================================ +// Gutenberg Carousel Block 渲染回调 +// ============================================================ +add_action('init', function () { + if (!function_exists('register_block_type')) return; + + register_block_type('aksrt/carousel', [ + 'render_callback' => function (array $attributes): string { + $images = $attributes['images'] ?? []; + if (empty($images)) return ''; + + $autoplay = !empty($attributes['autoplay']) ? 'true' : 'false'; + $delay = absint($attributes['delay'] ?? 3000); + $effect = sanitize_text_field($attributes['effect'] ?? 'slide'); + + $slides = ''; + foreach ($images as $img) { + if (empty($img['url'])) continue; + $alt = esc_attr($img['alt'] ?? ''); + $url = esc_url($img['url']); + $slides .= '
' . $alt . '
'; + } + + if (empty($slides)) return ''; + + return ''; + }, + ]); +}); + +// ============================================================ +// 付费内容系统:the_content 过滤器 +// ============================================================ + +/** + * 未付费内容:替换为付费墙。 + */ +add_filter('the_content', 'aksrt_paid_content_filter', 20); + +/** + * 已付费内容增强:追加图库轮播/视频播放器。 + */ +add_filter('the_content', 'aksrt_paid_content_enhance', 25); + +/** + * 付费文章使用统一的 single.php(不再路由到 single-aksrt_resource.php)。 + */ +add_filter('single_template', function (string $template): string { + return $template; +}); diff --git a/inc/helpers/aksrt-credits.php b/inc/helpers/aksrt-credits.php new file mode 100644 index 00000000..3de1cc9a --- /dev/null +++ b/inc/helpers/aksrt-credits.php @@ -0,0 +1,53 @@ + __('注册赠送', 'aksrt'), + 'signin' => __('每日签到', 'aksrt'), + 'comment' => __('发表评论', 'aksrt'), + 'invite' => __('邀请奖励', 'aksrt'), + 'invitee' => __('被邀请奖励', 'aksrt'), + 'download' => __('下载消费', 'aksrt'), + 'admin' => __('管理员调整', 'aksrt'), + 'refund' => __('退款', 'aksrt'), + ]; + return $map[$reason] ?? $reason; + } +} diff --git a/inc/helpers/aksrt-domain-model.php b/inc/helpers/aksrt-domain-model.php new file mode 100644 index 00000000..09f26f02 --- /dev/null +++ b/inc/helpers/aksrt-domain-model.php @@ -0,0 +1,423 @@ + + */ + public static function default_post_meta(): array { + return [ + 'cover_images' => '', + 'custom_excerpt' => '', + 'layout_override' => '', + 'header_image_mode' => '', + 'show_toc' => '', + 'show_reading_progress' => '', + 'enable_lightbox' => '', + 'show_related' => '', + 'related_count' => 0, + 'enable_like' => '', + 'enable_favorite' => '', + 'enable_share' => '', + 'enable_print' => '', + 'enable_pdf' => '', + 'copyright_text' => '', + 'is_featured' => false, + // §3.8 文章扩展字段 + 'thumbnail_url' => '', + 'featured_slide' => '', + 'featured_video' => '', + 'featured_video_episode' => 0, + 'subtitle' => '', + 'views' => 0, + 'likes' => 0, + 'maxheight' => 0, + ]; + } + + /** + * @return array + */ + public static function default_term_meta(): array { + return [ + 'cover_url' => '', + 'description' => '', + 'layout_mode' => '', + 'show_sidebar' => '', + 'show_header' => true, + 'show_featured' => false, + ]; + } + + /** + * @return array + */ + public static function default_pay_meta(): array { + return [ + 'pay_type' => self::PAY_TYPE_NO, + 'price' => 0.0, + 'original_price' => 0.0, + 'vip1_price' => 0.0, + 'vip2_price' => 0.0, + 'version' => '', + 'size' => '', + 'license' => '', + 'compat' => '', + 'demo_url' => '', + 'video_url' => '', + 'downloads' => [], + 'attributes' => [], + 'gallery' => [], + 'require_login' => false, + 'require_captcha' => false, + // §3.8 电商增强字段 + 'virtual_sales' => 0, + 'stock' => -1, + 'card_keys' => [], + 'purchase_limit' => 0, + 'free_preview' => '', + 'show_sales' => true, + ]; + } + + /** + * @return array + */ + public static function pay_type_labels(): array { + return [ + self::PAY_TYPE_NO => '不付费', + self::PAY_TYPE_READING => '付费阅读', + self::PAY_TYPE_DOWNLOAD => '付费下载', + self::PAY_TYPE_GALLERY => '付费图库', + self::PAY_TYPE_VIDEO => '付费视频', + self::PAY_TYPE_VIP => 'VIP 专属', + ]; + } + + public static function normalize_pay_type(string $pay_type): string { + $pay_type = sanitize_key($pay_type); + return array_key_exists($pay_type, self::pay_type_labels()) ? $pay_type : self::PAY_TYPE_NO; + } + + public static function is_resource_pay_type(string $pay_type): bool { + return self::normalize_pay_type($pay_type) === self::PAY_TYPE_DOWNLOAD; + } + + /** + * @param mixed $value + * @return array> + */ + public static function sanitize_downloads($value): array { + if (!is_array($value)) { + return []; + } + + $allowed_types = ['direct', 'net_disk', 'external']; + $downloads = []; + + foreach ($value as $row) { + if (!is_array($row)) { + continue; + } + + $url = isset($row['url']) ? esc_url_raw((string) $row['url']) : ''; + if ($url === '') { + continue; + } + + $type = isset($row['type']) && in_array($row['type'], $allowed_types, true) + ? (string) $row['type'] + : 'direct'; + + $extract_code = ''; + foreach ([ + 'extract_code', + 'extractCode', + 'extract', + 'extract_key', + 'extraction_code', + 'extractionCode', + 'code', + 'password', + 'pwd', + 'pan_code', + 'panCode', + 'pan_password', + 'panPassword', + 'access_code', + 'accessCode', + ] as $extract_key) { + if (isset($row[$extract_key]) && !is_array($row[$extract_key])) { + $candidate = trim((string) $row[$extract_key]); + if ($candidate !== '') { + $extract_code = sanitize_text_field($candidate); + break; + } + } + } + + $downloads[] = [ + 'label' => isset($row['label']) ? sanitize_text_field((string) $row['label']) : '', + 'url' => $url, + 'type' => $type, + 'extract_code' => $extract_code, + ]; + } + + return array_values($downloads); + } + + /** + * @param mixed $value + * @return array> + */ + public static function sanitize_attributes($value): array { + if (!is_array($value)) { + return []; + } + + $attributes = []; + + foreach ($value as $row) { + if (!is_array($row)) { + continue; + } + + $key = sanitize_text_field((string) ($row['key'] ?? '')); + $item_value = sanitize_text_field((string) ($row['value'] ?? '')); + + if ($key === '' && $item_value === '') { + continue; + } + + $attributes[] = [ + 'key' => $key, + 'value' => $item_value, + ]; + } + + return array_values($attributes); + } + + /** + * @param mixed $value + * @return string[] + */ + public static function sanitize_gallery($value): array { + if (!is_array($value)) { + return []; + } + + $gallery = []; + foreach ($value as $url) { + $url = esc_url_raw((string) $url); + if ($url !== '') { + $gallery[] = $url; + } + } + + return array_values(array_unique($gallery)); + } + + /** + * Sanitize card/key codes for auto-delivery. + * Each entry is a non-empty sanitized string (one code per line). + */ + public static function sanitize_card_keys($value): array { + if (!is_array($value)) { + return []; + } + + $keys = []; + foreach ($value as $key) { + $key = sanitize_text_field((string) $key); + if ($key !== '') { + $keys[] = $key; + } + } + + return $keys; + } + + /** + * @param array $raw + * @return array + */ + public static function sanitize_pay_meta(array $raw): array { + $meta = self::default_pay_meta(); + $meta['pay_type'] = self::normalize_pay_type((string) ($raw['pay_type'] ?? $meta['pay_type'])); + $meta['price'] = max(0, (float) ($raw['price'] ?? 0)); + $meta['original_price'] = max(0, (float) ($raw['original_price'] ?? 0)); + $meta['vip1_price'] = max(0, (float) ($raw['vip1_price'] ?? 0)); + $meta['vip2_price'] = max(0, (float) ($raw['vip2_price'] ?? 0)); + $meta['version'] = sanitize_text_field((string) ($raw['version'] ?? '')); + $meta['size'] = sanitize_text_field((string) ($raw['size'] ?? '')); + $meta['license'] = sanitize_text_field((string) ($raw['license'] ?? '')); + $meta['compat'] = sanitize_text_field((string) ($raw['compat'] ?? '')); + $meta['demo_url'] = esc_url_raw((string) ($raw['demo_url'] ?? '')); + $meta['video_url'] = esc_url_raw((string) ($raw['video_url'] ?? '')); + $meta['downloads'] = self::sanitize_downloads($raw['downloads'] ?? []); + $meta['attributes'] = self::sanitize_attributes($raw['attributes'] ?? []); + $meta['gallery'] = self::sanitize_gallery($raw['gallery'] ?? []); + $meta['require_login'] = !empty($raw['require_login']); + $meta['require_captcha'] = !empty($raw['require_captcha']); + + // §3.8 电商增强字段 + $meta['virtual_sales'] = max(0, (int) ($raw['virtual_sales'] ?? 0)); + $meta['stock'] = (int) ($raw['stock'] ?? -1); + $meta['card_keys'] = self::sanitize_card_keys($raw['card_keys'] ?? []); + $meta['purchase_limit'] = max(0, (int) ($raw['purchase_limit'] ?? 0)); + $meta['free_preview'] = wp_kses_post((string) ($raw['free_preview'] ?? '')); + $meta['show_sales'] = !isset($raw['show_sales']) || !empty($raw['show_sales']); + + return $meta; + } + + /** + * @return array + */ + public static function order_status_labels(): array { + return [ + self::ORDER_STATUS_CREATED => __('已创建', 'aksrt'), + self::ORDER_STATUS_PENDING => __('待支付', 'aksrt'), + self::ORDER_STATUS_PAID => __('已支付', 'aksrt'), + self::ORDER_STATUS_DELIVERED => __('已发货', 'aksrt'), + self::ORDER_STATUS_COMPLETED => __('已完成', 'aksrt'), + self::ORDER_STATUS_CANCELLED => __('已取消', 'aksrt'), + self::ORDER_STATUS_EXPIRED => __('已过期', 'aksrt'), + self::ORDER_STATUS_REFUNDING => __('退款中', 'aksrt'), + self::ORDER_STATUS_REFUNDED => __('已退款', 'aksrt'), + self::ORDER_STATUS_PENDING_REVIEW => __('待评价', 'aksrt'), + ]; + } + + /** + * @return array + */ + public static function order_transitions(): array { + return [ + self::ORDER_STATUS_CREATED => [ + self::ORDER_STATUS_PENDING, + self::ORDER_STATUS_CANCELLED, + ], + self::ORDER_STATUS_PENDING => [ + self::ORDER_STATUS_PAID, + self::ORDER_STATUS_CANCELLED, + self::ORDER_STATUS_EXPIRED, + ], + self::ORDER_STATUS_PAID => [ + self::ORDER_STATUS_DELIVERED, + self::ORDER_STATUS_COMPLETED, + self::ORDER_STATUS_REFUNDING, + ], + self::ORDER_STATUS_DELIVERED => [ + self::ORDER_STATUS_COMPLETED, + self::ORDER_STATUS_PENDING_REVIEW, + self::ORDER_STATUS_REFUNDING, + ], + self::ORDER_STATUS_COMPLETED => [ + self::ORDER_STATUS_PENDING_REVIEW, + self::ORDER_STATUS_REFUNDING, + ], + self::ORDER_STATUS_CANCELLED => [], + self::ORDER_STATUS_EXPIRED => [ + self::ORDER_STATUS_PENDING, + ], + self::ORDER_STATUS_REFUNDING => [ + self::ORDER_STATUS_REFUNDED, + self::ORDER_STATUS_PAID, + ], + self::ORDER_STATUS_REFUNDED => [], + self::ORDER_STATUS_PENDING_REVIEW => [ + self::ORDER_STATUS_COMPLETED, + self::ORDER_STATUS_REFUNDING, + ], + ]; + } + + public static function normalize_order_status(string $status, string $default = self::ORDER_STATUS_PENDING): string { + $status = sanitize_key($status); + return array_key_exists($status, self::order_status_labels()) ? $status : $default; + } + + public static function can_transition_order_status(string $from, string $to): bool { + $from = self::normalize_order_status($from, self::ORDER_STATUS_CREATED); + $to = self::normalize_order_status($to); + $transitions = self::order_transitions(); + + return isset($transitions[$from]) && in_array($to, $transitions[$from], true); + } + + /** + * @return string[] + */ + public static function paid_order_statuses(): array { + return [ + self::ORDER_STATUS_PAID, + self::ORDER_STATUS_DELIVERED, + self::ORDER_STATUS_COMPLETED, + ]; + } + + public static function is_paid_order_status(string $status): bool { + return in_array(self::normalize_order_status($status), self::paid_order_statuses(), true); + } + + /** + * @return string[] + */ + public static function vip_active_order_statuses(): array { + return self::paid_order_statuses(); + } +} diff --git a/inc/helpers/aksrt-flow.php b/inc/helpers/aksrt-flow.php new file mode 100644 index 00000000..e2ca0088 --- /dev/null +++ b/inc/helpers/aksrt-flow.php @@ -0,0 +1,93 @@ +post_type === 'aksrt_resource') return true; + if ($p->post_type !== 'post') return false; + return (bool) get_post_meta($p->ID, '_aksrt_is_resource', true); + } +} + +if (!function_exists('aksrt_get_post_flow')) { + /** + * @deprecated v4 起恒为 'cms'。 + * + * @param int|WP_Post|null $post + * @return string 'cms' + */ + function aksrt_get_post_flow($post = null): string { + unset($post); + return 'cms'; + } +} + +if (!function_exists('aksrt_is_cms_mode')) { + /** + * 当前请求是否处在 CMS 形态。v4 起全站统一 CMS。 + * + * @return bool + */ + function aksrt_is_cms_mode(): bool { + return true; + } +} + +if (!function_exists('aksrt_flow_layout_for')) { + /** + * @deprecated v4 起恒为 'grid-cms'。 + * + * @param string $flow + * @return string + */ + function aksrt_flow_layout_for(string $flow): string { + unset($flow); + return 'grid-cms'; + } +} diff --git a/inc/helpers/aksrt-frontend-tags.php b/inc/helpers/aksrt-frontend-tags.php new file mode 100644 index 00000000..0ec1676d --- /dev/null +++ b/inc/helpers/aksrt-frontend-tags.php @@ -0,0 +1,1062 @@ + + */ + function aksrt_comment_smilie_map(): array { + return [ + 'grinning' => '😀', + 'joy' => '😂', + 'rofl' => '🤣', + 'blush' => '😊', + 'heart-eyes' => '😍', + 'smiling-hearts' => '🥰', + 'sunglasses' => '😎', + 'thinking' => '🤔', + 'cry' => '😢', + 'sob' => '😭', + 'angry' => '😡', + 'pleading' => '🥺', + 'scream' => '😱', + 'rolling-eyes' => '🙄', + 'sleeping' => '😴', + 'nauseated' => '🤮', + 'thumbs-up' => '👍', + 'thumbs-down' => '👎', + 'clap' => '👏', + 'pray' => '🙏', + 'muscle' => '💪', + 'heart' => '❤️', + 'fire' => '🔥', + 'star' => '⭐', + 'tada' => '🎉', + 'confetti' => '🎊', + 'check' => '✅', + 'cross' => '❌', + 'warning' => '⚠️', + 'hundred' => '💯', + 'ok' => '🆗', + 'new' => '🆕', + ]; + } +endif; + +if (!function_exists('aksrt_render_comment_smilie')) : + /** + * 渲染单个 grin 表情为 smilie-icon 图片。 + * + * @param string $key grin key. + * @return string + */ + function aksrt_render_comment_smilie(string $key): string { + $map = aksrt_comment_smilie_map(); + if (!isset($map[$key])) { + return '[g=' . $key . ']'; + } + + $emoji = $map[$key]; + $svg = '' . esc_html($emoji) . ''; + $src = 'data:image/svg+xml;charset=UTF-8,' . rawurlencode($svg); + + return sprintf( + '%2$s', + esc_url($src), + esc_attr($emoji), + esc_attr($key) + ); + } +endif; + +if (!function_exists('aksrt_render_comment_content')) : + /** + * 将评论中的 grin token 渲染为 smilie-icon,并按 post 白名单输出。 + * + * @param string $content Comment HTML/text. + * @return string + */ + function aksrt_render_comment_content(string $content): string { + $rendered = preg_replace_callback('/\[g=([a-z0-9-]+)\]/i', static function (array $matches): string { + return aksrt_render_comment_smilie(sanitize_key($matches[1])); + }, $content); + + $allowed = wp_kses_allowed_html('post'); + if (!isset($allowed['img'])) { + $allowed['img'] = []; + } + $allowed['img'] = array_merge($allowed['img'], [ + 'class' => true, + 'src' => true, + 'alt' => true, + 'title' => true, + 'width' => true, + 'height' => true, + 'loading' => true, + 'decoding' => true, + 'data-smilie-key' => true, + ]); + + return wp_kses((string) $rendered, $allowed); + } +endif; + +if (!function_exists('aksrt_reading_minutes')) : + /** + * 估算文章阅读分钟数 + * + * @param int|null $post_id 文章 ID,默认当前文章 + * @return int + */ +function aksrt_reading_minutes($post_id = null) { + $post = get_post($post_id); + if (!$post) { + return 1; + } + $text = wp_strip_all_tags($post->post_content); + $han = 0; + if (preg_match_all('/\p{Han}/u', $text, $m)) { + $han = count($m[0]); + } + $words = str_word_count($text) + (int) ceil($han / 2); + if ($words < 1) { + $words = (int) max(1, strlen($text) / 4); + } + $minutes = (int) ceil($words / 220); + return max(1, $minutes); +} +endif; + +if (!function_exists('aksrt_post_word_count')) : + /** + * Approximate visible word count for single-post meta display. + * + * @param int|null $post_id Post ID. + * @return int + */ + function aksrt_post_word_count($post_id = null): int { + $post = get_post($post_id); + if (!($post instanceof WP_Post)) { + return 0; + } + + $text = wp_strip_all_tags((string) $post->post_content); + $han = 0; + if (preg_match_all('/\p{Han}/u', $text, $matches)) { + $han = count($matches[0]); + } + + $latin_words = str_word_count($text); + $total = $latin_words + $han; + if ($total <= 0) { + $total = (int) ceil(strlen($text) / 2); + } + + return max(0, $total); + } +endif; + +if (!function_exists('aksrt_resolve_single_post_display_settings')) : + /** + * Resolve single post/resource display settings, including post-meta overrides. + * + * @param int|null $post_id Post ID. + * @return array + */ + function aksrt_resolve_single_post_display_settings($post_id = null): array { + $post = get_post($post_id); + if (!($post instanceof WP_Post)) { + return []; + } + + $settings = class_exists('AKSRT_Settings') + ? AKSRT_Settings::get_post_page_settings() + : [ + 'toc_enabled' => true, + 'toc_depth' => 3, + 'reading_progress' => true, + 'lightbox' => true, + 'related_enabled' => true, + 'related_count' => 6, + 'related_type' => 'category', + 'related_orderby' => 'rand', + 'related_title' => '', + 'like_enabled' => true, + 'favorite_enabled' => true, + 'share_enabled' => true, + 'copyright_enabled' => true, + 'copyright_text' => '', + 'front_content' => '', + 'after_content' => '', + 'yiyan_header' => false, + 'yiyan_footer' => false, + 'yiyan_box' => false, + 'print_enabled' => true, + 'pdf_export_enabled' => false, + 'word_count_enabled' => true, + 'reading_time_enabled' => true, + ]; + + $meta = class_exists('AKSRT_Post_Meta_Box') ? AKSRT_Post_Meta_Box::get_meta($post->ID) : []; + if (($meta['show_toc'] ?? '') !== '') { + $settings['toc_enabled'] = $meta['show_toc'] === '1'; + } + + foreach ([ + 'show_reading_progress' => 'reading_progress', + 'enable_lightbox' => 'lightbox', + 'show_related' => 'related_enabled', + 'enable_like' => 'like_enabled', + 'enable_favorite' => 'favorite_enabled', + 'enable_share' => 'share_enabled', + 'enable_print' => 'print_enabled', + 'enable_pdf' => 'pdf_export_enabled', + ] as $meta_key => $setting_key) { + if (($meta[$meta_key] ?? '') === '') { + continue; + } + + $settings[$setting_key] = $meta[$meta_key] === '1'; + } + + if (!empty($meta['related_count'])) { + $settings['related_count'] = max(1, min(20, (int) $meta['related_count'])); + } + + $cover_mode = (string) ($meta['header_image_mode'] ?? ''); + $cover_url = ''; + $cover_type = 'none'; + + // Check global cover toggles; per-post header_image_mode='none' overrides all. + $image_cover_on = !empty($settings['article_image_cover']); + $slide_cover_on = !empty($settings['article_slide_cover']); + $video_cover_on = !empty($settings['article_video_cover']); + + if ($cover_mode !== 'none') { + // 1. Video cover — check for video meta (featured_video from metabox). + $video_url = ''; + if ($video_cover_on) { + $video_url = (string) ($meta['featured_video'] ?? ''); + } + + // 2. Slide cover — check for gallery/cover_images. + $slide_images = []; + if ($slide_cover_on && !empty($meta['cover_images'])) { + $slide_images = array_filter(array_map('trim', explode(',', (string) $meta['cover_images']))); + } + + // 3. Image cover — existing logic. + if ($video_url !== '') { + $cover_type = 'video'; + $cover_url = $video_url; + } elseif (!empty($slide_images) && count($slide_images) > 1) { + $cover_type = 'slide'; + $cover_url = $slide_images[0]; + } elseif ($image_cover_on) { + if ($cover_mode === 'auto') { + $cover_url = function_exists('aksrt_get_first_content_image_url') + ? (string) aksrt_get_first_content_image_url($post->ID) + : ''; + } + if ($cover_url === '' && has_post_thumbnail($post)) { + $cover_url = (string) get_the_post_thumbnail_url($post, 'full'); + } + if ($cover_url !== '') { + $cover_type = 'image'; + } + } + } + + $settings['cover_mode'] = $cover_mode; + $settings['cover_url'] = $cover_url; + $settings['cover_type'] = $cover_type; + $settings['show_cover'] = $cover_type !== 'none'; + $settings['slide_images'] = $slide_images ?? []; + $meta_copyright = trim((string) ($meta['copyright_text'] ?? '')); + if ($meta_copyright !== '') { + $settings['copyright_text'] = $meta_copyright; + } + + return $settings; + } +endif; + +if (!function_exists('aksrt_breadcrumbs')) : + /** + * 输出面包屑 HTML + */ + function aksrt_breadcrumbs(string $home_text = '') { + if (is_front_page()) { + return; + } + + if ($home_text === '') { + $home_text = __('首页', 'aksrt'); + } + + $items = []; + $items[] = '
  • ' . esc_html($home_text) . '
  • '; + + if (is_category()) { + $items[] = '
  • ' . esc_html(single_cat_title('', false)) . '
  • '; + } elseif (is_tag()) { + $items[] = '
  • ' . esc_html(single_tag_title('', false)) . '
  • '; + } elseif (is_author()) { + $items[] = '
  • ' . esc_html(get_the_author()) . '
  • '; + } elseif (is_search()) { + $items[] = '
  • ' . esc_html__('搜索', 'aksrt') . '
  • '; + } elseif (function_exists('aksrt_is_resource_archive') && aksrt_is_resource_archive()) { + $items[] = '
  • ' . esc_html__('资源', 'aksrt') . '
  • '; + } elseif (is_singular('post')) { + $cats = get_the_category(); + if (!empty($cats)) { + $category = $cats[0]; + $items[] = '
  • ' . esc_html($category->name) . '
  • '; + } + $items[] = '
  • ' . esc_html(get_the_title()) . '
  • '; + } elseif (is_page()) { + $items[] = '
  • ' . esc_html(get_the_title()) . '
  • '; + } elseif (is_archive()) { + $items[] = '
  • ' . esc_html(get_the_archive_title()) . '
  • '; + } else { + $items[] = '
  • ' . esc_html(get_the_title()) . '
  • '; + } + + echo ''; + } +endif; + +if (!function_exists('aksrt_get_post_views')) : + /** + * 获取文章浏览量 + * + * @param int|null $post_id 文章 ID,默认当前文章 + * @return int|string + */ + function aksrt_get_post_views($post_id = null) { + $post_id = $post_id ? (int) $post_id : get_the_ID(); + if (! $post_id) return 0; + + $common_count = (int) get_post_meta($post_id, 'aksrt_post_views', true); + $resource_count = (int) get_post_meta($post_id, '_aksrt_res_view_count', true); + + if (function_exists('aksrt_is_resource_post') && aksrt_is_resource_post($post_id)) { + return max(0, max($common_count, $resource_count)); + } + + return max(0, $common_count); + } +endif; + +if (! function_exists('aksrt_format_number')) : + function aksrt_format_number($num) { + if ($num >= 10000) { + return round($num / 10000, 1) . 'W+'; + } elseif ($num >= 1000) { + return round($num / 1000, 1) . 'k'; + } + return (string) number_format_i18n($num); + } +endif; + +if (!function_exists('aksrt_render_mini_post_item')) : + /** + * Render a compact sidebar post item for homepage widgets. + * + * Thin wrapper over the unified shared renderer (mode=mini). + * + * @param int|WP_Post|null $post + * @param array $args + */ + function aksrt_render_mini_post_item($post = null, array $args = []): void { + $post = get_post($post); + if (!($post instanceof WP_Post)) { + return; + } + + // Set up the global post so the shared renderer works + global $wp_query; + $original_post = isset($wp_query->post) ? $wp_query->post : null; + $wp_query->post = $post; + setup_postdata($post); + + if (function_exists('aksrt_render_post')) { + $config = [ + 'mode' => 'mini', + 'context' => 'sidebar', + 'show_thumb' => true, + 'show_meta' => !isset($args['show_views']) || !empty($args['show_views']), + ]; + + if (!empty($args['class']) && is_string($args['class'])) { + $config['extra_class'] = $args['class']; + } + + echo aksrt_render_post($config); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } else { + // Fallback: self-contained mini rendering + $post_id = (int) $post->ID; + $title = get_the_title($post_id); + $permalink = get_permalink($post_id); + $thumb_url = has_post_thumbnail($post_id) + ? (string) get_the_post_thumbnail_url($post_id, 'medium') + : ''; + + if ($thumb_url === '' && function_exists('aksrt_get_first_content_image_url')) { + $thumb_url = (string) aksrt_get_first_content_image_url($post_id); + } + + $placeholder_url = get_template_directory_uri() . '/assets/img/placeholder.svg'; + $view_count = function_exists('aksrt_get_post_views') ? (int) aksrt_get_post_views($post_id) : 0; + $show_views = !isset($args['show_views']) || !empty($args['show_views']); + $date_text = function_exists('aksrt_time_ago') + ? aksrt_time_ago((string) $post->post_date) + : get_the_date('Y-m-d', $post_id); + $classes = ['posts-mini']; + + if (!empty($args['class']) && is_string($args['class'])) { + foreach (preg_split('/\s+/', trim($args['class'])) as $extra_class) { + $extra_class = sanitize_html_class($extra_class); + if ($extra_class !== '') { + $classes[] = $extra_class; + } + } + } + ?> +
    + + <?php echo esc_attr($title); ?> + +
    +

    + +

    +
    + + + + + + + + +
    +
    +
    + post = $original_post; + setup_postdata($original_post); + } else { + wp_reset_postdata(); + } + } +endif; + +if (!function_exists('aksrt_set_post_views')) : + /** + * 记录文章/资源浏览量(每浏览器 24 小时只计 1 次)。 + * 使用原生 SQL UPDATE,避免高并发丢失。 + */ + function aksrt_set_post_views() { + if (!is_singular()) return; + if (is_preview()) return; + + $post_id = (int) get_queried_object_id(); + if ($post_id <= 0) { + $q = get_queried_object(); + if ($q instanceof WP_Post) { + $post_id = (int) $q->ID; + } + } + if ($post_id <= 0) { + $post_id = (int) get_the_ID(); + } + if ($post_id <= 0) return; + + $post_type = get_post_type($post_id); + // 只对 post / 历史 aksrt_resource CPT 计数 + if (!in_array($post_type, ['post', 'aksrt_resource'], true)) return; + + $key = 'aksrt_post_views_' . $post_id; + if (!empty($_COOKIE[$key])) return; + + // 资源(post + _aksrt_is_resource,或旧 CPT)使用独立 meta key,便于排序 + $is_resource = function_exists('aksrt_is_resource_post') && aksrt_is_resource_post($post_id); + + if ($is_resource) { + if (function_exists('aksrt_update_post_counter')) { + aksrt_update_post_counter($post_id, '_aksrt_res_view_count', 1); + aksrt_update_post_counter($post_id, 'aksrt_post_views', 1); + } else { + $resource_count = (int) get_post_meta($post_id, '_aksrt_res_view_count', true); + update_post_meta($post_id, '_aksrt_res_view_count', $resource_count + 1); + + $common_count = (int) get_post_meta($post_id, 'aksrt_post_views', true); + update_post_meta($post_id, 'aksrt_post_views', $common_count + 1); + } + } else { + if (function_exists('aksrt_update_post_counter')) { + aksrt_update_post_counter($post_id, 'aksrt_post_views', 1); + } else { + $count = (int) get_post_meta($post_id, 'aksrt_post_views', true); + update_post_meta($post_id, 'aksrt_post_views', $count + 1); + } + } + + $cookie_path = defined('COOKIEPATH') && COOKIEPATH ? COOKIEPATH : '/'; + $cookie_domain = defined('COOKIE_DOMAIN') ? COOKIE_DOMAIN : ''; + setcookie($key, '1', time() + DAY_IN_SECONDS, $cookie_path, $cookie_domain); + // 同一次请求内立即可见,避免重复执行时再次计数。 + $_COOKIE[$key] = '1'; + } +endif; + +if (!function_exists('aksrt_get_post_likes')) : + function aksrt_get_post_likes($post_id = null) { + $post_id = $post_id ? (int) $post_id : get_the_ID(); + if (! $post_id) return 0; + return max(0, (int) get_post_meta($post_id, '_aksrt_like_count', true)); + } +endif; + +if (!function_exists('aksrt_get_post_favorites')) : + function aksrt_get_post_favorites($post_id = null) { + $post_id = $post_id ? (int) $post_id : get_the_ID(); + if (! $post_id) return 0; + return max(0, (int) get_post_meta($post_id, '_aksrt_favorite_count', true)); + } +endif; + +if (!function_exists('aksrt_get_interaction_cookie_name')) : + function aksrt_get_interaction_cookie_name($type, $post_id) { + return 'aksrt_' . sanitize_key($type) . '_' . (int) $post_id; + } +endif; + +if (!function_exists('aksrt_normalize_post_id_list')) : + function aksrt_normalize_post_id_list($ids) { + if (! is_array($ids)) { + $ids = []; + } + + $ids = array_map('absint', $ids); + $ids = array_filter($ids, static function ($id) { + return $id > 0; + }); + + return array_values(array_unique($ids)); + } +endif; + +if (!function_exists('aksrt_set_interaction_cookie')) : + function aksrt_set_interaction_cookie($name, $active) { + $expire = $active ? time() + YEAR_IN_SECONDS : time() - HOUR_IN_SECONDS; + $path = defined('COOKIEPATH') && COOKIEPATH ? COOKIEPATH : '/'; + $domain = defined('COOKIE_DOMAIN') ? COOKIE_DOMAIN : ''; + + setcookie($name, $active ? '1' : '', $expire, $path, $domain, is_ssl(), true); + + if ($active) { + $_COOKIE[$name] = '1'; + } else { + unset($_COOKIE[$name]); + } + } +endif; + +if (!function_exists('aksrt_get_user_interaction_posts')) : + function aksrt_get_user_interaction_posts($meta_key, $user_id = null) { + $user_id = $user_id ? (int) $user_id : get_current_user_id(); + if ($user_id <= 0) { + return []; + } + + return aksrt_normalize_post_id_list(get_user_meta($user_id, $meta_key, true)); + } +endif; + +if (!function_exists('aksrt_set_user_interaction_state')) : + function aksrt_set_user_interaction_state($meta_key, $post_id, $active, $user_id = null) { + $user_id = $user_id ? (int) $user_id : get_current_user_id(); + if ($user_id <= 0) { + return; + } + + $posts = aksrt_get_user_interaction_posts($meta_key, $user_id); + if ($active) { + $posts[] = (int) $post_id; + } else { + $posts = array_filter($posts, static function ($id) use ($post_id) { + return (int) $id !== (int) $post_id; + }); + } + + update_user_meta($user_id, $meta_key, aksrt_normalize_post_id_list($posts)); + } +endif; + +if (!function_exists('aksrt_is_post_liked')) : + function aksrt_is_post_liked($post_id = null) { + $post_id = $post_id ? (int) $post_id : get_the_ID(); + if ($post_id <= 0) { + return false; + } + + if (is_user_logged_in()) { + return in_array($post_id, aksrt_get_user_interaction_posts('_aksrt_liked_posts'), true); + } + + $cookie = aksrt_get_interaction_cookie_name('like', $post_id); + return ! empty($_COOKIE[$cookie]) && '1' === $_COOKIE[$cookie]; + } +endif; + +if (!function_exists('aksrt_is_post_favorited')) : + function aksrt_is_post_favorited($post_id = null) { + $post_id = $post_id ? (int) $post_id : get_the_ID(); + if ($post_id <= 0) { + return false; + } + + if (is_user_logged_in()) { + return in_array($post_id, aksrt_get_user_interaction_posts('_aksrt_favorite_posts'), true); + } + + $cookie = aksrt_get_interaction_cookie_name('favorite', $post_id); + return ! empty($_COOKIE[$cookie]) && '1' === $_COOKIE[$cookie]; + } +endif; + +if (!function_exists('aksrt_update_post_counter')) : + /** + * 原生修改 post meta 数字计数器,避免高并发 read-modify-write 丢失。 + * + * 实现:单条 SQL UPDATE postmeta SET meta_value = GREATEST(0, CAST + delta), + * 若行不存在则先 add_post_meta 初始化为 max(0, delta) 再读出。 + * + * @param int $post_id + * @param string $meta_key + * @param int $delta 可正可负 + * @return int 操作后的最新计数(>= 0) + */ + function aksrt_update_post_counter($post_id, $meta_key, $delta) { + global $wpdb; + $post_id = (int) $post_id; + $delta = (int) $delta; + + $exists = (int) $wpdb->get_var($wpdb->prepare( + "SELECT 1 FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s LIMIT 1", + $post_id, + $meta_key + )); + + if ($exists) { + $wpdb->query($wpdb->prepare( + "UPDATE {$wpdb->postmeta} + SET meta_value = GREATEST(0, CAST(meta_value AS SIGNED) + %d) + WHERE post_id = %d AND meta_key = %s", + $delta, + $post_id, + $meta_key + )); + } else { + add_post_meta($post_id, $meta_key, max(0, $delta), true); + } + + wp_cache_delete($post_id, 'post_meta'); + return (int) get_post_meta($post_id, $meta_key, true); + } +endif; + +if (!function_exists('aksrt_toggle_like_ajax')) : + function aksrt_toggle_like_ajax() { + check_ajax_referer('aksrt_public_nonce', 'nonce'); + + $post_id = isset($_POST['postId']) ? absint(wp_unslash($_POST['postId'])) : 0; + $post = $post_id ? get_post($post_id) : null; + if (! $post || 'post' !== $post->post_type || 'publish' !== $post->post_status) { + wp_send_json_error(['message' => __('Invalid post.', 'aksrt')], 400); + } + + $current = aksrt_is_post_liked($post_id); + $target = isset($_POST['active']) ? '1' === sanitize_text_field(wp_unslash($_POST['active'])) : ! $current; + + if ($target !== $current) { + if (is_user_logged_in()) { + aksrt_set_user_interaction_state('_aksrt_liked_posts', $post_id, $target); + } else { + aksrt_set_interaction_cookie(aksrt_get_interaction_cookie_name('like', $post_id), $target); + } + + $count = aksrt_update_post_counter($post_id, '_aksrt_like_count', $target ? 1 : -1); + } else { + $count = aksrt_get_post_likes($post_id); + } + + wp_send_json_success([ + 'active' => $target, + 'count' => $count, + ]); + } +endif; + +if (!function_exists('aksrt_toggle_favorite_ajax')) : + function aksrt_toggle_favorite_ajax() { + check_ajax_referer('aksrt_public_nonce', 'nonce'); + + $post_id = isset($_POST['postId']) ? absint(wp_unslash($_POST['postId'])) : 0; + $post = $post_id ? get_post($post_id) : null; + if (! $post || 'post' !== $post->post_type || 'publish' !== $post->post_status) { + wp_send_json_error(['message' => __('Invalid post.', 'aksrt')], 400); + } + + $current = aksrt_is_post_favorited($post_id); + $target = isset($_POST['active']) ? '1' === sanitize_text_field(wp_unslash($_POST['active'])) : ! $current; + + if ($target !== $current) { + if (is_user_logged_in()) { + aksrt_set_user_interaction_state('_aksrt_favorite_posts', $post_id, $target); + } else { + aksrt_set_interaction_cookie(aksrt_get_interaction_cookie_name('favorite', $post_id), $target); + } + + $count = aksrt_update_post_counter($post_id, '_aksrt_favorite_count', $target ? 1 : -1); + } else { + $count = aksrt_get_post_favorites($post_id); + } + + wp_send_json_success([ + 'active' => $target, + 'count' => $count, + ]); + } +endif; + +add_action('wp_ajax_aksrt_toggle_like', 'aksrt_toggle_like_ajax'); +add_action('wp_ajax_nopriv_aksrt_toggle_like', 'aksrt_toggle_like_ajax'); +add_action('wp_ajax_aksrt_toggle_favorite', 'aksrt_toggle_favorite_ajax'); +add_action('wp_ajax_nopriv_aksrt_toggle_favorite', 'aksrt_toggle_favorite_ajax'); + +// Float moderator-pinned top-level comments to the front of the first-page list. +// Runs during comments_template() before comments.php is loaded. +add_filter('comments_array', 'aksrt_reorder_pinned_comments', 20, 2); + +if (!function_exists('aksrt_comment_action_permissions')) : + /** + * Single source of truth for per-comment action permissions. + * + * Both the renderer (button visibility) and the REST endpoints + * (server-side enforcement) must read from this contract so the UI never + * advertises an action the backend would reject. + * + * @param WP_Comment|int $comment Comment object or ID. + * @return array{can_edit:bool,can_delete:bool,can_moderate:bool,can_pin:bool} + */ + function aksrt_comment_action_permissions($comment): array { + $comment = $comment instanceof WP_Comment ? $comment : get_comment($comment); + $none = ['can_edit' => false, 'can_delete' => false, 'can_moderate' => false, 'can_pin' => false]; + + if (!($comment instanceof WP_Comment) || !is_user_logged_in()) { + return $none; + } + + $is_moderator = current_user_can('moderate_comments'); + $is_owner = (int) $comment->user_id > 0 && (int) $comment->user_id === get_current_user_id(); + + // Owner self-edit window (minutes); moderators bypass the window. + $edit_window = 10; + if (class_exists('AKSRT_Settings')) { + $display = AKSRT_Settings::get_comment_display_settings(); + $edit_window = (int) ($display['edit_window'] ?? 10); + } + $within_window = $edit_window > 0 + && (time() - strtotime((string) $comment->comment_date_gmt)) < ($edit_window * MINUTE_IN_SECONDS); + + return [ + 'can_edit' => $is_moderator || ($is_owner && $within_window), + 'can_delete' => $is_moderator || $is_owner, + 'can_moderate' => $is_moderator, + 'can_pin' => $is_moderator && (int) $comment->comment_parent === 0, + ]; + } +endif; + +if (!function_exists('aksrt_is_comment_pinned')) : + /** + * Whether a comment is pinned ("置顶") by a moderator. + * + * @param int|WP_Comment $comment Comment object or ID. + * @return bool + */ + function aksrt_is_comment_pinned($comment): bool { + $comment_id = $comment instanceof WP_Comment ? (int) $comment->comment_ID : (int) $comment; + if ($comment_id <= 0) { + return false; + } + + return '1' === (string) get_comment_meta($comment_id, '_aksrt_comment_pinned', true); + } +endif; + +if (!function_exists('aksrt_update_comment_counter')) : + /** + * Atomically bump a numeric comment-meta counter, floored at zero. + * + * @param int $comment_id Comment ID. + * @param string $meta_key Meta key. + * @param int $delta Signed delta. + * @return int Latest value (>= 0). + */ + function aksrt_update_comment_counter($comment_id, $meta_key, $delta) { + global $wpdb; + $comment_id = (int) $comment_id; + $delta = (int) $delta; + + $exists = (int) $wpdb->get_var($wpdb->prepare( + "SELECT 1 FROM {$wpdb->commentmeta} WHERE comment_id = %d AND meta_key = %s LIMIT 1", + $comment_id, + $meta_key + )); + + if ($exists) { + $wpdb->query($wpdb->prepare( + "UPDATE {$wpdb->commentmeta} + SET meta_value = GREATEST(0, CAST(meta_value AS SIGNED) + %d) + WHERE comment_id = %d AND meta_key = %s", + $delta, + $comment_id, + $meta_key + )); + } else { + add_comment_meta($comment_id, $meta_key, max(0, $delta), true); + } + + wp_cache_delete($comment_id, 'comment_meta'); + return (int) get_comment_meta($comment_id, $meta_key, true); + } +endif; + +if (!function_exists('aksrt_get_comment_likes')) : + /** + * Comment like count (shared by renderer, "hottest" sort and REST). + * + * @param int|WP_Comment $comment Comment object or ID. + * @return int + */ + function aksrt_get_comment_likes($comment): int { + $comment_id = $comment instanceof WP_Comment ? (int) $comment->comment_ID : (int) $comment; + if ($comment_id <= 0) { + return 0; + } + + return max(0, (int) get_comment_meta($comment_id, 'aksrt_comment_likes', true)); + } +endif; + +if (!function_exists('aksrt_is_comment_liked')) : + /** + * Whether the current visitor already liked a comment. + * + * @param int $comment_id Comment ID. + * @return bool + */ + function aksrt_is_comment_liked($comment_id): bool { + $comment_id = (int) $comment_id; + if ($comment_id <= 0) { + return false; + } + + if (is_user_logged_in()) { + $liked = get_user_meta(get_current_user_id(), '_aksrt_liked_comments', true); + $liked = is_array($liked) ? array_map('intval', $liked) : []; + return in_array($comment_id, $liked, true); + } + + $cookie = 'aksrt_clike_' . $comment_id; + return !empty($_COOKIE[$cookie]) && '1' === $_COOKIE[$cookie]; + } +endif; + +if (!function_exists('aksrt_set_comment_like_state')) : + /** + * Persist a visitor's like state for a comment. + * + * @param int $comment_id Comment ID. + * @param bool $active Target state. + */ + function aksrt_set_comment_like_state($comment_id, $active): void { + $comment_id = (int) $comment_id; + if ($comment_id <= 0) { + return; + } + + if (is_user_logged_in()) { + $user_id = get_current_user_id(); + $liked = get_user_meta($user_id, '_aksrt_liked_comments', true); + $liked = is_array($liked) ? array_map('intval', $liked) : []; + if ($active) { + $liked[] = $comment_id; + } else { + $liked = array_filter($liked, static function ($id) use ($comment_id) { + return (int) $id !== $comment_id; + }); + } + update_user_meta($user_id, '_aksrt_liked_comments', array_values(array_unique($liked))); + return; + } + + $cookie = 'aksrt_clike_' . $comment_id; + $path = defined('COOKIEPATH') && COOKIEPATH ? COOKIEPATH : '/'; + $domain = defined('COOKIE_DOMAIN') ? COOKIE_DOMAIN : ''; + setcookie($cookie, $active ? '1' : '', $active ? time() + YEAR_IN_SECONDS : time() - HOUR_IN_SECONDS, $path, $domain, is_ssl(), true); + if ($active) { + $_COOKIE[$cookie] = '1'; + } else { + unset($_COOKIE[$cookie]); + } + } +endif; + +if (!function_exists('aksrt_reorder_pinned_comments')) : + /** + * Float pinned top-level comments to the front of the queried list. + * + * Attached to `comments_array` on singular views so the first-page render + * and the canonical renderer agree on pin ordering. Nested replies keep + * their parent ordering; only top-level pins are reordered. + * + * @param array $comments Comment objects. + * @param int $post_id Post ID. + * @return array + */ + function aksrt_reorder_pinned_comments($comments, $post_id) { + if (!is_array($comments) || count($comments) < 2) { + return $comments; + } + + $pinned = []; + $normal = []; + foreach ($comments as $comment) { + if ($comment instanceof WP_Comment && (int) $comment->comment_parent === 0 && aksrt_is_comment_pinned($comment)) { + $pinned[] = $comment; + } else { + $normal[] = $comment; + } + } + + if (empty($pinned)) { + return $comments; + } + + return array_merge($pinned, $normal); + } +endif; + +if (!function_exists('aksrt_comment_callback')) : + /** + * Canonical comment list item renderer. + * + * This is the single contract used by first-page rendering (comments.php), + * AJAX pagination (AKSRT_Admin::ajax_load_more_comments) and the post-submit + * list reload (main.js reloadCommentList(), which refetches the rendered + * page). Emits a stable `data-comment-id` plus reply, like, management + * (edit/delete/moderate) and pin controls gated through + * aksrt_comment_action_permissions(). + * + * @param WP_Comment $comment 评论对象 + * @param array $args 参数 + * @param int $depth 嵌套深度 + */ + function aksrt_comment_callback($comment, $args, $depth) { + $GLOBALS['comment'] = $comment; + $settings = get_query_var('aksrt_comment_settings', []); + $settings = is_array($settings) ? $settings : []; + $comment_id = (int) $comment->comment_ID; + $depth_class = 'aksrt-depth-' . min((int) ($settings['nested_depth'] ?? 3), (int) $depth); + $author_name = get_comment_author($comment); + $comment_date = get_comment_date('c', $comment); + $comment_label = get_comment_date('Y-m-d', $comment); + $avatar_size = max(24, (int) ($settings['avatar_size'] ?? 48)); + $allow_reply = !empty($settings['allow_reply']); + $show_author_tag = !isset($settings['author_tag']) || !empty($settings['author_tag']); + $like_enabled = !isset($settings['like_enabled']) || !empty($settings['like_enabled']); + $is_post_author = (int) $comment->user_id > 0 && (int) $comment->user_id === (int) get_post_field('post_author', $comment->comment_post_ID); + $is_pinned = aksrt_is_comment_pinned($comment); + $perms = aksrt_comment_action_permissions($comment); + $is_pending = '0' === $comment->comment_approved; + $is_spam = 'spam' === $comment->comment_approved; + $like_count = aksrt_get_comment_likes($comment); + $is_liked = aksrt_is_comment_liked($comment_id); + + $li_classes = trim($depth_class . ($is_pinned ? ' aksrt-comment--pinned' : '')); + ?> +
  • id="comment-" data-comment-id="" data-comment-status="comment_approved); ?>"> +
      +
    • +
      +
      + 'avatar-img comt-avatar']); ?> + + + + + + + +
      + +
      +
      + +

      + + + +
      + +
    • +
    +
  • + [], + 'posts_per_page' => 6, + ]; + + if (!class_exists('AKSRT_Settings')) { + return $config; + } + + foreach (AKSRT_Settings::get_homepage_modules() as $module) { + if (!is_array($module) || ($module['type'] ?? '') !== 'tabs') { + continue; + } + + $module_config = isset($module['config']) && is_array($module['config']) + ? $module['config'] + : []; + + $term_ids = isset($module_config['cat_ids']) && is_array($module_config['cat_ids']) + ? array_values(array_filter(array_map('intval', $module_config['cat_ids']))) + : []; + + if ($term_ids) { + $config['term_ids'] = $term_ids; + } + + if (!empty($module_config['count'])) { + $config['posts_per_page'] = max(4, min(12, (int) $module_config['count'])); + } + + break; + } + + return $config; + } +} + +if (!function_exists('aksrt_homepage_get_tab_terms')) { + /** + * Homepage category tabs with full config. + * + * Returns enabled tabs in config order, each with 'term', 'title', 'orderby'. + * Uses homepage_tab_limit from settings. + * + * @return array + */ + function aksrt_homepage_get_tab_configs(): array { + $limit = class_exists('AKSRT_Settings') + ? AKSRT_Settings::homepage_tab_limit() + : 4; + + if (!class_exists('AKSRT_Settings')) { + return []; + } + + $structured_tabs = AKSRT_Settings::homepage_tabs(); + if (empty($structured_tabs)) { + return []; + } + + $excluded_cat_ids = array_flip(AKSRT_Settings::home_exclude_category_ids()); + $configs = []; + + foreach ($structured_tabs as $tab) { + if (empty($tab['enabled']) || $tab['term_id'] <= 0) { + continue; + } + if (count($configs) >= $limit) { + break; + } + // Skip excluded categories + if (isset($excluded_cat_ids[$tab['term_id']])) { + continue; + } + + $term = get_term($tab['term_id'], 'category'); + if (!$term || is_wp_error($term)) { + continue; + } + + $configs[] = [ + 'term' => $term, + 'term_id' => (int) $tab['term_id'], + 'title' => $tab['title'] !== '' ? $tab['title'] : $term->name, + 'orderby' => $tab['orderby'] ?: 'date', + ]; + } + + return $configs; + } + + /** + * Homepage category tabs — returns WP_Term objects for backward compat. + * + * @param int $limit Unused, reads from settings. + * @return WP_Term[] + */ + function aksrt_homepage_get_tab_terms(int $limit = 4): array { + $configs = aksrt_homepage_get_tab_configs(); + return array_column($configs, 'term'); + } +} + +if (!function_exists('aksrt_homepage_get_tab_posts_per_page')) { + /** + * Per-tab homepage post count. + * + * Reads from AKSRT_Settings::homepage_tab_posts_per_page() first; + * falls back to the legacy module config bridge for old installs + * that haven't saved the new setting yet. + */ + function aksrt_homepage_get_tab_posts_per_page(): int { + if (class_exists('AKSRT_Settings')) { + $count = AKSRT_Settings::homepage_tab_posts_per_page(); + if ($count > 0) { + return max(1, min(20, $count)); + } + } + + // Legacy fallback for sites that haven't migrated yet + $legacy = aksrt_homepage_get_legacy_tabs_config(); + $count = (int) apply_filters('aksrt_homepage_tab_posts_per_page', $legacy['posts_per_page']); + + return max(4, min(12, $count)); + } +} + +if (!function_exists('aksrt_homepage_get_default_view_mode')) { + /** + * Default homepage view mode. + */ + function aksrt_homepage_get_default_view_mode(): string { + if (class_exists('AKSRT_Settings')) { + return AKSRT_Settings::homepage_default_view(); + } + + return 'list'; + } +} + +if (!function_exists('aksrt_homepage_sticky_tabs_enabled')) { + /** + * Whether homepage tabs should stick below the primary nav. + */ + function aksrt_homepage_sticky_tabs_enabled(): bool { + if (class_exists('AKSRT_Settings')) { + return AKSRT_Settings::homepage_sticky_tabs_enabled(); + } + + return true; + } +} + +if (!function_exists('aksrt_homepage_module_hero_enabled')) { + /** + * Whether the fixed homepage should render the built-in hero section. + */ + function aksrt_homepage_module_hero_enabled(): bool { + if (class_exists('AKSRT_Settings')) { + return AKSRT_Settings::homepage_module_hero_enabled(); + } + + return true; + } +} + +if (!function_exists('aksrt_homepage_module_banner_enabled')) { + /** + * Whether the fixed homepage should render the built-in banner section. + */ + function aksrt_homepage_module_banner_enabled(): bool { + if (class_exists('AKSRT_Settings')) { + return AKSRT_Settings::homepage_module_banner_enabled(); + } + + return true; + } +} + +if (!function_exists('aksrt_homepage_get_sort_mode')) { + /** + * Current homepage main-stream sort mode. + */ + function aksrt_homepage_get_sort_mode(): string { + $allowed = class_exists('AKSRT_Settings') + ? AKSRT_Settings::homepage_allowed_sorts() + : ['modified', 'date', 'views', 'likes', 'comments', 'favorites', 'downloads', 'rand']; + $default_sort = class_exists('AKSRT_Settings') + ? AKSRT_Settings::homepage_default_sort() + : 'modified'; + $override_sort = get_query_var('aksrt_homepage_sort_override'); + $sort = is_string($override_sort) && $override_sort !== '' + ? sanitize_key($override_sort) + : (isset($_GET['home_sort']) ? sanitize_key(wp_unslash((string) $_GET['home_sort'])) : $default_sort); + + return in_array($sort, $allowed, true) ? $sort : $default_sort; + } +} + +if (!function_exists('aksrt_homepage_get_sort_items')) { + /** + * Configured sort bar items for the main stream. + * + * @return array + */ + function aksrt_homepage_get_sort_items(): array { + $all_labels = class_exists('AKSRT_Settings') + ? AKSRT_Settings::homepage_sort_labels() + : [ + 'modified' => '更新', 'date' => '发布', 'views' => '浏览', + 'likes' => '点赞', 'comments' => '评论', 'favorites' => '收藏', + 'downloads' => '下载', 'rand' => '随机', + ]; + + $enabled_keys = class_exists('AKSRT_Settings') + ? AKSRT_Settings::home_list_sort_options() + : array_keys($all_labels); + + $items = []; + foreach ($enabled_keys as $key) { + if (isset($all_labels[$key])) { + $items[$key] = $all_labels[$key]; + } + } + + return !empty($items) ? $items : ['modified' => '更新', 'date' => '发布']; + } +} + +if (!function_exists('aksrt_homepage_apply_sort_args')) { + /** + * Apply sort args to a query configuration. + * Supports all 8 homepage sort modes. + * + * @param array $args + * @return array + */ + function aksrt_homepage_apply_sort_args(array $args): array { + $sort_mode = aksrt_homepage_get_sort_mode(); + + switch ($sort_mode) { + case 'date': + $args['orderby'] = 'date'; + $args['order'] = 'DESC'; + break; + case 'downloads': + $args['meta_key'] = '_aksrt_res_download_count'; + $args['orderby'] = 'meta_value_num'; + $args['order'] = 'DESC'; + break; + case 'views': + $args['meta_key'] = 'aksrt_post_views'; + $args['orderby'] = 'meta_value_num'; + $args['order'] = 'DESC'; + break; + case 'likes': + $args['meta_key'] = '_aksrt_like_count'; + $args['orderby'] = 'meta_value_num'; + $args['order'] = 'DESC'; + break; + case 'favorites': + $args['meta_key'] = '_aksrt_favorite_count'; + $args['orderby'] = 'meta_value_num'; + $args['order'] = 'DESC'; + break; + case 'comments': + $args['orderby'] = 'comment_count'; + $args['order'] = 'DESC'; + break; + case 'rand': + $args['orderby'] = 'rand'; + unset($args['order']); + break; + case 'modified': + default: + $args['orderby'] = 'modified'; + $args['order'] = 'DESC'; + break; + } + + return $args; + } +} + +if (!function_exists('aksrt_homepage_apply_named_sort_args')) { + /** + * Apply a specific named sort to query args (used by per-tab orderby). + * + * @param array $args + * @param string $sort_mode + * @return array + */ + function aksrt_homepage_apply_named_sort_args(array $args, string $sort_mode): array { + $allowed = class_exists('AKSRT_Settings') + ? AKSRT_Settings::homepage_allowed_sorts() + : ['modified', 'date', 'views', 'likes', 'comments', 'favorites', 'downloads', 'rand']; + + if (!in_array($sort_mode, $allowed, true)) { + $sort_mode = 'date'; + } + + // Temporarily override the global sort mode + $previous = get_query_var('aksrt_homepage_sort_override'); + set_query_var('aksrt_homepage_sort_override', $sort_mode); + $result = aksrt_homepage_apply_sort_args($args); + set_query_var('aksrt_homepage_sort_override', $previous); + + return $result; + } +} + +if (!function_exists('aksrt_homepage_render_sortbar')) { + /** + * Render the main-stream sort bar. + * Only renders when home_list_sort_enabled is true. + * Supports inline links or dropdown mode. + */ + function aksrt_homepage_render_sortbar(): void { + if (class_exists('AKSRT_Settings') && !AKSRT_Settings::home_list_sort_enabled()) { + return; + } + + $current = aksrt_homepage_get_sort_mode(); + $sort_ajax_enabled = class_exists('AKSRT_Settings') + ? AKSRT_Settings::homepage_sort_ajax_enabled() + : true; + $is_dropdown = class_exists('AKSRT_Settings') + ? AKSRT_Settings::home_list_sort_dropdown() + : false; + $base_url = remove_query_arg('home_sort', get_pagenum_link(aksrt_homepage_get_current_page())); + $sort_items = aksrt_homepage_get_sort_items(); + $all_labels = class_exists('AKSRT_Settings') + ? AKSRT_Settings::homepage_sort_labels() + : $sort_items; + ?> +
    + + +
    + + +
    + + + +
    + + +
    +
    + aksrt_homepage_get_post_types(), + 'post_status' => 'publish', + 'posts_per_page' => (int) get_option('posts_per_page'), + 'paged' => aksrt_homepage_get_current_page(), + 'ignore_sticky_posts' => false, + 'orderby' => 'date', + 'order' => 'DESC', + ]; + + // Exclude specific posts (both main stream and tabs) + $exclude_ids = class_exists('AKSRT_Settings') + ? AKSRT_Settings::home_exclude_post_ids() + : []; + if (!empty($exclude_ids)) { + $query_args['post__not_in'] = $exclude_ids; + } + + // Exclude specific categories (main stream only) + $exclude_cat_ids = class_exists('AKSRT_Settings') + ? AKSRT_Settings::home_exclude_category_ids() + : []; + if (!empty($exclude_cat_ids)) { + $query_args['category__not_in'] = $exclude_cat_ids; + } + + $query = new WP_Query(aksrt_homepage_apply_sort_args($query_args)); + + return $query; + } +} + +if (!function_exists('aksrt_homepage_get_tab_query')) { + /** + * Build a category-tab query. + * Uses the tab's own orderby instead of the main stream sort. + * Applies post exclusion but NOT category exclusion (tabs bind to specific categories). + * + * @param int $term_id Category term ID. + * @param string $orderby Per-tab orderby override. + */ + function aksrt_homepage_get_tab_query(int $term_id, string $orderby = 'date'): WP_Query { + static $queries = []; + + $cache_key = $term_id . '_' . $orderby; + if (isset($queries[$cache_key]) && $queries[$cache_key] instanceof WP_Query) { + return $queries[$cache_key]; + } + + $query_args = [ + 'post_type' => aksrt_homepage_get_post_types(), + 'post_status' => 'publish', + 'posts_per_page' => class_exists('AKSRT_Settings') + ? AKSRT_Settings::homepage_tab_posts_per_page() + : 6, + 'ignore_sticky_posts' => true, + 'orderby' => 'date', + 'order' => 'DESC', + 'cat' => $term_id, + 'no_found_rows' => true, + ]; + + // Exclude specific posts (shared with main stream) + $exclude_ids = class_exists('AKSRT_Settings') + ? AKSRT_Settings::home_exclude_post_ids() + : []; + if (!empty($exclude_ids)) { + $query_args['post__not_in'] = $exclude_ids; + } + + // Apply per-tab orderby (NOT the main stream sort) + $queries[$cache_key] = new WP_Query( + aksrt_homepage_apply_named_sort_args($query_args, $orderby) + ); + + return $queries[$cache_key]; + } +} + +if (!function_exists('aksrt_homepage_has_active_sidebars')) { + /** + * Check whether any sidebar in the given stack is active. + * + * @param string[] $sidebar_ids + */ + function aksrt_homepage_has_active_sidebars(array $sidebar_ids): bool { + foreach ($sidebar_ids as $sidebar_id) { + if (is_active_sidebar($sidebar_id)) { + return true; + } + } + + return false; + } +} + +if (!function_exists('aksrt_homepage_render_widget_zone')) { + /** + * Render a stacked widget zone. + * + * @param string[] $sidebar_ids + * @param string $wrapper_class + */ + function aksrt_homepage_render_widget_zone(array $sidebar_ids, string $wrapper_class = ''): void { + if (!aksrt_homepage_has_active_sidebars($sidebar_ids)) { + wp_reset_postdata(); + return; + } + + echo '
    '; + foreach ($sidebar_ids as $sidebar_id) { + if (is_active_sidebar($sidebar_id)) { + dynamic_sidebar($sidebar_id); + } + } + echo '
    '; + } +} + +if (!function_exists('aksrt_homepage_has_sidebar')) { + /** + * Whether the fixed homepage should render an aside column. + */ + function aksrt_homepage_has_sidebar(): bool { + return aksrt_homepage_has_active_sidebars([ + 'all_sidebar_top', + 'home_sidebar', + 'all_sidebar_bottom', + 'sidebar-blog', + ]); + } +} + +if (!function_exists('aksrt_homepage_render_sidebar')) { + /** + * Render the homepage aside column. + */ + function aksrt_homepage_render_sidebar(): void { + if (!aksrt_homepage_has_sidebar()) { + return; + } + + echo ''; + } +} + +if (!function_exists('aksrt_homepage_render_pagination')) { + /** + * Render pagination for the fixed homepage latest stream. + */ + function aksrt_homepage_render_pagination(WP_Query $query): void { + $max_pages = (int) $query->max_num_pages; + if ($max_pages < 2) { + return; + } + + $query_page = (int) $query->get('paged'); + $current_page = min(max(1, $query_page > 0 ? $query_page : aksrt_homepage_get_current_page()), $max_pages); + $pagination_mode = class_exists('AKSRT_Settings') + ? AKSRT_Settings::get_post_pagination_mode() + : 'numbered'; + + if ($pagination_mode === 'numbered') { + $base_url = (string) get_query_var('aksrt_homepage_pagination_base_url'); + $base_args = [ + 'format' => '', + 'current' => $current_page, + 'total' => $max_pages, + 'mid_size' => 2, + 'prev_text' => '', + 'next_text' => '', + 'type' => 'array', + ]; + + if ($base_url !== '') { + $base_args['base'] = add_query_arg( + 'paged', + '%#%', + remove_query_arg(['paged', 'page'], $base_url) + ); + } else { + $base_args['base'] = str_replace(999999999, '%#%', esc_url(get_pagenum_link(999999999))); + } + + $links = paginate_links($base_args); + + if (!$links || !is_array($links)) { + return; + } + ?> + + +
    + + + + +
    + ]+src=["\x27"]([^"\x27"]+)["\x27"]/i', $content, $html_matches) && !empty($html_matches[1])) { + foreach ($html_matches[1] as $raw_url) { + $images[] = esc_url_raw((string) $raw_url); + } + } + + if (preg_match_all('/"url"\s*:\s*"([^"]+)"/i', $content, $block_matches) && !empty($block_matches[1])) { + foreach ($block_matches[1] as $raw_url) { + $images[] = esc_url_raw((string) $raw_url); + } + } + } + + $images = array_values(array_unique(array_filter($images, static function ($url) { + return is_string($url) && $url !== ''; + }))); + + set_transient($transient_key, $images, DAY_IN_SECONDS); + $request_cache[$post_id] = $images; + + return array_slice($images, 0, $limit); + } +} + +if (!function_exists('aksrt_homepage_render_post_card')) { + /** + * Render a homepage post card inline. + * + * Thin wrapper over the unified shared renderer (aksrt_render_post). + */ + function aksrt_homepage_render_post_card(): void { + if (!function_exists('aksrt_render_post')) { + return; + } + + $config = [ + 'mode' => 'list', + 'context' => 'home', + ]; + + echo aksrt_render_post($config); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } +} + +if (!function_exists('aksrt_homepage_render_posts_grid')) { + /** + * Render a card-grid post block for the fixed homepage. + * + * Delegates to the unified shared renderer via aksrt_render_post_list. + */ + function aksrt_homepage_render_posts_grid(?WP_Query $query = null, bool $with_pagination = false): void { + $query = $query instanceof WP_Query ? $query : aksrt_homepage_get_latest_query(); + + if (function_exists('aksrt_render_post_list')) { + aksrt_render_post_list($query, ['mode' => 'list', 'context' => 'home'], true); + } else { + // Fallback: simple loop if shared renderer is unavailable + if (!$query->have_posts()) { + echo '

    ' . esc_html__('暂无文章', 'aksrt') . '

    '; + } else { + echo '
    '; + while ($query->have_posts()) { + $query->the_post(); + aksrt_homepage_render_post_card(); + } + echo '
    '; + } + wp_reset_postdata(); + } + + if ($with_pagination) { + aksrt_homepage_render_pagination($query); + } + } +} + +if (!function_exists('aksrt_homepage_render_system_module')) { + /** + * Render a legacy homepage module inside the fixed homepage layout. + */ + function aksrt_homepage_render_system_module(string $type, string $wrapper_class = 'home-modules', array $config = []): void { + $type = sanitize_key($type); + if ($type === '') { + return; + } + + set_query_var('aksrt_module_config', $config); + ob_start(); + get_template_part('templates/parts/home-modules/module', $type); + set_query_var('aksrt_module_config', null); + $module_html = trim((string) ob_get_clean()); + + if ($module_html === '') { + return; + } + + echo '
    '; + echo $module_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo '
    '; + } +} + +if (!function_exists('aksrt_homepage_get_featured_modules')) { + /** + * Resolve non-core homepage modules that should render alongside the fixed + * hero/banner/main-stream skeleton. + * + * Hero, banner, and tabs are now owned by dedicated settings surfaces, so + * the legacy modules list only contributes featured content modules here. + * + * @return array + */ + function aksrt_homepage_get_featured_modules(): array { + if (!class_exists('AKSRT_Settings')) { + return []; + } + + $allowed_types = [ + 'category', + 'recent', + 'ranking', + 'hotspot', + 'category_recommend_cards', + 'tag_cloud', + 'friend_links', + 'html', + ]; + $modules = []; + + foreach (AKSRT_Settings::get_homepage_modules() as $module) { + if (!is_array($module)) { + continue; + } + + $type = sanitize_key((string) ($module['type'] ?? '')); + if (!in_array($type, $allowed_types, true)) { + continue; + } + + $modules[] = [ + 'type' => $type, + 'config' => isset($module['config']) && is_array($module['config']) ? $module['config'] : [], + ]; + } + + return $modules; + } +} + +if (!function_exists('aksrt_homepage_render_featured_modules')) { + /** + * Render featured homepage modules in configured order. + * + * @param array $modules + * @param string $wrapper_class + */ + function aksrt_homepage_render_featured_modules(array $modules, string $wrapper_class = 'home-modules'): void { + if (empty($modules)) { + return; + } + + echo '
    '; + foreach ($modules as $idx => $module) { + $type = sanitize_key((string) ($module['type'] ?? '')); + if ($type === '') { + continue; + } + + set_query_var('aksrt_module_config', isset($module['config']) && is_array($module['config']) ? $module['config'] : []); + set_query_var('aksrt_module_index', $idx); + get_template_part('templates/parts/home-modules/module', $type); + } + set_query_var('aksrt_module_config', null); + set_query_var('aksrt_module_index', null); + echo '
    '; + } +} + +if (!function_exists('aksrt_homepage_render_layout')) { + /** + * Render the fixed homepage layout directly. + * + * Keeping the markup here avoids silent empty homepages when a template + * part file is missing or not synced on the target server. + */ + function aksrt_homepage_render_layout(): void { + $current_page = aksrt_homepage_get_current_page(); + $show_tabs = $current_page === 1; + $tab_configs = $show_tabs ? aksrt_homepage_get_tab_configs() : []; + $featured_modules = $show_tabs ? aksrt_homepage_get_featured_modules() : []; + $has_sidebar = aksrt_homepage_has_sidebar(); + $hero_on = $show_tabs && aksrt_homepage_module_hero_enabled(); + $banner_on = $show_tabs && aksrt_homepage_module_banner_enabled(); + $main_list_enabled = !class_exists('AKSRT_Settings') || AKSRT_Settings::home_posts_list_enabled(); + $list_title = class_exists('AKSRT_Settings') + ? AKSRT_Settings::home_list_title() + : __('最新发布', 'aksrt'); + ?> +
    + + +
    +
    +
    + + + +
    + +
    +
      + + + + +
    + +
    + +
    +
    + + 1) : ?> + + + + +
    + +
    + + + + +
    +
    + +
    + + + + + + +
    +
    + + + +
    + + +
    +
    + + +
    + + */ +function aksrt_get_available_languages(): array { + $languages = []; + + // Polylang + if (function_exists('pll_the_languages')) { + $pll_langs = pll_the_languages(['raw' => 1]); + if (is_array($pll_langs)) { + foreach ($pll_langs as $lang) { + $languages[] = [ + 'code' => $lang['slug'] ?? '', + 'name' => $lang['name'] ?? '', + 'url' => $lang['url'] ?? '#', + 'flag' => $lang['flag'] ?? '', + 'active' => !empty($lang['current_lang']), + ]; + } + } + return $languages; + } + + // WPML + if (defined('ICL_LANGUAGE_CODE') && function_exists('icl_get_languages')) { + $wpml_langs = icl_get_languages('skip_missing=0'); + if (is_array($wpml_langs)) { + foreach ($wpml_langs as $lang) { + $languages[] = [ + 'code' => $lang['language_code'] ?? '', + 'name' => $lang['native_name'] ?? '', + 'url' => $lang['url'] ?? '#', + 'flag' => $lang['country_flag_url'] ?? '', + 'active' => !empty($lang['active']), + ]; + } + } + return $languages; + } + + // WordPress 内置:通过 filter 提供语言列表 + $filtered = apply_filters('aksrt_available_languages', []); + if (is_array($filtered) && !empty($filtered)) { + return $filtered; + } + + return []; +} + +/** + * 检查是否有多语言插件可用 + */ +function aksrt_has_multilingual(): bool { + if (function_exists('pll_the_languages')) return true; + if (defined('ICL_LANGUAGE_CODE')) return true; + if (!empty(apply_filters('aksrt_available_languages', []))) return true; + return false; +} + +/** + * 获取当前语言代码 + */ +function aksrt_get_current_language(): string { + if (function_exists('pll_current_language')) { + return pll_current_language('slug'); + } + if (defined('ICL_LANGUAGE_CODE')) { + return ICL_LANGUAGE_CODE; + } + return get_locale(); +} + +/** + * 渲染语言切换器 HTML + * + * @param string $style dropdown | inline | flags + * @return string + */ +function aksrt_render_language_switcher(string $style = 'dropdown'): string { + $languages = aksrt_get_available_languages(); + if (count($languages) < 2) return ''; + + $current = aksrt_get_current_language(); + + if ($style === 'flags') { + $html = '
    '; + foreach ($languages as $lang) { + $active_class = $lang['active'] ? ' is-active' : ''; + $html .= ''; + if ($lang['flag']) { + $html .= '' . esc_attr($lang['code']) . ''; + } else { + $html .= '' . esc_html(strtoupper($lang['code'])) . ''; + } + $html .= ''; + } + $html .= '
    '; + return $html; + } + + // dropdown style + $html = '
    '; + $html .= ''; + + $html .= ''; + + $html .= '
    '; + + return $html; +} diff --git a/inc/helpers/aksrt-icons.php b/inc/helpers/aksrt-icons.php new file mode 100644 index 00000000..4f46e5e1 --- /dev/null +++ b/inc/helpers/aksrt-icons.php @@ -0,0 +1,121 @@ +\s*/i', '', $svg); + $svg = preg_replace('/^]*>\s*/i', '', $svg); + // 移除 上的固定宽高,让 CSS 控制尺寸 + $svg = preg_replace('/\swidth="[^"]*"/', '', $svg); + $svg = preg_replace('/\sheight="[^"]*"/', '', $svg); + // 移除 iconfont 冗余属性 + $svg = preg_replace('/\sclass="[^"]*"/', '', $svg); + $svg = preg_replace('/\st="[^"]*"/', '', $svg); + $svg = preg_replace('/\sp-id="[^"]*"/', '', $svg); + $svg = preg_replace('/\sxmlns:xlink="[^"]*"/', '', $svg); + // 移除路径上的 opacity 属性 + $svg = preg_replace('/(<(?:path|circle|rect|polygon|line|polyline|ellipse)[^>]*)\sopacity="[^"]*"/', '$1', $svg); + $cache[$name] = $svg; + return $svg; + } + return ''; +} +endif; + +// ============================================================ +// 图标 key → 显示名称 +// ============================================================ +if (!function_exists('aksrt_icon_key_to_name')) : +function aksrt_icon_key_to_name($key) { + $map = array( + 'github' => 'GitHub', 'google' => 'Google', 'x' => 'X', + 'email' => '邮箱', 'wechat' => '微信', 'qq' => 'QQ', + 'weibo' => '微博', 'telegram' => 'Telegram', + 'discord' => 'Discord', 'bilibili' => 'Bilibili', + 'linkedin' => 'LinkedIn', 'facebook' => 'Facebook', + 'youtube' => 'YouTube', 'instagram' => 'Instagram', + 'web' => '网站', + ); + return isset($map[$key]) ? $map[$key] : ucwords(str_replace('-', ' ', $key)); +} +endif; + +// ============================================================ +// 联系方式图标选项(仅保留选定的 15 个) +// ============================================================ +if (!function_exists('aksrt_contact_icon_options')) : +function aksrt_contact_icon_options($selected = '') { + $icons = array( + 'github' => 'GitHub', + 'google' => 'Google', + 'x' => 'X', + 'email' => '邮箱', + 'wechat' => '微信', + 'qq' => 'QQ', + 'weibo' => '微博', + 'telegram' => 'Telegram', + 'discord' => 'Discord', + 'bilibili' => 'Bilibili', + 'linkedin' => 'LinkedIn', + 'facebook' => 'Facebook', + 'youtube' => 'YouTube', + 'instagram' => 'Instagram', + 'web' => '网站', + ); + $html = ''; + foreach ($icons as $key => $label) { + $sel = $key === $selected ? ' selected' : ''; + $html .= ''; + } + return $html; +} +endif; diff --git a/inc/helpers/aksrt-pay.php b/inc/helpers/aksrt-pay.php new file mode 100644 index 00000000..710a447e --- /dev/null +++ b/inc/helpers/aksrt-pay.php @@ -0,0 +1,629 @@ + __('不付费', 'aksrt'), + AKSRT_PAY_TYPE_READING => __('付费阅读', 'aksrt'), + AKSRT_PAY_TYPE_DOWNLOAD => __('付费下载', 'aksrt'), + AKSRT_PAY_TYPE_GALLERY => __('付费图库', 'aksrt'), + AKSRT_PAY_TYPE_VIDEO => __('付费视频', 'aksrt'), + AKSRT_PAY_TYPE_VIP => __('VIP 专属', 'aksrt'), + ]; +} + +// ============================================================ +// 元数据读取 +// ============================================================ + +if (!function_exists('aksrt_get_pay_meta')) { + function aksrt_get_pay_meta($post = null): array { + $p = get_post($post); + if (!($p instanceof WP_Post)) return aksrt_default_pay_meta(); + + $meta = get_post_meta($p->ID, AKSRT_Domain_Model::PAY_META_KEY, true); + if (is_array($meta) && isset($meta['pay_type'])) { + return wp_parse_args(AKSRT_Domain_Model::sanitize_pay_meta($meta), aksrt_default_pay_meta()); + } + + return aksrt_default_pay_meta(); + } +} + +if (!function_exists('aksrt_default_pay_meta')) { + function aksrt_default_pay_meta(): array { + return AKSRT_Domain_Model::default_pay_meta(); + + return [ + 'pay_type' => AKSRT_PAY_TYPE_NO, + 'price' => 0.0, + 'original_price' => 0.0, + 'vip1_price' => 0.0, + 'vip2_price' => 0.0, + 'version' => '', + 'size' => '', + 'license' => '', + 'compat' => '', + 'demo_url' => '', + 'downloads' => [], + 'require_login' => false, + 'require_captcha' => false, + 'attributes' => [], + 'gallery' => [], + 'video_url' => '', + ]; + } +} + +// ============================================================ +// 状态判断 +// ============================================================ + +if (!function_exists('aksrt_is_paid_post')) { + function aksrt_is_paid_post($post = null): bool { + return aksrt_get_pay_meta($post)['pay_type'] !== AKSRT_PAY_TYPE_NO; + } +} + +if (!function_exists('aksrt_is_paid_reading')) { + function aksrt_is_paid_reading($post = null): bool { + return aksrt_get_pay_meta($post)['pay_type'] === AKSRT_PAY_TYPE_READING; + } +} + +if (!function_exists('aksrt_is_paid_download')) { + function aksrt_is_paid_download($post = null): bool { + return aksrt_get_pay_meta($post)['pay_type'] === AKSRT_PAY_TYPE_DOWNLOAD; + } +} + +if (!function_exists('aksrt_is_paid_gallery')) { + function aksrt_is_paid_gallery($post = null): bool { + return aksrt_get_pay_meta($post)['pay_type'] === AKSRT_PAY_TYPE_GALLERY; + } +} + +if (!function_exists('aksrt_is_paid_video')) { + function aksrt_is_paid_video($post = null): bool { + return aksrt_get_pay_meta($post)['pay_type'] === AKSRT_PAY_TYPE_VIDEO; + } +} + +if (!function_exists('aksrt_is_vip_only')) { + function aksrt_is_vip_only($post = null): bool { + return aksrt_get_pay_meta($post)['pay_type'] === AKSRT_PAY_TYPE_VIP; + } +} + +// ============================================================ +// 付费状态检查 +// ============================================================ + +if (!function_exists('aksrt_has_paid_access')) { + function aksrt_has_paid_access($post = null, int $user_id = 0): bool { + $p = get_post($post); + if (!($p instanceof WP_Post)) return true; + + // 单一真相:付费访问由 AKSRT_Content_Visibility(基于订单 + VIP)裁决。 + if (class_exists('AKSRT_Content_Visibility')) { + $state = AKSRT_Content_Visibility::get_post_access_state($p, $user_id); + return !empty($state['has_paid_access']); + } + + // 兜底:仅在 service 不可用时使用,避免离线环境直接拒绝。 + $meta = aksrt_get_pay_meta($p); + if ($meta['pay_type'] === AKSRT_PAY_TYPE_NO) return true; + if ((float) $meta['price'] <= 0 && $meta['pay_type'] !== AKSRT_PAY_TYPE_VIP) return true; + + $uid = $user_id > 0 ? $user_id : get_current_user_id(); + + if ($uid > 0 && function_exists('aksrt_is_vip_exempt') && aksrt_is_vip_exempt($uid)) return true; + if ($uid > 0 && get_user_meta($uid, '_aksrt_paid_' . $p->ID, true)) return true; + + return false; + } +} + +if (!function_exists('aksrt_can_access_content')) { + function aksrt_can_access_content($post = null): bool { + $p = get_post($post); + if (!($p instanceof WP_Post)) return true; + + if (class_exists('AKSRT_Content_Visibility')) { + return AKSRT_Content_Visibility::can_view_paid_content($p); + } + + $pay_type = aksrt_get_pay_meta($p)['pay_type']; + if ($pay_type === AKSRT_PAY_TYPE_NO) return true; + + // 只有 reading/gallery/video 需要隐藏内容 + if (!in_array($pay_type, [AKSRT_PAY_TYPE_READING, AKSRT_PAY_TYPE_GALLERY, AKSRT_PAY_TYPE_VIDEO], true)) { + return true; + } + + return aksrt_has_paid_access($p); + } +} + +if (!function_exists('aksrt_can_download')) { + /** + * 下载授权门禁 —— 单一合同。 + * + * 实际判定全部交给 AKSRT_Content_Visibility::can_download_resource() + * (由 inc/helpers/aksrt-resource.php 的同名 helper 在更早加载时定义)。 + * 此处仅作为该 helper 不可用时的兜底,避免出现第二套下载真相。 + * + * @param int|WP_Post|null $post + * @param int $user_id 0 表示当前用户 + * @return true|WP_Error + */ + function aksrt_can_download($post = null, int $user_id = 0) { + if (class_exists('AKSRT_Content_Visibility')) { + return AKSRT_Content_Visibility::can_download_resource($post, $user_id); + } + + $p = get_post($post); + if (!($p instanceof WP_Post)) { + return new WP_Error('aksrt_invalid', __('无效内容。', 'aksrt')); + } + + $meta = aksrt_get_pay_meta($p); + if ($meta['pay_type'] !== AKSRT_PAY_TYPE_DOWNLOAD) { + return new WP_Error('aksrt_not_downloadable', __('该内容不是可下载资源。', 'aksrt')); + } + + $paid_settings = class_exists('AKSRT_Settings') + ? AKSRT_Settings::get_paid_content_settings() + : ['download_login' => true, 'download_credit' => true]; + + $uid = $user_id > 0 ? $user_id : get_current_user_id(); + + if (!empty($paid_settings['download_login']) && $uid <= 0) { + $login_url = wp_login_url(get_permalink($p)); + return new WP_Error('aksrt_need_login', __('请先登录。', 'aksrt'), ['login_url' => $login_url]); + } + + if ($uid > 0 && aksrt_has_paid_access($p, $uid)) return true; + if ($uid > 0 && function_exists('aksrt_is_vip_exempt') && aksrt_is_vip_exempt($uid)) return true; + + $cost = aksrt_get_download_cost($p); + if ($cost > 0 && !empty($paid_settings['download_credit'])) { + $balance = function_exists('aksrt_user_credits') ? aksrt_user_credits($uid) : 0; + if ($balance < $cost) { + return new WP_Error('aksrt_credits_insufficient', __('积分不足。', 'aksrt'), [ + 'balance' => $balance, + 'cost' => $cost, + ]); + } + } + + return true; + } +} + +if (!function_exists('aksrt_get_download_cost')) { + function aksrt_get_download_cost($post = null): int { + if (function_exists('aksrt_resource_download_cost')) { + return (int) aksrt_resource_download_cost($post); + } + + $meta = aksrt_get_pay_meta($post); + return (int) ceil((float) $meta['price']); + } +} + +// ============================================================ +// 价格显示 +// ============================================================ + +if (!function_exists('aksrt_get_pay_price_label')) { + function aksrt_get_pay_price_label($post = null): array { + $meta = aksrt_get_pay_meta($post); + $price = (float) $meta['price']; + $currency = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_currency_name() : __('积分', 'aksrt'); + + if ($meta['pay_type'] === AKSRT_PAY_TYPE_VIP) { + return ['text' => __('VIP 专属', 'aksrt'), 'free' => false, 'amount' => 0]; + } + + if ($price <= 0) { + return ['text' => __('免费', 'aksrt'), 'free' => true, 'amount' => 0]; + } + + $amount = aksrt_format_price($price); + return ['text' => sprintf(__('%s %s', 'aksrt'), $amount, $currency), 'free' => false, 'amount' => $price]; + } +} + +if (!function_exists('aksrt_format_price')) { + function aksrt_format_price(float $price): string { + if (fmod($price, 1.0) === 0.0) return (string) (int) $price; + return rtrim(rtrim(number_format($price, 2, '.', ''), '0'), '.'); + } +} + +// ============================================================ +// 付费类型徽章 +// ============================================================ + +if (!function_exists('aksrt_get_pay_type_badge')) { + function aksrt_get_pay_type_badge(string $pay_type): string { + $badges = [ + AKSRT_PAY_TYPE_READING => ['label' => __('付费阅读', 'aksrt'), 'color' => '#EF4444', 'icon' => 'fa-lock'], + AKSRT_PAY_TYPE_DOWNLOAD => ['label' => __('付费下载', 'aksrt'), 'color' => '#F97316', 'icon' => 'fa-download'], + AKSRT_PAY_TYPE_GALLERY => ['label' => __('付费图库', 'aksrt'), 'color' => '#10B981', 'icon' => 'fa-images'], + AKSRT_PAY_TYPE_VIDEO => ['label' => __('付费视频', 'aksrt'), 'color' => '#8B5CF6', 'icon' => 'fa-play-circle'], + AKSRT_PAY_TYPE_VIP => ['label' => __('VIP 专属', 'aksrt'), 'color' => '#3B82F6', 'icon' => 'fa-crown'], + ]; + + if (!isset($badges[$pay_type])) return ''; + $b = $badges[$pay_type]; + return sprintf( + ' %s', + esc_attr($b['color']), esc_attr($b['icon']), esc_html($b['label']) + ); + } +} + +// ============================================================ +// 付费墙 HTML +// ============================================================ + +if (!function_exists('aksrt_get_paywall_html')) { + function aksrt_get_paywall_html(WP_Post $post): string { + $meta = aksrt_get_pay_meta($post); + $pay_type = $meta['pay_type']; + $price = (float) $meta['price']; + $currency = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_currency_name() : __('积分', 'aksrt'); + + $type_labels = [ + AKSRT_PAY_TYPE_READING => ['icon' => 'fa-lock', 'label' => __('付费阅读', 'aksrt'), 'desc' => __('付费后即可查看完整内容', 'aksrt')], + AKSRT_PAY_TYPE_GALLERY => ['icon' => 'fa-images', 'label' => __('付费图库', 'aksrt'), 'desc' => __('付费后即可查看全部图片', 'aksrt')], + AKSRT_PAY_TYPE_VIDEO => ['icon' => 'fa-play-circle', 'label' => __('付费视频', 'aksrt'), 'desc' => __('付费后即可观看完整视频', 'aksrt')], + ]; + + $type_info = $type_labels[$pay_type] ?? $type_labels[AKSRT_PAY_TYPE_READING]; + $login_url = function_exists('aksrt_get_login_url') ? aksrt_get_login_url() : wp_login_url(get_permalink($post)); + $is_logged_in = is_user_logged_in(); + $original_price = (float) ($meta['original_price'] ?? 0); + $has_original = $original_price > 0 && $original_price > $price; + + ob_start(); + ?> +
    +
    +
    +
    +
    + +
    +

    +

    +
    + 0) : ?> + + + + + + + +
    + + + + + 0) : ?> + + +

    + +

    +
    +
    +
    + + + + + + + + + + + +
    + + +
    + + + +
    + +
    + +
    + + +
    +
    + +

    + + +
    +
    + +
    + + + + */ + function aksrt_renderer_defaults(): array { + return [ + 'context' => 'archive', + 'mode' => 'list', + 'columns' => 1, + 'style' => 'default', + 'card_style' => 'standard', + 'heading_bold' => true, + 'show_excerpt' => true, + 'excerpt_words' => 36, + 'show_meta' => true, + 'show_author' => true, + 'show_date' => true, + 'show_views' => true, + 'show_comments' => true, + 'show_likes' => false, + 'show_badges' => true, + 'show_tags' => true, + 'tag_limit' => 3, + 'category_limit' => 1, + 'show_thumb' => true, + 'thumb_ratio' => '4:3', + 'thumb_fit' => 'cover', + 'thumb_lazy' => true, + 'thumb_if_has' => false, + 'target_blank' => false, + 'title_html' => '', + 'excerpt_html' => '', + 'badge_show' => true, + 'show_resource_badge' => true, + 'multi_thumb_enabled' => true, + 'multi_thumb_limit' => 4, + 'multi_thumb_min' => 2, + 'list_show_type' => 'separate', + 'number_offset' => 0, + 'show_number' => false, + 'extra_class' => '', + 'highlight_query' => '', + ]; + } +} + +if (!function_exists('aksrt_renderer_config')) { + /** + * Merge renderer config with context-aware defaults. + * + * @param array $config User config. + * @return array + */ + function aksrt_renderer_config(array $config = []): array { + $cfg = wp_parse_args($config, aksrt_renderer_defaults()); + + $cfg['mode'] = in_array($cfg['mode'], ['list', 'card', 'mini', 'grid-cms'], true) + ? $cfg['mode'] + : 'list'; + + $cfg['columns'] = max(1, min(6, (int) $cfg['columns'])); + $cfg['excerpt_words'] = max(0, (int) $cfg['excerpt_words']); + $cfg['tag_limit'] = max(0, (int) $cfg['tag_limit']); + $cfg['category_limit'] = max(0, (int) $cfg['category_limit']); + $cfg['multi_thumb_limit'] = max(1, min(6, (int) $cfg['multi_thumb_limit'])); + $cfg['multi_thumb_min'] = max(1, min(6, (int) $cfg['multi_thumb_min'])); + + if ($cfg['mode'] === 'mini') { + $cfg['show_excerpt'] = false; + $cfg['show_badges'] = false; + $cfg['show_tags'] = false; + $cfg['show_author'] = false; + $cfg['show_comments'] = false; + } + + if ($cfg['mode'] === 'card' || $cfg['mode'] === 'grid-cms') { + $cfg['style'] = $cfg['style'] === 'default' ? 'style3' : $cfg['style']; + } + + return $cfg; + } +} + +if (!function_exists('aksrt_renderer_sanitize_inline_html')) { + /** + * Sanitize small inline HTML snippets used by render config overrides. + * + * @param string $html Raw HTML. + * @return string + */ + function aksrt_renderer_sanitize_inline_html(string $html): string { + return wp_kses($html, [ + 'span' => ['class' => true, 'aria-hidden' => true], + 'em' => ['class' => true], + 'strong' => ['class' => true], + 'i' => ['class' => true, 'aria-hidden' => true], + 'small'=> ['class' => true], + ]); + } +} + +if (!function_exists('aksrt_renderer_highlight_text')) { + /** + * Highlight plain text query matches using a safe span wrapper. + * + * @param string $text Plain text. + * @param string $query Highlight query. + * @return string + */ + function aksrt_renderer_highlight_text(string $text, string $query): string { + $text = wp_strip_all_tags($text); + $query = trim($query); + + if ($query === '' || $text === '') { + return esc_html($text); + } + + $words = preg_split('/\s+/u', $query) ?: []; + $words = array_values(array_unique(array_filter(array_map('trim', $words), static function ($word) { + return $word !== '' && mb_strlen($word) >= 2; + }))); + + if (empty($words)) { + return esc_html($text); + } + + $pattern = '/' . implode('|', array_map(static function ($word) { + return preg_quote($word, '/'); + }, $words)) . '/iu'; + + $escaped = esc_html($text); + return (string) preg_replace($pattern, '$0', $escaped); + } +} + +if (!function_exists('aksrt_renderer_title_markup')) { + /** + * Resolve title markup, supporting highlighted search terms. + * + * @param array $data Collected post data. + * @param array $cfg Renderer config. + * @return string + */ + function aksrt_renderer_title_markup(array $data, array $cfg): string { + if (!empty($cfg['title_html'])) { + return aksrt_renderer_sanitize_inline_html((string) $cfg['title_html']); + } + + $title = aksrt_renderer_highlight_text((string) $data['title'], (string) $cfg['highlight_query']); + $subtitle = trim((string) ($data['subtitle'] ?? '')); + if ($subtitle !== '') { + $title .= ' ' . esc_html($subtitle) . ''; + } + + return $title; + } +} + +if (!function_exists('aksrt_renderer_excerpt_markup')) { + /** + * Resolve excerpt markup, supporting highlighted search terms. + * + * @param array $data Collected post data. + * @param array $cfg Renderer config. + * @return string + */ + function aksrt_renderer_excerpt_markup(array $data, array $cfg): string { + if (!empty($cfg['excerpt_html'])) { + return aksrt_renderer_sanitize_inline_html((string) $cfg['excerpt_html']); + } + + return aksrt_renderer_highlight_text((string) $data['excerpt'], (string) $cfg['highlight_query']); + } +} + +if (!function_exists('aksrt_renderer_curated_images')) { + /** + * Resolve curated cover images from post meta. + * + * @param int $post_id Post ID. + * @param int $limit Maximum items. + * @return string[] + */ + function aksrt_renderer_curated_images(int $post_id, int $limit = 6): array { + $images = []; + $limit = max(1, min(8, $limit)); + + if (!class_exists('AKSRT_Post_Meta_Box')) { + return []; + } + + $cover_images = (string) AKSRT_Post_Meta_Box::get_meta_value($post_id, 'cover_images', ''); + if ($cover_images === '') { + return []; + } + + $cover_ids = array_filter(array_map('absint', preg_split('/[\s,]+/', $cover_images) ?: [])); + foreach ($cover_ids as $attachment_id) { + $url = (string) wp_get_attachment_image_url($attachment_id, 'medium_large'); + if ($url !== '') { + $images[] = esc_url_raw($url); + } + } + + return array_slice(array_values(array_unique($images)), 0, $limit); + } +} + +if (!function_exists('aksrt_renderer_attachment_images')) { + /** + * Resolve gallery/content images for list cards. + * + * @param int $post_id Post ID. + * @param int $limit Maximum items. + * @return string[] + */ + function aksrt_renderer_attachment_images(int $post_id, int $limit = 6): array { + $images = []; + $limit = max(1, min(8, $limit)); + + if (function_exists('aksrt_get_post_gallery_images')) { + $images = aksrt_get_post_gallery_images($post_id, $limit); + } + + if (function_exists('aksrt_get_first_content_image_url')) { + $first_content_image = (string) aksrt_get_first_content_image_url($post_id); + if ($first_content_image !== '') { + $images[] = esc_url_raw($first_content_image); + } + } + + return array_slice(array_values(array_unique(array_filter($images))), 0, $limit); + } +} + +if (!function_exists('aksrt_renderer_random_fallback_image')) { + /** + * Pick a deterministic fallback image when "random cover" is enabled. + * + * @param int $post_id Post ID. + * @return string + */ + function aksrt_renderer_random_fallback_image(int $post_id): string { + $candidates = [ + get_template_directory_uri() . '/assets/img/person_background.png', + get_template_directory_uri() . '/assets/img/placeholder.svg', + ]; + + if (class_exists('AKSRT_Settings')) { + $thumb = AKSRT_Settings::get_post_thumb_settings(); + $spare = $thumb['spare_images'] ?? []; + if (is_array($spare) && !empty($spare)) { + foreach ($spare as $url) { + if (is_string($url) && $url !== '') { + array_unshift($candidates, $url); + } + } + } + } + + $candidates = array_values(array_unique(array_filter(array_map('esc_url_raw', $candidates)))); + if (empty($candidates)) { + return ''; + } + + $index = $post_id % count($candidates); + return $candidates[$index] ?? $candidates[0]; + } +} + +if (!function_exists('aksrt_renderer_resolve_images')) { + /** + * Resolve primary + gallery images for a post based on card settings. + * + * @param int $post_id Post ID. + * @param array $cfg Renderer config. + * @return array{primary:string,gallery:string[],has_thumb:bool,has_real:bool} + */ + function aksrt_renderer_resolve_images(int $post_id, array $cfg): array { + $thumb_url = get_the_post_thumbnail_url($post_id, 'medium_large'); + $curated_images = !empty($cfg['thumb_if_has']) || !empty($cfg['multi_thumb_enabled']) + ? aksrt_renderer_curated_images($post_id, max(4, (int) $cfg['multi_thumb_limit'])) + : []; + $content_images = !empty($cfg['multi_thumb_enabled']) + ? aksrt_renderer_attachment_images($post_id, max(4, (int) $cfg['multi_thumb_limit'])) + : []; + + $gallery = []; + if (!empty($curated_images)) { + $gallery = array_merge($gallery, $curated_images); + } + if (!empty($content_images)) { + $gallery = array_merge($gallery, $content_images); + } + $gallery = array_values(array_unique(array_filter($gallery))); + + $primary = is_string($thumb_url) && $thumb_url !== '' ? esc_url_raw($thumb_url) : ''; + $has_real = $primary !== ''; + + if ($primary === '' && !empty($curated_images[0])) { + $primary = $curated_images[0]; + $has_real = true; + } + if ($primary === '' && !empty($content_images[0])) { + $primary = $content_images[0]; + $has_real = true; + } + + $thumb_settings = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_post_thumb_settings() : []; + $allow_fallback = !empty($thumb_settings['rand_thumb']) || !empty($cfg['thumb_if_has']); + if ($primary === '' && $allow_fallback) { + $primary = aksrt_renderer_random_fallback_image($post_id); + } + + return [ + 'primary' => $primary, + 'gallery' => $gallery, + 'has_thumb' => $primary !== '', + 'has_real' => $has_real, + ]; + } +} + +if (!function_exists('aksrt_renderer_lazyload_config')) { + /** + * Small lazyload config subset for post cards. + * + * @return array + */ + function aksrt_renderer_lazyload_config(): array { + if (class_exists('AKSRT_Settings')) { + $lazy = AKSRT_Settings::get_lazyload_config(); + return [ + 'enabled' => !empty($lazy['enabled']) && !empty($lazy['posts_thumb_enabled']), + 'placeholder_url' => (string) ($lazy['placeholder_url'] ?? ''), + 'blur' => !empty($lazy['blur']), + ]; + } + + return [ + 'enabled' => false, + 'placeholder_url' => '', + 'blur' => false, + ]; + } +} + +if (!function_exists('aksrt_renderer_thumb_ratio_value')) { + /** + * Convert ratio tokens like 4:3 to CSS aspect ratio values. + * + * @param string $ratio Ratio token. + * @return string + */ + function aksrt_renderer_thumb_ratio_value(string $ratio): string { + $ratio = trim($ratio); + if (preg_match('/^(\d+)\s*:\s*(\d+)$/', $ratio, $matches)) { + $w = max(1, (int) $matches[1]); + $h = max(1, (int) $matches[2]); + return $w . ' / ' . $h; + } + + return '4 / 3'; + } +} + +if (!function_exists('aksrt_renderer_collect_post_data')) { + /** + * Aggregate the runtime data needed by all renderer modes. + * + * @param array $cfg Renderer config. + * @return array + */ + function aksrt_renderer_collect_post_data(array $cfg): array { + $post_id = (int) get_the_ID(); + if ($post_id <= 0) { + return []; + } + + $lazy = aksrt_renderer_lazyload_config(); + $lazy['enabled'] = !empty($lazy['enabled']) && !empty($cfg['thumb_lazy']); + + $images = aksrt_renderer_resolve_images($post_id, $cfg); + $gallery_images = array_slice($images['gallery'], 0, $cfg['multi_thumb_limit']); + $is_mult_thumb = !empty($cfg['multi_thumb_enabled']) + && count($gallery_images) >= (int) $cfg['multi_thumb_min'] + && in_array($cfg['mode'], ['list', 'card', 'grid-cms'], true); + + $author_id = (int) get_post_field('post_author', $post_id); + $categories = get_the_category($post_id) ?: []; + $tags = get_the_tags($post_id) ?: []; + + $custom_excerpt = class_exists('AKSRT_Post_Meta_Box') + ? (string) AKSRT_Post_Meta_Box::get_meta_value($post_id, 'custom_excerpt', '') + : ''; + $custom_subtitle = (string) get_post_meta($post_id, '_subtitle', true); + $excerpt_source = $custom_excerpt !== '' ? $custom_excerpt : (string) get_the_excerpt($post_id); + if ($excerpt_source === '') { + $excerpt_source = (string) get_post_field('post_content', $post_id, 'raw'); + } + $excerpt = wp_trim_words(wp_strip_all_tags($excerpt_source), $cfg['excerpt_words'], '...'); + + $resource_meta = function_exists('aksrt_get_resource_meta') ? aksrt_get_resource_meta($post_id) : []; + $resource_strip = function_exists('aksrt_resource_meta_strip') ? aksrt_resource_meta_strip($resource_meta) : []; + + return [ + 'post_id' => $post_id, + 'permalink' => get_permalink($post_id), + 'title' => get_the_title($post_id), + 'subtitle' => $custom_subtitle, + 'excerpt' => $excerpt, + 'img_url' => $images['primary'], + 'has_img' => !empty($images['has_thumb']), + 'has_real_img' => !empty($images['has_real']), + 'gallery_images' => $gallery_images, + 'is_mult_thumb' => $is_mult_thumb, + 'is_video_cover' => !empty(get_post_meta($post_id, '_aksrt_featured_video', true)) || !empty(get_post_meta($post_id, '_video_url', true)), + 'author_id' => $author_id, + 'avatar_url' => get_avatar_url($author_id, ['size' => 40]), + 'author_name' => get_the_author(), + 'categories' => $categories, + 'tags' => $tags, + 'comment_count' => (int) get_comments_number($post_id), + 'views' => function_exists('aksrt_get_post_views') ? (int) aksrt_get_post_views($post_id) : 0, + 'likes' => function_exists('aksrt_get_post_likes') ? (int) aksrt_get_post_likes($post_id) : 0, + 'time_ago' => human_time_diff((int) get_the_time('U', $post_id), current_time('timestamp')), + 'resource_badge' => function_exists('aksrt_resource_card_badge_text') ? (string) aksrt_resource_card_badge_text($post_id) : '', + 'resource_strip' => $resource_strip, + 'is_resource' => function_exists('aksrt_is_resource_post') ? aksrt_is_resource_post($post_id) : false, + 'is_sticky' => is_sticky($post_id), + 'lazy' => $lazy, + 'thumb_ratio_css' => aksrt_renderer_thumb_ratio_value((string) $cfg['thumb_ratio']), + 'thumb_ratio_key' => (string) $cfg['thumb_ratio'], + ]; + } +} + +if (!function_exists('aksrt_renderer_article_attrs')) { + /** + * Build the article wrapper attributes. + * + * @param string[] $classes CSS classes. + * @param array $data Collected post data. + * @return string + */ + function aksrt_renderer_article_attrs(array $classes, array $data): string { + $classes[] = 'thumb-ratio--' . str_replace(':', '-', (string) $data['thumb_ratio_key']); + + return sprintf( + 'class="%s" style="%s"', + esc_attr(implode(' ', array_unique(array_filter($classes)))), + esc_attr('--aksrt-thumb-ratio:' . $data['thumb_ratio_css']) + ); + } +} + +if (!function_exists('aksrt_renderer_thumb')) { + /** + * Render the primary thumbnail. + * + * @param array $data Collected post data. + * @param array $cfg Renderer config. + * @return string + */ + function aksrt_renderer_thumb(array $data, array $cfg): string { + if (!$cfg['show_thumb'] || empty($data['has_img'])) { + return ''; + } + + if (!empty($data['is_mult_thumb']) && in_array($cfg['mode'], ['card', 'grid-cms'], true)) { + return aksrt_renderer_mult_thumb($data); + } + + $lazy = $data['lazy']; + $img = (string) $data['img_url']; + $title = (string) $data['title']; + $href = (string) $data['permalink']; + $is_sticky = !empty($data['is_sticky']); + $has_real_img = !empty($data['has_real_img']); + + if ($has_real_img && !empty($lazy['enabled'])) { + $img_html = '' + . '' . esc_attr($title) . ''; + } else { + $img_html = '' . esc_attr($title) . ''; + } + + $sticky_html = ($is_sticky && !empty($cfg['badge_show'])) + ? '' . esc_html__('置顶', 'aksrt') . '' + : ''; + + $format_badge = ''; + if (!empty($data['is_mult_thumb']) && !empty($data['gallery_images'])) { + $format_badge = '' . esc_html(count($data['gallery_images'])) . ''; + } elseif (!empty($data['is_video_cover'])) { + $format_badge = ''; + } + + $empty_class = $has_real_img ? '' : ' item-thumbnail--fallback'; + $target_attr = !empty($cfg['target_blank']) ? ' target="_blank"' : ''; + + if ($cfg['mode'] === 'list') { + return '
    ' + . $img_html . '' . $sticky_html . $format_badge . '
    '; + } + + return '
    ' + . $img_html . '' . $sticky_html . $format_badge . '
    '; + } +} + +if (!function_exists('aksrt_renderer_mult_thumb')) { + /** + * Render the multi-image strip used by list cards. + * + * @param array $data Collected post data. + * @return string + */ + function aksrt_renderer_mult_thumb(array $data): string { + if (empty($data['is_mult_thumb']) || empty($data['gallery_images'])) { + return ''; + } + + $lazy = $data['lazy']; + $html = ''; + return $html; + } +} + +if (!function_exists('aksrt_renderer_badges')) { + /** + * Render taxonomy and resource badges. + * + * @param array $data Collected post data. + * @param array $cfg Renderer config. + * @return string + */ + function aksrt_renderer_badges(array $data, array $cfg): string { + $items = []; + + if (!empty($cfg['show_resource_badge']) && $data['resource_badge'] !== '') { + $items[] = '' . esc_html($data['resource_badge']) . ''; + } + + if (!empty($cfg['show_badges']) && !empty($cfg['badge_show']) && $cfg['category_limit'] > 0) { + foreach (array_slice($data['categories'], 0, $cfg['category_limit']) as $category) { + $items[] = ' ' . esc_html($category->name) . ''; + } + } + + if (!empty($cfg['show_tags']) && $cfg['tag_limit'] > 0) { + foreach (array_slice($data['tags'], 0, $cfg['tag_limit']) as $tag) { + $items[] = '#' . esc_html($tag->name) . ''; + } + } + + $topics = taxonomy_exists('topics') ? wp_get_post_terms((int) $data['post_id'], 'topics') : []; + if (!is_wp_error($topics) && !empty($topics)) { + foreach (array_slice($topics, 0, 2) as $topic) { + $items[] = ' ' . esc_html($topic->name) . ''; + } + } + + if (!empty($data['is_resource'])) { + $items[] = 'VIP'; + } + + if (empty($items)) { + return ''; + } + + return '
    ' . implode('', $items) . '
    '; + } +} + +if (!function_exists('aksrt_renderer_resource_strip')) { + /** + * Render compact resource meta chips. + * + * @param array $data Collected post data. + * @return string + */ + function aksrt_renderer_resource_strip(array $data): string { + if (empty($data['resource_strip']) || !is_array($data['resource_strip'])) { + return ''; + } + + $html = '
      '; + foreach ($data['resource_strip'] as $item) { + $html .= '
    • ' . esc_html((string) $item) . '
    • '; + } + $html .= '
    '; + + return $html; + } +} + +if (!function_exists('aksrt_renderer_meta')) { + /** + * Render the meta bar. + * + * @param array $data Collected post data. + * @param array $cfg Renderer config. + * @return string + */ + function aksrt_renderer_meta(array $data, array $cfg): string { + if (!$cfg['show_meta']) { + return ''; + } + + $format_number = function_exists('aksrt_format_number') ? 'aksrt_format_number' : 'strval'; + + if ($cfg['mode'] === 'mini') { + $items = []; + if (!empty($cfg['show_date'])) { + $items[] = '' . esc_html($data['time_ago']) . ''; + } + if (!empty($cfg['show_views'])) { + $items[] = ' ' . esc_html($format_number($data['views'])) . ''; + } + + if (empty($items)) { + return ''; + } + + return '
    ' . implode('', $items) . '
    '; + } + + $left = []; + if (!empty($cfg['show_author'])) { + $left[] = '' . esc_html($data['author_name']) . ''; + } + if (!empty($cfg['show_date'])) { + $left[] = '' . esc_html($data['time_ago']) . ''; + } + + $right = []; + if (!empty($cfg['show_comments'])) { + $right[] = ' ' . esc_html((string) $data['comment_count']) . ''; + } + if (!empty($cfg['show_views'])) { + $right[] = ' ' . esc_html($format_number($data['views'])) . ''; + } + if (!empty($cfg['show_likes'])) { + $right[] = ' ' . esc_html($format_number($data['likes'])) . ''; + } + + if (empty($left) && empty($right)) { + return ''; + } + + $html = '
    '; + if (!empty($left)) { + $html .= '
    ' . implode('', $left) . '
    '; + } + if (!empty($right)) { + $html .= '
    ' . implode('', $right) . '
    '; + } + $html .= '
    '; + + return $html; + } +} + +if (!function_exists('aksrt_renderer_list')) { + /** + * Render a horizontal list card. + * + * @param array $data Collected post data. + * @param array $cfg Renderer config. + * @return string + */ + function aksrt_renderer_list(array $data, array $cfg): string { + $classes = ['posts-item', 'list', 'ajax-item', 'posts-item--' . ($cfg['card_style'] ?: 'standard')]; + + if ($cfg['style'] !== '' && $cfg['style'] !== 'default') { + $classes[] = $cfg['style']; + } + if (!empty($data['is_mult_thumb'])) { + $classes[] = 'mult-thumb'; + } + if ($cfg['context'] === 'home') { + $classes[] = 'posts-item--home-list'; + } + if (empty($data['has_img'])) { + $classes[] = 'no-thumb'; + } + if (($cfg['list_show_type'] ?? 'separate') === 'no_margin') { + $classes[] = 'no-margin'; + } + + $thumb = aksrt_renderer_thumb($data, $cfg); + $mult = aksrt_renderer_mult_thumb($data); + + if ($cfg['card_style'] === 'text' || ($cfg['card_style'] === 'thumb_if_has' && empty($data['has_img']))) { + $thumb = ''; + $mult = ''; + $classes[] = 'no-thumb'; + $classes[] = 'posts-item--text'; + } + $title_markup = aksrt_renderer_title_markup($data, $cfg); + $excerpt_markup = aksrt_renderer_excerpt_markup($data, $cfg); + $badges = aksrt_renderer_badges($data, $cfg); + $meta = aksrt_renderer_meta($data, $cfg); + + $target_attr = !empty($cfg['target_blank']) ? ' target="_blank"' : ''; + $heading_cls = !empty($cfg['heading_bold']) ? 'item-heading item-heading--bold' : 'item-heading'; + $title_html = '

    ' . $title_markup . '

    '; + $excerpt_html = $cfg['show_excerpt'] && $excerpt_markup !== '' + ? '

    ' . $excerpt_markup . '

    ' + : ''; + + $html = ''; + + if ($cfg['style'] === 'style2') { + $html .= $title_html; + $html .= '
    '; + $html .= $thumb; + $html .= '
    '; + $html .= $mult ?: $excerpt_html; + $html .= $badges; + $html .= $meta; + $html .= '
    '; + } else { + $html .= $thumb; + $html .= '
    '; + $html .= $title_html; + $html .= $mult ?: $excerpt_html; + $html .= '
    ' . $badges . $meta . '
    '; + $html .= '
    '; + } + + $html .= '
    '; + return $html; + } +} + +if (!function_exists('aksrt_renderer_card')) { + /** + * Render a vertical card or CMS resource card. + * + * @param array $data Collected post data. + * @param array $cfg Renderer config. + * @return string + */ + function aksrt_renderer_card(array $data, array $cfg): string { + $classes = ['posts-item', 'card', 'ajax-item', 'posts-item--' . ($cfg['card_style'] ?: 'standard')]; + + if ($cfg['style'] !== '' && $cfg['style'] !== 'default') { + $classes[] = $cfg['style']; + } + if ($cfg['context'] === 'home') { + $classes[] = 'posts-item--home'; + } + if ($cfg['mode'] === 'grid-cms') { + $classes[] = 'posts-item--cms'; + } + if (!empty($data['is_mult_thumb'])) { + $classes[] = 'mult-thumb'; + } + if (empty($data['has_img'])) { + $classes[] = 'no-thumb'; + } + + $thumb = aksrt_renderer_thumb($data, $cfg); + $badges = aksrt_renderer_badges($data, $cfg); + $resource_strip = aksrt_renderer_resource_strip($data); + $meta = aksrt_renderer_meta($data, $cfg); + $title_markup = aksrt_renderer_title_markup($data, $cfg); + $excerpt_markup = aksrt_renderer_excerpt_markup($data, $cfg); + + $html = ''; + $html .= $thumb; + $html .= '
    '; + $target_attr = !empty($cfg['target_blank']) ? ' target="_blank"' : ''; + $heading_cls = !empty($cfg['heading_bold']) ? 'item-heading item-heading--bold' : 'item-heading'; + $html .= '

    ' . $title_markup . '

    '; + + if ($cfg['show_excerpt'] && $excerpt_markup !== '') { + $html .= '

    ' . $excerpt_markup . '

    '; + } + + $html .= $badges; + + if ($cfg['mode'] === 'grid-cms') { + $html .= $resource_strip; + } + + $html .= $meta; + $html .= '
    '; + + return $html; + } +} + +if (!function_exists('aksrt_renderer_mini')) { + /** + * Render a compact mini card. + * + * @param array $data Collected post data. + * @param array $cfg Renderer config. + * @return string + */ + function aksrt_renderer_mini(array $data, array $cfg): string { + $classes = ['posts-mini']; + if (!empty($cfg['extra_class'])) { + $classes[] = $cfg['extra_class']; + } + if (empty($data['has_img'])) { + $classes[] = 'posts-mini--no-thumb'; + } + + $number_html = ''; + if ($cfg['show_number'] && $cfg['number_offset'] > 0) { + $badge_colors = ['c-red', 'c-yellow', 'c-orange', 'c-blue', 'c-green']; + $index = $cfg['number_offset'] - 1; + $color = $badge_colors[$index] ?? ''; + $number_html = '' . (int) $cfg['number_offset'] . ''; + } + + $thumb_html = ''; + $target_attr = !empty($cfg['target_blank']) ? ' target="_blank"' : ''; + if ($cfg['show_thumb'] && !empty($data['has_img'])) { + $lazy = $data['lazy']; + $thumb_img = !empty($lazy['enabled']) && !empty($data['has_real_img']) + ? '' . esc_attr($data['title']) . '' + : '' . esc_attr($data['title']) . ''; + + $thumb_html = '' . $thumb_img . ''; + } + + $heading_class = 'item-heading' . (!empty($cfg['heading_bold']) ? ' item-heading--bold' : '') . (!empty($data['has_img']) ? ' text-ellipsis-2' : ' text-ellipsis'); + $title_html = '

    ' . $number_html . '' . aksrt_renderer_title_markup($data, $cfg) . '

    '; + + $html = '
    '; + $html .= $thumb_html; + $html .= '
    '; + $html .= $title_html; + $html .= aksrt_renderer_meta($data, $cfg); + $html .= '
    '; + + return $html; + } +} + +if (!function_exists('aksrt_render_post')) { + /** + * Render one post item inside a loop. + * + * @param array $config Renderer config overrides. + * @return string + */ + function aksrt_render_post(array $config = []): string { + $cfg = aksrt_renderer_config($config); + $data = aksrt_renderer_collect_post_data($cfg); + + if (empty($data)) { + return ''; + } + + switch ($cfg['mode']) { + case 'card': + case 'grid-cms': + return aksrt_renderer_card($data, $cfg); + case 'mini': + return aksrt_renderer_mini($data, $cfg); + case 'list': + default: + return aksrt_renderer_list($data, $cfg); + } + } +} + +if (!function_exists('aksrt_render_post_list')) { + /** + * Render a query loop with the unified renderer. + * + * @param WP_Query|null $query Posts query. Falls back to global loop. + * @param array $config Renderer config overrides. + * @param bool $echo Whether to echo output. + * @return string + */ + function aksrt_render_post_list(?WP_Query $query = null, array $config = [], bool $echo = true): string { + $cfg = aksrt_renderer_config($config); + $wrapper_classes = ['posts-row', 'ajaxpager']; + + if ($cfg['context'] === 'home') { + $wrapper_classes[] = 'aksrt-homepage__posts'; + } elseif ($cfg['mode'] !== 'mini') { + $wrapper_classes[] = 'post-grid'; + $wrapper_classes[] = 'post-grid--' . $cfg['mode']; + } + + if (!empty($config['wrapper_class']) && is_string($config['wrapper_class'])) { + foreach (preg_split('/\s+/', trim($config['wrapper_class'])) as $extra_class) { + $extra_class = sanitize_html_class($extra_class); + if ($extra_class !== '') { + $wrapper_classes[] = $extra_class; + } + } + } + + $wrapper_attr = 'class="' . esc_attr(implode(' ', array_unique($wrapper_classes))) . '"'; + if ($cfg['columns'] > 1) { + $wrapper_attr .= ' style="--aksrt-widget-columns:' . (int) $cfg['columns'] . '"'; + } + + $data_view = in_array($cfg['mode'], ['card', 'grid-cms'], true) ? 'card' : $cfg['mode']; + $html = ''; + + if ($query instanceof WP_Query) { + if (!$query->have_posts()) { + $html = '

    ' . esc_html__('暂无文章', 'aksrt') . '

    '; + } else { + $html .= '
    '; + $number = $cfg['number_offset']; + while ($query->have_posts()) { + $query->the_post(); + if ($cfg['mode'] === 'mini' && $cfg['show_number']) { + $number++; + $config['number_offset'] = $number; + $cfg = aksrt_renderer_config($config); + } + $html .= aksrt_render_post($cfg); + } + $html .= '
    '; + } + wp_reset_postdata(); + } else { + if (!have_posts()) { + $html = '

    ' . esc_html__('暂无文章', 'aksrt') . '

    '; + } else { + $html .= '
    '; + while (have_posts()) { + the_post(); + $html .= aksrt_render_post($cfg); + } + $html .= '
    '; + } + } + + if ($echo) { + echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + return ''; + } + + return $html; + } +} + +if (!function_exists('aksrt_render_mini_list')) { + /** + * Convenience wrapper for mini lists. + * + * @param WP_Query $query Posts query. + * @param array $config Renderer config overrides. + * @param bool $echo Whether to echo output. + * @return string + */ + function aksrt_render_mini_list(WP_Query $query, array $config = [], bool $echo = true): string { + $config['mode'] = 'mini'; + return aksrt_render_post_list($query, $config, $echo); + } +} + +if (!function_exists('aksrt_render_placeholder')) { + /** + * Render skeleton placeholders for loading states. + * + * @param string $mode list|card|mini. + * @param int $count Number of placeholders. + * @return string + */ + function aksrt_render_placeholder(string $mode = 'list', int $count = 3): string { + $count = max(1, min(12, $count)); + $html = ''; + + if ($mode === 'card') { + $item = '
    ' + . '

    ' + . '

    ' + . '
    '; + $html = str_repeat($item, $count); + } elseif ($mode === 'mini') { + $item = '
    ' + . '
    ' + . '
    '; + $html = str_repeat($item, $count); + } else { + $item = '
    ' + . '

    ' + . '

    ' + . '
    '; + $html = str_repeat($item, $count); + } + + return $html; + } +} diff --git a/inc/helpers/aksrt-resource.php b/inc/helpers/aksrt-resource.php new file mode 100644 index 00000000..cbbf0331 --- /dev/null +++ b/inc/helpers/aksrt-resource.php @@ -0,0 +1,579 @@ +ID, AKSRT_Domain_Model::RESOURCE_DOWNLOAD_COUNT_KEY, true); + $legacy_download_count = (int) get_post_meta($p->ID, 'download_count', true); + $resource_view_count = (int) get_post_meta($p->ID, AKSRT_Domain_Model::RESOURCE_VIEW_COUNT_KEY, true); + $common_view_count = (int) get_post_meta($p->ID, AKSRT_Domain_Model::POST_VIEW_COUNT_KEY, true); + + return [ + 'version' => (string) ($pay_meta['version'] ?? ''), + 'size' => (string) ($pay_meta['size'] ?? ''), + 'price' => (float) ($pay_meta['price'] ?? 0), + 'original_price' => (float) ($pay_meta['original_price'] ?? 0), + 'vip1_price' => (float) ($pay_meta['vip1_price'] ?? 0), + 'vip2_price' => (float) ($pay_meta['vip2_price'] ?? 0), + 'pay_type' => (string) ($pay_meta['pay_type'] ?? AKSRT_Domain_Model::PAY_TYPE_DOWNLOAD), + 'license' => (string) ($pay_meta['license'] ?? ''), + 'compat' => (string) ($pay_meta['compat'] ?? ''), + 'demo_url' => (string) ($pay_meta['demo_url'] ?? ''), + 'downloads' => array_values(is_array($pay_meta['downloads'] ?? null) ? $pay_meta['downloads'] : []), + 'download_count' => max(0, max($download_count, $legacy_download_count)), + 'view_count' => max(0, max($resource_view_count, $common_view_count)), + 'require_login' => !empty($pay_meta['require_login']), + 'require_captcha' => !empty($pay_meta['require_captcha']), + ]; + + // 一次取出全部 post meta(WP 已缓存到 object cache) + $all = get_post_meta($p->ID); + if (!is_array($all)) $all = []; + + $get = static function ($key) use ($all) { + if (!isset($all[$key]) || !is_array($all[$key]) || empty($all[$key])) return null; + return maybe_unserialize($all[$key][0]); + }; + + $downloads = $get('_aksrt_res_downloads'); + if (!is_array($downloads)) $downloads = []; + $downloads = array_values(array_filter(array_map(static function ($row) { + if (!is_array($row)) return null; + $extract_keys = [ + 'extract_code', 'extractCode', 'extract', 'extract_key', + 'extraction_code', 'extractionCode', + 'code', 'password', 'pwd', + 'pan_code', 'panCode', 'pan_password', 'panPassword', + 'access_code', 'accessCode', + ]; + if (!array_key_exists('extract_code', $row)) { + foreach ($extract_keys as $alias) { + if (isset($row[$alias]) && !is_array($row[$alias]) && trim((string) $row[$alias]) !== '') { + $row['extract_code'] = (string) $row[$alias]; + break; + } + } + } + $row['extract_code'] = isset($row['extract_code']) ? trim((string) $row['extract_code']) : ''; + return $row; + }, $downloads))); + $resource_download_count = (int) ($get('_aksrt_res_download_count') ?? 0); + $legacy_download_count = (int) ($get('download_count') ?? 0); + $resource_view_count = (int) ($get('_aksrt_res_view_count') ?? 0); + $common_view_count = (int) ($get('aksrt_post_views') ?? 0); + + $allowed_pay_types = ['download', 'reading', 'gallery', 'video', 'vip']; + $pay_type = (string) ($get('_aksrt_res_pay_type') ?? 'download'); + if (!in_array($pay_type, $allowed_pay_types, true)) { + $pay_type = 'download'; + } + + return [ + 'version' => (string) ($get('_aksrt_res_version') ?? ''), + 'size' => (string) ($get('_aksrt_res_size') ?? ''), + 'price' => (float) ($get('_aksrt_res_price') ?? 0), + 'original_price' => (float) ($get('_aksrt_res_original_price') ?? 0), + 'vip1_price' => (float) ($get('_aksrt_res_vip1_price') ?? 0), + 'vip2_price' => (float) ($get('_aksrt_res_vip2_price') ?? 0), + 'pay_type' => $pay_type, + 'license' => (string) ($get('_aksrt_res_license') ?? ''), + 'compat' => (string) ($get('_aksrt_res_compat') ?? ''), + 'demo_url' => (string) ($get('_aksrt_res_demo_url') ?? ''), + 'downloads' => array_values($downloads), + 'download_count' => max(0, max($resource_download_count, $legacy_download_count)), + 'view_count' => max(0, max($resource_view_count, $common_view_count)), + 'require_login' => (bool) ($get('_aksrt_res_require_login') ?? false), + 'require_captcha' => (bool) ($get('_aksrt_res_require_captcha') ?? false), + ]; + } +} + +if (!function_exists('aksrt_default_resource_meta')) { + function aksrt_default_resource_meta(): array { + $meta = AKSRT_Domain_Model::default_pay_meta(); + + return [ + 'version' => (string) ($meta['version'] ?? ''), + 'size' => (string) ($meta['size'] ?? ''), + 'price' => (float) ($meta['price'] ?? 0), + 'original_price' => (float) ($meta['original_price'] ?? 0), + 'vip1_price' => (float) ($meta['vip1_price'] ?? 0), + 'vip2_price' => (float) ($meta['vip2_price'] ?? 0), + 'pay_type' => AKSRT_Domain_Model::PAY_TYPE_DOWNLOAD, + 'license' => (string) ($meta['license'] ?? ''), + 'compat' => (string) ($meta['compat'] ?? ''), + 'demo_url' => (string) ($meta['demo_url'] ?? ''), + 'downloads' => [], + 'download_count' => 0, + 'view_count' => 0, + 'require_login' => !empty($meta['require_login']), + 'require_captcha' => !empty($meta['require_captcha']), + ]; + + return [ + 'version' => '', + 'size' => '', + 'price' => 0.0, + 'original_price' => 0.0, + 'vip1_price' => 0.0, + 'vip2_price' => 0.0, + 'pay_type' => 'download', + 'license' => '', + 'compat' => '', + 'demo_url' => '', + 'downloads' => [], + 'download_count' => 0, + 'view_count' => 0, + 'require_login' => false, + 'require_captcha' => false, + ]; + } +} + +if (!function_exists('aksrt_resource_price_label')) { + /** + * 把价格数字渲染成展示徽章文案。 + * + * @param float $price + * @return array { text:string, free:bool } + */ + function aksrt_resource_price_label(float $price): array { + if ($price <= 0) { + return [ + 'text' => __('免费', 'aksrt'), + 'free' => true, + ]; + } + $n = (fmod($price, 1.0) === 0.0) ? (string) (int) $price : rtrim(rtrim(number_format($price, 2, '.', ''), '0'), '.'); + return [ + 'text' => sprintf(__('%s 积分', 'aksrt'), $n), + 'free' => false, + ]; + } +} + +if (!function_exists('aksrt_resource_price_amount')) { + /** + * Format the raw resource price number for compact UI labels. + * + * @param float $price + * @return string + */ + function aksrt_resource_price_amount(float $price): string { + if (fmod($price, 1.0) === 0.0) { + return (string) (int) $price; + } + + return rtrim(rtrim(number_format($price, 2, '.', ''), '0'), '.'); + } +} + +if (!function_exists('aksrt_resource_card_badge_text')) { + /** + * Resource badge text used in mixed post cards. + * + * Only resource-type posts should render this badge. + * + * @param int|WP_Post|null $post + * @return string + */ + function aksrt_resource_card_badge_text($post = null): string { + if (!function_exists('aksrt_is_resource_post') || !aksrt_is_resource_post($post)) { + return ''; + } + + $meta = aksrt_get_resource_meta($post); + $price = (float) $meta['price']; + + if ($price <= 0) { + // 免费资源不在卡片上显示价格 chip。 + return ''; + } + + $currency = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_currency_name() : __('积分', 'aksrt'); + return sprintf(__('付费 %s %s', 'aksrt'), aksrt_resource_price_amount($price), $currency); + } +} + +if (!function_exists('aksrt_resource_pay_type_badge')) { + /** + * 根据付费类型返回徽章 HTML。 + * + * @param string $pay_type + * @return string HTML 或空字符串 + */ + function aksrt_resource_pay_type_badge(string $pay_type): string { + $badges = [ + 'reading' => ['label' => __('付费阅读', 'aksrt'), 'color' => '#EF4444', 'icon' => 'fa-lock'], + 'download'=> ['label' => __('付费下载', 'aksrt'), 'color' => '#F97316', 'icon' => 'fa-download'], + 'gallery' => ['label' => __('付费图库', 'aksrt'), 'color' => '#10B981', 'icon' => 'fa-images'], + 'video' => ['label' => __('付费视频', 'aksrt'), 'color' => '#8B5CF6', 'icon' => 'fa-play-circle'], + 'vip' => ['label' => __('VIP 专属', 'aksrt'), 'color' => '#3B82F6', 'icon' => 'fa-crown'], + ]; + + if ($pay_type === 'download' || !isset($badges[$pay_type])) { + return ''; + } + + $b = $badges[$pay_type]; + return sprintf( + ' %s', + esc_attr($b['color']), + esc_attr($b['icon']), + esc_html($b['label']) + ); + } +} + +if (!function_exists('aksrt_resource_preview_images')) { + /** + * Collect preview images for resource cards and archive thumbnails. + * + * Priority: curated cover_images meta -> featured image -> gallery/content images. + * + * @param int|WP_Post|null $post + * @param int $limit + * @return string[] + */ + function aksrt_resource_preview_images($post = null, int $limit = 4): array { + $p = get_post($post); + if (!($p instanceof WP_Post) || !function_exists('aksrt_is_resource_post') || !aksrt_is_resource_post($p)) { + return []; + } + + $limit = max(1, min(8, $limit)); + $images = []; + + if (class_exists('AKSRT_Post_Meta_Box')) { + $cover_images = (string) AKSRT_Post_Meta_Box::get_meta_value($p->ID, 'cover_images', ''); + if ($cover_images !== '') { + $cover_ids = array_filter(array_map('absint', preg_split('/[\s,]+/', $cover_images) ?: [])); + foreach ($cover_ids as $attachment_id) { + $url = (string) wp_get_attachment_image_url($attachment_id, 'medium_large'); + if ($url !== '') { + $images[] = esc_url_raw($url); + } + } + } + } + + if (has_post_thumbnail($p)) { + $featured = (string) get_the_post_thumbnail_url($p, 'medium_large'); + if ($featured !== '') { + $images[] = esc_url_raw($featured); + } + } + + if (function_exists('aksrt_get_post_gallery_images')) { + $images = array_merge($images, aksrt_get_post_gallery_images($p->ID, $limit)); + } + + if (function_exists('aksrt_get_first_content_image_url')) { + $content_image = (string) aksrt_get_first_content_image_url($p->ID); + if ($content_image !== '') { + $images[] = esc_url_raw($content_image); + } + } + + $images = array_values(array_unique(array_filter($images, static function ($url): bool { + return is_string($url) && $url !== ''; + }))); + + return array_slice($images, 0, $limit); + } +} + +if (!function_exists('aksrt_resource_primary_image_url')) { + /** + * Primary preview image for a resource post. + * + * @param int|WP_Post|null $post + * @return string + */ + function aksrt_resource_primary_image_url($post = null): string { + $images = aksrt_resource_preview_images($post, 1); + return $images[0] ?? ''; + } +} + +if (!function_exists('aksrt_resource_meta_strip')) { + /** + * 卡片用的紧凑元数据条 [v1.0 · 210MB · ↓1234]。 + * + * @param array $meta aksrt_get_resource_meta 返回值 + * @return array 字符串数组 + */ + function aksrt_resource_meta_strip(array $meta): array { + $parts = []; + if ($meta['version'] !== '') { + $parts[] = 'v' . $meta['version']; + } + if ($meta['size'] !== '') { + $parts[] = $meta['size']; + } + if ($meta['download_count'] > 0) { + $parts[] = '↓ ' . number_format_i18n($meta['download_count']); + } + return $parts; + } +} + +/** + * 是否命中下载积分免扣策略。 + */ +if (!function_exists('aksrt_is_download_credit_exempt_user')) { + function aksrt_is_download_credit_exempt_user(int $user_id = 0): bool { + if ($user_id <= 0 || !class_exists('AKSRT_Settings')) { + return false; + } + + $paid_settings = AKSRT_Settings::get_paid_content_settings(); + if (empty($paid_settings['vip_free_download']) || empty($paid_settings['vip_exempt_roles'])) { + return false; + } + + $user = get_userdata($user_id); + if (!$user || empty($user->roles) || !is_array($user->roles)) { + return false; + } + + return !empty(array_intersect($user->roles, (array) $paid_settings['vip_exempt_roles'])); + } +} + +if (!function_exists('aksrt_can_download')) { + /** + * 是否可下载(P3 阶段:登录 + 积分校验已接入;P5 接入验证码)。 + * + * 返回 WP_Error 表示拒绝;返回 true 表示放行。 + * + * @param int|WP_Post|null $post + * @param int $user_id 0 表示当前用户 + * @return true|WP_Error + */ + function aksrt_can_download($post = null, int $user_id = 0) { + if (class_exists('AKSRT_Content_Visibility')) { + return AKSRT_Content_Visibility::can_download_resource($post, $user_id); + } + + $p = get_post($post); + if (!($p instanceof WP_Post) || !aksrt_is_resource_post($p)) { + return new WP_Error('aksrt_not_resource', __('该内容不是可下载资源。', 'aksrt')); + } + + $meta = aksrt_get_resource_meta($p); + $paid_settings = class_exists('AKSRT_Settings') + ? AKSRT_Settings::get_paid_content_settings() + : [ + 'download_login' => true, + 'download_credit' => true, + ]; + + // 登录门槛:全局开关 OR 单条覆盖 + $need_login = !empty($paid_settings['download_login']) || !empty($meta['require_login']); + $uid = $user_id > 0 ? $user_id : get_current_user_id(); + + if ($need_login && $uid <= 0) { + return new WP_Error('aksrt_need_login', __('请先登录后再下载。', 'aksrt'), [ + 'status' => 401, + 'login_url' => wp_login_url(get_permalink($p)), + ]); + } + + // 积分门槛:仅在 cms_features.download_credit 开启 + 实际成本 > 0 + 登录态时才校验 + $credit_enabled = !empty($paid_settings['download_credit']); + + if ($credit_enabled) { + $cost = (int) aksrt_resource_download_cost($p); + if ($cost > 0) { + if ($uid <= 0) { + return new WP_Error('aksrt_need_login', __('请先登录后再下载。', 'aksrt'), [ + 'status' => 401, + 'login_url' => wp_login_url(get_permalink($p)), + ]); + } + $balance = class_exists('AKSRT_Credits') ? AKSRT_Credits::balance($uid) : 0; + if ($balance < $cost) { + return new WP_Error('aksrt_credits_insufficient', __('积分不足,无法下载。', 'aksrt'), [ + 'status' => 402, + 'balance' => $balance, + 'cost' => $cost, + ]); + } + } + } + + // P5 阶段:验证码校验(在请求时校验,不在 can_download 静态判定中拦截) + return true; + } +} + +if (!function_exists('aksrt_resource_download_cost')) { + /** + * 取实际需要扣除的积分(向上取整),未启用积分门槛时返回 0。 + * + * 优先级:单条 _aksrt_res_price > 全局 download_default_cost。 + */ + function aksrt_resource_download_cost($post = null): int { + $meta = aksrt_get_resource_meta($post); + $paid_settings = class_exists('AKSRT_Settings') + ? AKSRT_Settings::get_paid_content_settings() + : [ + 'download_credit' => true, + 'default_cost' => 0, + ]; + if (empty($paid_settings['download_credit'])) return 0; + if (function_exists('get_current_user_id') && aksrt_is_download_credit_exempt_user((int) get_current_user_id())) { + return 0; + } + $price = (float) $meta['price']; + if ($price > 0) { + return (int) ceil($price); + } + // 单条未设价格 → 回退到全局默认价 + return max(0, (int) ($paid_settings['default_cost'] ?? 0)); + } +} + +if (!function_exists('aksrt_resource_meta_query')) { + /** + * 「是资源」WP_Query meta_query 片段。 + * + * v3.4: 资源 = post + _aksrt_is_resource = 1。 + * 兼容历史 aksrt_resource CPT 时由调用方决定是否额外加 post_type。 + * + * @return array + */ + function aksrt_resource_meta_query(): array { + return [ + 'key' => '_aksrt_is_resource', + 'value' => '1', + 'compare' => '=', + ]; + } +} + +if (!function_exists('aksrt_resource_post_types')) { + /** + * 资源查询使用的 post_type 数组。 + * + * 当历史 aksrt_resource CPT 仍在站点中存在(未迁移完成)时,会自动包含。 + * + * @return array + */ + function aksrt_resource_post_types(): array { + $types = ['post']; + if (post_type_exists('aksrt_resource')) { + $types[] = 'aksrt_resource'; + } + return $types; + } +} + +if (!function_exists('aksrt_resource_archive_url')) { + /** + * 取资源归档页 URL(v3.4 起统一为 /resources/)。 + */ + function aksrt_resource_archive_url(): string { + return home_url('/resources/'); + } +} + +if (!function_exists('aksrt_is_resource_archive')) { + /** + * 当前请求是否为资源归档(/resources)。 + * + * 兼容历史 aksrt_resource CPT 归档。 + */ + function aksrt_is_resource_archive(): bool { + if (function_exists('post_type_exists') && post_type_exists('aksrt_resource') + && function_exists('is_post_type_archive') && is_post_type_archive('aksrt_resource')) { + return true; + } + return (bool) get_query_var('aksrt_is_resource_archive'); + } +} + +if (!function_exists('aksrt_detect_cloud_service')) { + /** + * 从下载链接自动识别云盘服务商。 + * + * @param string $url 下载链接 + * @return array { key:string, name:string, color:string } + */ + function aksrt_detect_cloud_service(string $url): array { + $url_lower = strtolower($url); + + $services = [ + 'baidu' => ['name' => '百度网盘', 'color' => '#1578f8', 'pattern' => ['pan.baidu.com', 'yun.baidu.com']], + 'lanzou' => ['name' => '蓝奏云', 'color' => '#f37526', 'pattern' => ['lanzouf.com', 'lanzoui.com', 'lanzoux.com', 'lanzouw.com', 'lanzoup.com', 'lanzout.com', 'lanzou']], + 'aliyun' => ['name' => '阿里云盘', 'color' => '#6666ff', 'pattern' => ['alipan.com', 'aliyundrive.com']], + 'weiyun' => ['name' => '微云', 'color' => '#4facfe', 'pattern' => ['share.weiyun.com', 'weiyun.com']], + 'onedrive' => ['name' => 'OneDrive', 'color' => '#0078d4', 'pattern' => ['1drv.ms', 'onedrive.live.com']], + 'tianyi' => ['name' => '天翼云盘', 'color' => '#0099ff', 'pattern' => ['cloud.189.cn']], + '123pan' => ['name' => '123云盘', 'color' => '#2b7bff', 'pattern' => ['123pan.com', '123684.com']], + 'quark' => ['name' => '夸克网盘', 'color' => '#6a5acd', 'pattern' => ['pan.quark.cn']], + 'xunlei' => ['name' => '迅雷云盘', 'color' => '#00a4ff', 'pattern' => ['pan.xunlei.com']], + 'mega' => ['name' => 'MEGA', 'color' => '#d92323', 'pattern' => ['mega.nz', 'mega.co.nz']], + 'gofile' => ['name' => 'GoFile', 'color' => '#4caf50', 'pattern' => ['gofile.io']], + 'mediafire' => ['name' => 'MediaFire', 'color' => '#1294f2', 'pattern' => ['mediafire.com']], + 'github' => ['name' => 'GitHub', 'color' => '#333333', 'pattern' => ['github.com', 'raw.githubusercontent.com']], + ]; + + foreach ($services as $key => $svc) { + foreach ($svc['pattern'] as $p) { + if (strpos($url_lower, $p) !== false) { + return ['key' => $key, 'name' => $svc['name'], 'color' => $svc['color']]; + } + } + } + + return ['key' => 'other', 'name' => __('下载', 'aksrt'), 'color' => '#6b7280']; + } +} + +if (!function_exists('aksrt_resource_meta_icon')) { + /** + * 根据元信息字段返回对应的 Font Awesome 图标类。 + */ + function aksrt_resource_meta_icon(string $field): string { + $icons = [ + 'version' => 'fas fa-code-branch', + 'size' => 'fas fa-hdd', + 'compat' => 'fas fa-laptop', + 'license' => 'fas fa-shield-alt', + 'demo_url' => 'fas fa-external-link-alt', + 'download' => 'fas fa-download', + 'views' => 'fas fa-eye', + ]; + return $icons[$field] ?? 'fas fa-info-circle'; + } +} diff --git a/inc/helpers/aksrt-shortcodes.php b/inc/helpers/aksrt-shortcodes.php new file mode 100644 index 00000000..c994e3ad --- /dev/null +++ b/inc/helpers/aksrt-shortcodes.php @@ -0,0 +1,255 @@ +render_hide_shortcode($atts, $content); + } + + /** + * [hide]...[/hide] — 评论后可见(默认 mode=comment) + */ + public function render_hide_comment(array $atts = [], string $content = ''): string { + $atts = shortcode_atts(['message' => ''], $atts, 'hide'); + return $this->render_hide(['mode' => 'comment', 'message' => $atts['message']], $content); + } + + /** + * [reply]...[/reply] — 回复(评论)后可见 + */ + public function render_reply(array $atts = [], string $content = ''): string { + $atts = shortcode_atts(['message' => ''], $atts, 'reply'); + $msg = $atts['message'] ?: __('回复本文后可查看隐藏内容', 'aksrt'); + return $this->render_hide(['mode' => 'comment', 'message' => $msg], $content); + } + + /** + * [vip]...[/vip] — VIP 用户可见 + */ + public function render_vip(array $atts = [], string $content = ''): string { + $atts = shortcode_atts(['message' => ''], $atts, 'vip'); + $uid = get_current_user_id(); + + $is_vip = $uid > 0 && function_exists('aksrt_is_vip') && aksrt_is_vip($uid); + + if ($is_vip) { + return '
    ' + . do_shortcode($content) . '
    '; + } + + $message = $atts['message'] ?: __('此内容仅 VIP 用户可见', 'aksrt'); + $html = '
    '; + $html .= '
    '; + $html .= ' '; + $html .= '' . esc_html($message) . ''; + $html .= '
    '; + if ($uid <= 0) { + $html .= ''; + } + $html .= '
    '; + return $html; + } + + /** + * [payshow]...[/payshow] — 付费后可见 + */ + public function render_payshow(array $atts = [], string $content = ''): string { + $atts = shortcode_atts(['message' => ''], $atts, 'payshow'); + $pid = get_the_ID(); + + $can_view = false; + if ($pid && class_exists('AKSRT_Content_Visibility')) { + $can_view = AKSRT_Content_Visibility::can_view_paid_content($pid); + } + + if ($can_view) { + return '
    ' + . do_shortcode($content) . '
    '; + } + + $message = $atts['message'] ?: __('此内容需付费后查看', 'aksrt'); + $uid = get_current_user_id(); + $html = '
    '; + $html .= '
    '; + $html .= ' '; + $html .= '' . esc_html($message) . ''; + $html .= '
    '; + if ($uid <= 0) { + $html .= ''; + } + $html .= '
    '; + return $html; + } + + /** + * [content type="admin|author|vip"]...[/content] — 按角色/身份控制可见性 + */ + public function render_content_gate(array $atts = [], string $content = ''): string { + $atts = shortcode_atts(['type' => 'admin', 'message' => ''], $atts, 'content'); + $type = sanitize_key((string) $atts['type']); + $uid = get_current_user_id(); + $pid = get_the_ID(); + $allowed = false; + + switch ($type) { + case 'admin': + $allowed = $uid > 0 && user_can($uid, 'manage_options'); + break; + case 'author': + if ($uid > 0 && $pid) { + $post = get_post($pid); + $allowed = $post && (int) $post->post_author === $uid; + } + break; + case 'vip': + $allowed = $uid > 0 && function_exists('aksrt_is_vip') && aksrt_is_vip($uid); + break; + case 'login': + $allowed = $uid > 0; + break; + } + + if ($allowed) { + return '
    ' + . do_shortcode($content) . '
    '; + } + + $default_msgs = [ + 'admin' => __('此内容仅管理员可见', 'aksrt'), + 'author' => __('此内容仅作者可见', 'aksrt'), + 'vip' => __('此内容仅 VIP 用户可见', 'aksrt'), + 'login' => __('此内容仅登录用户可见', 'aksrt'), + ]; + $message = $atts['message'] ?: ($default_msgs[$type] ?? __('此内容已隐藏', 'aksrt')); + $html = '
    '; + $html .= '
    '; + $html .= ' '; + $html .= '' . esc_html($message) . ''; + $html .= '
    '; + $html .= '
    '; + return $html; + } + + /** + * [aksrt_archive] 文章归档 + */ + public function render_archive(array $atts = [], string $content = ''): string { + if (!class_exists('AKSRT_Templates')) { + return ''; + } + return AKSRT_Templates::get_instance()->render_archive_shortcode($atts, $content); + } + + /** + * [aksrt_profile] 个人资料卡 + */ + public function render_profile(array $atts = [], string $content = ''): string { + if (!class_exists('AKSRT_Templates')) { + return ''; + } + return AKSRT_Templates::get_instance()->render_profile_shortcode($atts, $content); + } + + /** + * [aksrt_posts count="6" cat="" tag="" orderby="date" order="DESC" mode="card"] + * + * 用统一渲染器输出文章列表,避免内容里手写卡片循环。 + */ + public function render_posts(array $atts = [], string $content = ''): string { + if (!function_exists('aksrt_render_post_list')) { + return ''; + } + + $a = shortcode_atts([ + 'count' => 6, + 'cat' => '', + 'tag' => '', + 'orderby' => 'date', + 'order' => 'DESC', + 'mode' => 'card', + ], $atts, 'aksrt_posts'); + + $query_args = [ + 'post_type' => 'post', + 'post_status' => 'publish', + 'posts_per_page' => max(1, min(24, (int) $a['count'])), + 'orderby' => sanitize_key((string) $a['orderby']) ?: 'date', + 'order' => strtoupper((string) $a['order']) === 'ASC' ? 'ASC' : 'DESC', + 'ignore_sticky_posts' => true, + 'no_found_rows' => true, + ]; + + $cat = sanitize_text_field((string) $a['cat']); + if ($cat !== '') { + $query_args[is_numeric($cat) ? 'cat' : 'category_name'] = $cat; + } + $tag = sanitize_text_field((string) $a['tag']); + if ($tag !== '') { + $query_args[is_numeric($tag) ? 'tag_id' : 'tag'] = $tag; + } + + $mode = in_array($a['mode'], ['card', 'list', 'mini'], true) ? $a['mode'] : 'card'; + + $query = new WP_Query($query_args); + $html = aksrt_render_post_list($query, [ + 'context' => 'widget', + 'mode' => $mode, + ], false); + wp_reset_postdata(); + + return '
    ' . $html . '
    '; + } +} + +endif; diff --git a/inc/helpers/aksrt-slider.php b/inc/helpers/aksrt-slider.php new file mode 100644 index 00000000..e5b3ed18 --- /dev/null +++ b/inc/helpers/aksrt-slider.php @@ -0,0 +1,217 @@ + 'https://...', + * 'link' => 'https://...', + * 'title' => '标题', + * 'desc' => '描述', + * 'alt' => '图片 alt', + * 'bg_color' => '#f0f0f0', // 背景色 + * 'target' => '_blank', + * ] + * + * @return string HTML + */ +function aksrt_render_slider(array $args = []): string { + $defaults = [ + 'slides' => [], + 'effect' => 'slide', + 'loop' => true, + 'autoplay' => true, + 'interval' => 5000, + 'speed' => 0, + 'direction' => 'horizontal', + 'spaceBetween' => 0, + 'pagination_type' => 'bullets', + 'pc_height' => 'auto', + 'm_height' => 'auto', + 'scale_height' => false, + 'scale_ratio' => 0.45, + 'wrapper_class' => '', + 'slider_class' => '', + 'show_nav' => true, + 'show_pagination' => true, + ]; + + $args = wp_parse_args($args, $defaults); + $slides = $args['slides']; + + if (empty($slides) || !is_array($slides)) { + return ''; + } + + // 构建 data 属性 + $data_attrs = []; + $data_attrs[] = 'data-effect="' . esc_attr($args['effect']) . '"'; + if ($args['loop']) $data_attrs[] = 'data-loop'; + if ($args['autoplay']) { + $data_attrs[] = 'data-autoplay'; + $data_attrs[] = 'data-interval="' . esc_attr((string) $args['interval']) . '"'; + } + if ($args['speed'] > 0) $data_attrs[] = 'data-speed="' . esc_attr((string) $args['speed']) . '"'; + if ($args['direction'] !== 'horizontal') $data_attrs[] = 'data-direction="' . esc_attr($args['direction']) . '"'; + if ($args['spaceBetween'] > 0) $data_attrs[] = 'data-spaceBetween="' . esc_attr((string) $args['spaceBetween']) . '"'; + if ($args['pagination_type'] !== 'bullets') $data_attrs[] = 'data-pagination-type="' . esc_attr($args['pagination_type']) . '"'; + + // 高度样式 + $height_style = ''; + if ($args['scale_height']) { + $height_style = sprintf('--pc-height:auto;--m-height:auto;--scale-ratio:%s;', esc_attr((string) $args['scale_ratio'])); + } else { + $height_style = sprintf('--pc-height:%s;--m-height:%s;', esc_attr($args['pc_height']), esc_attr($args['m_height'])); + } + + // CSS 类 + $slider_classes = ['new-swiper', 'slide-index']; + if ($args['slider_class']) $slider_classes[] = $args['slider_class']; + $slider_class_str = implode(' ', $slider_classes); + + $wrapper_classes = ['aksrt-slider-wrapper']; + if ($args['wrapper_class']) $wrapper_classes[] = $args['wrapper_class']; + $wrapper_class_str = implode(' ', $wrapper_classes); + + // 渲染 HTML + $html = '
    '; + $html .= '
    '; + $html .= '
    '; + + foreach ($slides as $slide) { + $image = esc_url($slide['image'] ?? ''); + $link = esc_url($slide['link'] ?? ''); + $title = sanitize_text_field($slide['title'] ?? ''); + $desc = sanitize_text_field($slide['desc'] ?? ''); + $alt = sanitize_text_field($slide['alt'] ?? $title); + $bg_color = sanitize_hex_color($slide['bg_color'] ?? ''); + $target = $slide['target'] === '_blank' ? '_blank' : '_self'; + + $slide_style = $bg_color ? 'background-color:' . esc_attr($bg_color) . ';' : ''; + + $html .= '
    '; + + if ($link) { + $html .= ''; + } + + if ($image) { + $html .= '' . esc_attr($alt) . ''; + $html .= '
    '; + } + + if ($link) { + $html .= '
    '; + } + + // 文字覆盖层 + if ($title || $desc) { + $html .= '
    '; + if ($title) $html .= '
    ' . esc_html($title) . '
    '; + if ($desc) $html .= '
    ' . esc_html($desc) . '
    '; + $html .= '
    '; + } + + $html .= '
    '; + } + + $html .= '
    '; // .swiper-wrapper + + if ($args['show_nav']) { + $html .= '
    '; + $html .= '
    '; + } + + if ($args['show_pagination']) { + $html .= '
    '; + } + + $html .= '
    '; // .new-swiper + $html .= '
    '; // .aksrt-slider-wrapper + + return $html; +} + +/** + * 渲染水平滚动器(Tab 导航、横向内容列表) + * + * @param array $args { + * @type array $items 内容数组 + * @type string $wrapper_class 额外 wrapper 类 + * @type bool $autoplay 是否自动播放 + * @type int $interval 自动播放间隔 + * @type string $render_item 自定义渲染回调 (callable) + * } + * @return string HTML + */ +function aksrt_render_scroll_slider(array $args = []): string { + $defaults = [ + 'items' => [], + 'wrapper_class' => '', + 'autoplay' => false, + 'interval' => 4000, + 'render_item' => null, + ]; + + $args = wp_parse_args($args, $defaults); + $items = $args['items']; + + if (empty($items)) return ''; + + $data_attrs = ['class="swiper-scroll' . ($args['wrapper_class'] ? ' ' . esc_attr($args['wrapper_class']) : '') . '"']; + if ($args['autoplay']) { + $data_attrs[] = 'data-autoplay'; + $data_attrs[] = 'data-interval="' . esc_attr((string) $args['interval']) . '"'; + } + + $html = '
    '; + $html .= '
    '; + + foreach ($items as $i => $item) { + $html .= '
    '; + if (is_callable($args['render_item'])) { + $html .= call_user_func($args['render_item'], $item, $i); + } else { + $html .= '
    ' . esc_html($item['label'] ?? '') . '
    '; + } + $html .= '
    '; + } + + $html .= '
    '; + $html .= '
    '; + $html .= '
    '; + $html .= '
    '; + + return $html; +} diff --git a/inc/helpers/aksrt-template-tags.php b/inc/helpers/aksrt-template-tags.php new file mode 100644 index 00000000..8bc143bc --- /dev/null +++ b/inc/helpers/aksrt-template-tags.php @@ -0,0 +1,1348 @@ + + */ +function aksrt_get_slider_config(): array { + if (class_exists('AKSRT_Settings')) { + return AKSRT_Settings::get_slider_config(); + } + return [ + 'effect' => 'fade', 'loop' => true, 'autoplay_interval' => 5000, + 'pagination' => true, 'navigation' => true, 'auto_height' => false, + 'pc_height' => '', 'mobile_height' => '', 'space_between' => 0, + ]; +} +endif; + +if (!function_exists('aksrt_get_banners')): +/** + * 获取已启用的 Hero 幻灯片列表(供首页 Hero 轮播使用) + * 数据由后台 "内容配置 → Hero 轮播" 管理 + * + * @param int $limit 最大数量 + * @return array + */ +function aksrt_get_banners($limit = 8, $kind = 'hero') { + $kind = in_array($kind, array('hero', 'banner'), true) ? $kind : 'hero'; + + $args = array( + 'post_type' => 'aksrt_banner', + 'posts_per_page' => $limit, + 'post_status' => 'publish', + 'orderby' => 'menu_order', + 'order' => 'ASC', + 'meta_query' => 'hero' === $kind + ? array( + 'relation' => 'OR', + array( + 'key' => '_aksrt_kind', + 'value' => 'hero', + ), + array( + 'key' => '_aksrt_kind', + 'compare' => 'NOT EXISTS', + ), + ) + : array( + array( + 'key' => '_aksrt_kind', + 'value' => 'banner', + ), + ), + ); + + $query = new WP_Query($args); + $banners = array(); + + if ($query->have_posts()) { + while ($query->have_posts()) { + $query->the_post(); + $id = get_the_ID(); + + // 只返回启用的 + $enabled = get_post_meta($id, '_enabled', true); + if ($enabled === '0') continue; + + $banners[] = array( + 'id' => $id, + 'title' => get_the_title(), + 'subtitle' => get_post_meta($id, '_subtitle', true), + 'description' => get_post_meta($id, '_description', true), + 'image' => get_post_meta($id, '_image_url', true), + 'video_url' => get_post_meta($id, '_video_url', true), + 'link' => get_post_meta($id, '_link_url', true), + 'link_target' => get_post_meta($id, '_link_target', true) ?: '_self', + 'text_align' => get_post_meta($id, '_text_align', true) ?: 'left', + 'device_visibility' => get_post_meta($id, '_device_visibility', true) ?: 'all', + 'kind' => get_post_meta($id, '_aksrt_kind', true) ?: 'hero', + 'show_text' => get_post_meta($id, '_show_text', true) !== '0', + // show_meta: legacy field, read for backward compat but not consumed by frontend + 'show_meta' => get_post_meta($id, '_show_meta', true) === '1', + 'text_size_pc' => get_post_meta($id, '_text_size_pc', true) ?: '', + 'text_size_m' => get_post_meta($id, '_text_size_m', true) ?: '', + 'text_parallax' => get_post_meta($id, '_text_parallax', true) === '1', + 'layers' => aksrt_decode_slide_layers($id), + ); + } + wp_reset_postdata(); + } + + return $banners; +} +endif; + +if (!function_exists('aksrt_render_slider_markup')): +/** + * Render a shared slider block for Hero and Banner sections. + * Uses Swiper.js when available, falls back to native JS. + * + * @param array $slides Slider items. + * @param array $args Render options. + */ +function aksrt_render_slider_markup(array $slides, array $args = array()) { + if (empty($slides)) { + return; + } + + $args = array_merge( + array( + 'section_class' => 'hero hero--slider hero--with-overlay-nav', + 'section_id' => '', + 'section_label' => __('轮播', 'aksrt'), + 'content_class' => 'hero__content hero__content--slide', + 'with_search' => false, + 'search_id' => 'hero-search', + 'search_class' => 'hero-search hero-search--floating hero-search--pill', + 'search_placeholder' => aksrt_get_search_placeholder(), + 'show_meta' => false, + ), + $args + ); + + $slide_count = count($slides); + $section_id_attr = $args['section_id'] ? ' id="' . esc_attr($args['section_id']) . '"' : ''; + + // Swiper config JSON + $swiper_config = array( + 'effect' => 'fade', + 'loop' => $slide_count > 1, + 'speed' => 600, + 'autoplay' => $slide_count > 1 ? array('delay' => 5000, 'disableOnInteraction' => false) : false, + 'pagination' => array('type' => 'progressbar'), + 'keyboard' => array('enabled' => true), + 'a11y' => array('prevSlideMessage' => __('上一张', 'aksrt'), 'nextSlideMessage' => __('下一张', 'aksrt')), + ); + ?> +
    aria-label=""> +
    +
    + $slide) : ?> + +
    data-link=""> +
    + + <?php echo esc_attr($slide['title'] ?: get_bloginfo('name')); ?> + +
    + +
    +
    + +
    + +

    + + +

    + +
    + +
    + +
    + + 1) : ?> +
    +
    +
    + +
    + + +
    + +
    + + + +
    +
    + +
    + '; + + // 图标容器 + $icon_container = '
    ' . $icon_html . '
    '; + + // 文字区 + $text_html = '
    '; + if ($title) { + $text_html .= '
    ' . wp_kses_post($title) . '
    '; + } + if ($desc) { + $text_html .= '
    ' . wp_kses_post($desc) . '
    '; + } + $text_html .= '
    '; + + // 卡片内容 + $card_inner = $icon_container . $text_html; + + // 包裹标签 + if ($url) { + $html = '' . $card_inner . ''; + } else { + $html = '
    ' . $card_inner . '
    '; + } + + return $html; +} +endif; + +// ============================================================ + +// 模板标签:友情链接 +// ============================================================ + +if (!function_exists('aksrt_get_friend_links')): +/** + * 获取友情链接列表(已激活的) + * + * @return array + */ +function aksrt_get_friend_links() { + if (class_exists('AKSRT_Settings')) { + return AKSRT_Settings::get_friend_links(); + } + + $links = get_option('aksrt_friend_links', array()); + return array_values(array_filter($links, function($l) { + return !empty($l['active']); + })); +} +endif; + +// ============================================================ +// 模板标签:站点 Logo / Favicon / About +// ============================================================ + +if (!function_exists('aksrt_get_site_logo_url')): +/** + * 获取站点 LOGO URL + * + * @return string + */ +function aksrt_get_site_logo_url() { + if (class_exists('AKSRT_Settings')) { + return AKSRT_Settings::logo_url(); + } + + return (string) aksrt_get_setting('logo_url', ''); +} +endif; + +if (!function_exists('aksrt_get_favicon_url')): +/** + * 获取 Favicon URL + * + * @return string + */ +function aksrt_get_favicon_url() { + if (class_exists('AKSRT_Settings')) { + return AKSRT_Settings::favicon_url(); + } + + $favicon = (string) aksrt_get_setting('favicon_url', ''); + if ($favicon !== '') { + return $favicon; + } + + $theme_mod = trim((string) get_theme_mod('aksrt_favicon', '')); + if ($theme_mod !== '') { + return $theme_mod; + } + + $site_icon = get_site_icon_url(512); + return $site_icon ? (string) $site_icon : ''; +} +endif; + +if (!function_exists('aksrt_get_site_logo_dark_url')): +/** + * 获取暗色模式 Logo URL + * + * @return string + */ +function aksrt_get_site_logo_dark_url(): string { + if (class_exists('AKSRT_Settings')) { + return AKSRT_Settings::logo_dark_url(); + } + + return (string) aksrt_get_setting('site_logo_dark', aksrt_get_setting('logo_url_dark', '')); +} +endif; + +if (!function_exists('aksrt_get_site_logo_text')): +/** + * 获取品牌文字 + * + * @return string + */ +function aksrt_get_site_logo_text(): string { + if (class_exists('AKSRT_Settings')) { + return AKSRT_Settings::logo_text(); + } + + $text = trim((string) aksrt_get_setting('logo_text', '')); + return $text !== '' ? $text : get_bloginfo('name'); +} +endif; + +if (!function_exists('aksrt_get_site_logo_width')): +/** + * 获取导航 Logo 宽度 + * + * @return int + */ +function aksrt_get_site_logo_width(): int { + if (class_exists('AKSRT_Settings')) { + return AKSRT_Settings::logo_width(); + } + + $width = (int) aksrt_get_setting('logo_width', 150); + return max(50, min(400, $width)); +} +endif; + +if (!function_exists('aksrt_get_search_placeholder')): +/** + * 获取搜索占位文案 + * + * @return string + */ +function aksrt_get_search_placeholder(): string { + if (class_exists('AKSRT_Settings')) { + return AKSRT_Settings::search_placeholder(); + } + + $placeholder = trim((string) aksrt_get_setting('search_placeholder', '')); + return $placeholder !== '' ? $placeholder : __('开启精彩搜索', 'aksrt'); +} +endif; + +if (!function_exists('aksrt_get_search_hot_keywords')): +/** + * 获取热门搜索关键词 + * + * @return array + */ +function aksrt_get_search_hot_keywords(): array { + if (class_exists('AKSRT_Settings')) { + if (!AKSRT_Settings::hot_search_enabled()) { + return []; + } + $keywords = AKSRT_Settings::hot_search_keywords(); + // 置顶词前置 + $sticky = AKSRT_Settings::search_popular_sticky(); + if (!empty($sticky)) { + $keywords = array_values(array_unique(array_merge($sticky, $keywords))); + } + return $keywords; + } + + if (!aksrt_get_setting('hot_search_enabled', true)) { + return []; + } + + $raw = aksrt_get_setting('hot_search_keywords', ''); + $items = is_array($raw) ? $raw : (preg_split('/[\r\n,]+/', (string) $raw) ?: []); + $items = array_map(static function ($keyword): string { + return trim(sanitize_text_field((string) $keyword)); + }, $items); + $items = array_values(array_filter(array_unique($items), static function (string $keyword): bool { + return $keyword !== ''; + })); + + $limit = max(1, min(50, absint(aksrt_get_setting('search_popular_key_num', 20)))); + return array_slice($items, 0, $limit); +} +endif; + +if (!function_exists('aksrt_search_history_enabled')): +/** + * 是否启用搜索历史 + * + * @return bool + */ +function aksrt_search_history_enabled(): bool { + if (class_exists('AKSRT_Settings')) { + return AKSRT_Settings::search_history_enabled(); + } + + return !empty(aksrt_get_setting('search_history_enabled', true)); +} +endif; + +if (!function_exists('aksrt_search_suggestions_enabled')): +/** + * 是否启用搜索建议 + * + * @return bool + */ +function aksrt_search_suggestions_enabled(): bool { + if (class_exists('AKSRT_Settings')) { + return AKSRT_Settings::search_suggestions_enabled(); + } + + return aksrt_get_setting('search_type', 'default') === 'ajax' + && !empty(aksrt_get_setting('search_suggestions_enabled', false)); +} +endif; + +if (!function_exists('aksrt_get_search_form_type')): +/** + * 获取当前搜索类型 + * + * @return string + */ +function aksrt_get_search_form_type(): string { + $type = sanitize_key((string) get_query_var('aksrt_search_type')); + return in_array($type, ['post', 'category', 'tag'], true) ? $type : 'post'; +} +endif; + +if (!function_exists('aksrt_searchable_post_types')): +/** + * 站内搜索 / 索引 / 推送共用的可检索内容类型。 + * + * 单一数据选择策略:前台搜索建议、MeiliSearch 索引、百度推送都读这里, + * 避免「搜索含资源、索引只含 post」之类的不一致。 + * + * @return string[] + */ +function aksrt_searchable_post_types(): array { + $types = ['post']; + + if (post_type_exists('aksrt_resource') + && class_exists('AKSRT_Settings') + && AKSRT_Settings::cms_feature('search_include_resource', true)) { + $types[] = 'aksrt_resource'; + } + + /** + * 过滤可检索内容类型。 + * + * @param string[] $types 内容类型 slug 列表。 + */ + $types = apply_filters('aksrt_searchable_post_types', $types); + + return array_values(array_unique(array_filter((array) $types))); +} +endif; + +if (!function_exists('aksrt_get_admin_settings_url')): +/** + * Build a quick-link URL into the admin SPA settings app. + * + * @param string $path Route path without leading #/. + * @return string + */ +function aksrt_get_admin_settings_url(string $path): string { + if (!current_user_can('manage_options')) { + return ''; + } + + $path = trim($path, "/# \t\n\r\0\x0B"); + if ($path === '') { + return admin_url('admin.php?page=aksrt-settings'); + } + + return admin_url('admin.php?page=aksrt-settings#/' . $path); +} +endif; + +if (!function_exists('aksrt_render_admin_quick_link')): +/** + * Render an admin-only front-end shortcut to the corresponding settings page. + * + * @param string $path Admin SPA route path. + * @param string $label Button label. + * @param string $class CSS classes. + */ +function aksrt_render_admin_quick_link(string $path, string $label = '', string $class = 'aksrt-admin-quicklink'): void { + $url = aksrt_get_admin_settings_url($path); + if ($url === '') { + return; + } + + if ($label === '') { + $label = __('编辑此区域', 'aksrt'); + } + ?> + + + + + +
    + +
    + + + + + +
    +
    + 96], $args)); + return isset($data['url']) ? (string) $data['url'] : ''; +} +endif; + +if (!function_exists('aksrt_get_avatar_html')): +/** + * 获取统一头像 HTML + * + * @param mixed $id_or_email 用户、评论对象或邮箱 + * @param int $size 头像尺寸 + * @param array $args get_avatar 参数 + * @return string + */ +function aksrt_get_avatar_html($id_or_email, int $size = 96, array $args = []): string { + $args = array_merge(['size' => $size], $args); + + return get_avatar($id_or_email, $size, '', '', $args); +} +endif; + +if (!function_exists('aksrt_get_about_info')): +/** + * 获取 About 页面信息 + * + * @return array + */ +function aksrt_get_about_info() { + $public = get_option('aksrt_public_settings', array()); + return array( + 'display_name' => class_exists('AKSRT_Settings') ? AKSRT_Settings::about_display_name() : aksrt_get_setting('about_display_name', get_bloginfo('name')), + 'bio' => class_exists('AKSRT_Settings') ? AKSRT_Settings::about_bio() : aksrt_get_setting('about_bio', ''), + 'avatar_url' => class_exists('AKSRT_Settings') ? AKSRT_Settings::about_avatar() : aksrt_get_setting('about_avatar', ($public['admin_avatar_url'] ?? '')), + 'contacts' => isset($public['about_contacts']) ? $public['about_contacts'] : array(), + ); +} +endif; + +// ============================================================ +// 模板标签:社交链接图标渲染 +// ============================================================ + +if (!function_exists('aksrt_render_social_links')): +/** + * 渲染社交链接图标 + * + * @param array $links 联系方式数组 [{name:'GitHub', url:'...'}] + */ +function aksrt_render_social_links($links = array()) { + if (empty($links)) return; + + echo ''; +} +endif; + +// ============================================================ +// Head 输出(Favicon / Canonical / SEO / OpenGraph / Twitter / JSON-LD / robots) +// ------------------------------------------------------------ +// Phase 7:上述 head 输出已统一收口到 AKSRT_SEO_Head(单一装配器, +// 见 inc/classes/class-aksrt-seo-head.php 与 seo-output-matrix.md), +// 不再在此用多个分散的 wp_head 闭包输出,避免重复与来源不可追踪。 +// ============================================================ + +// ============================================================ +// Sitemap:兼容历史 aksrt_resource CPT(v3.4 起资源已并入 post,无需特殊处理) +// ============================================================ +add_filter('wp_sitemaps_post_types', function ($post_types) { + if (post_type_exists('aksrt_resource') && !isset($post_types['aksrt_resource'])) { + $post_types['aksrt_resource'] = get_post_type_object('aksrt_resource'); + } + // 移除内部隐藏的 banner CPT(虽然其 public=false,但兜底) + if (isset($post_types['aksrt_banner'])) { + unset($post_types['aksrt_banner']); + } + return $post_types; +}); + +// ============================================================ +// 模板标签函数(供 index.php / single.php / archive.php 使用) +// ============================================================ + +if (!function_exists('aksrt_post_categories')): +/** + * 显示文章分类标签 + * 定义在 functions.php 中,确保所有模板都能调用 + */ +function aksrt_post_categories() { + $categories = get_the_category_list(array( + 'separator' => '', + 'before' => '', + 'after' => '', + )); + echo $categories; +} +endif; + +if (!function_exists('aksrt_estimated_reading_time')): +/** + * 估算阅读时间(分钟) + * + * @return int 阅读时间(分钟) + */ +function aksrt_estimated_reading_time() { + $content = get_post_field('post_content', get_the_ID(), 'raw'); + $word_count = str_word_count(strip_tags($content)); + return max(1, (int) ceil($word_count / 200)); // 假设每分钟200字 +} +endif; + +if (!function_exists('comments_number_label')): +/** + * 评论数量显示标签 + * + * @return string 格式化的评论数 + */ +function comments_number_label() { + $num = get_comments_number(); + if ($num === 0) return __('暂无评论', 'aksrt'); + if ($num === 1) return __('1 条评论', 'aksrt'); + return sprintf(__('%d 条评论', 'aksrt'), $num); +} +endif; + +// ============================================================ +// 模板标签:从文章内容中提取第一张图片 +// ============================================================ + +if (!function_exists('aksrt_get_first_content_image_url')): +/** + * 从文章内容中提取第一张图片 URL + * 支持 标签和 Gutenberg wp:image 块 + * + * @param int $post_id 文章 ID,默认当前文章 + * @return string 图片 URL,无图片返回空字符串 + */ +function aksrt_get_first_content_image_url(int $post_id = 0): string { + $post_id = $post_id ?: get_the_ID(); + $content = get_post_field('post_content', $post_id, 'raw'); + if (!$content) return ''; + + // 匹配 标签的 src + if (preg_match('/]+src=["\x27"]([^"\x27"]+)["\x27"]/', $content, $m)) { + return esc_url_raw($m[1]); + } + + // 匹配 Gutenberg image 块的 url 属性 + if (preg_match('/)td}SCP4P7ly^u9Fw76l9P#^V(+u5vB+&5y_n+;9s~OeEtI{fKxT z#>+J2tHbZiPRDJE?scq_{(VYR6I5r&Ga{o1HyE!Law)PTeM)YBh(=yfB!vhu?pY%< z;Gc=16;FbaHZXc5ZIdx^)}I$ER9j z!H?d$bKz=v-|of}502!is;pBJ1ORJIyhYx&s z@?)h^bOj=oi2Meg7`!eKSyPhV^+YKENR-Z6EvK!<+N!GFdGCX_-g(d6kSMib)B4@J z@7cS1@8->0)^FInZvFb%nW^?jyWJjXG#h!|a938PvDQ}BT5EU`#e-KBi8pt~8wWuO zK5P($35pSl<_MrN*4nN*rKe+IMu;=EdwHBX>NLY^B30@rKwES z?G}k&YcyQ(I-L1%UsFUiKXV`Kz2j`W%)pI1gcqI(-hsyE8mzB`!a!>VVi7t6W2%FP z4`6U{1mw;)0RY&&dtaU=i+4JSPD`8KTahT z1mZ(ApQ!*ZvOdMbkMNoltwIqY4540Rr&)Lvx-XRMy+@z^^0O~Jx9`Cx4;h zAAIlD<&%Jeq?Q7aO46*2EAuDcdF9gd_9yn=_x$wej`sTdo>{kJ%jQQ8ANjp2S3f9= zMU|mW6dAIlq`!c{{rco8^Ec8#y23*(m#^BO?HQkR(~&m7aXbRb!qX`t)!p@Rz?LwAVZqGG)oVIC)a|%Uf{2xL*Rv?PD*je* zJMj>PlE9H|2nFpp+(a;(;y%OZ&_fnNWf1`sXg_I14ka0jrE!&p7RVKGxXn0JqLaLq zH5#>gquFk^Mn>D?6H~LZ8#Zj*KDTbu_{3DZJ=ScEWLXn19aN^fS}d;&7MA-LZwxvs z*H*i$-Cj`?##kUiji^xR6cd%!Yv@aG#hCl_HIXF)AQ7Fl1OOVr5|Y89pe!339~ocn zc9)ixUw!?JXP$m+rLzi(Eegrmsv%7q0w@R)iHM4*Dvh;eJx^7mh5*KrH6CX3-Jl4U zmby2tEu;x%d6H&%t)9=$ji-qs*6TR?p|v8iGgD1lnuVqQ<_+T;*Np;!mEd5=Y#@=# zHQ^86x^ZJZo1JWZaOCFtx%8fG4JchHghE79mU*7O|H0vF*KY#A)YOJVr#G+N%5vRq zk7}x}-dyY#srW*4)FcZQz#2p-dfnN{vDe={_r!xQ{Lw%0GiU$7-(6T<$!iU=mdNk) zDUMU2ifoPRJ%Kxuw<7WtmY}t&*K3K^E-_J6)#WSKFI>3%z3=_N4X@c~?bxwn$F^md5<6`X_n>JW zy~hw3!NCa!PAKp}AyRCqoUuo;#_rzIUK+0vLaUz6ts7ZbsYt1Mv(YYSY^;6t%I!g= zMrz5m>q|2ewVO9qx&thVa^uFanTeXQRxAmHUJ}NjE*6Jqf}24X-1YK<7kLZwtsrMg zdRy!nM{F(q@f9JewL~0n`Bi_Y5EPK5M5(f@Oi8u8)@(NVy+SAH=-5c7)4hH3&e(Vp z5eI{c#z*T=>v#J~tJY|f3Q(xhR0AOp5|PsG6k)K4Xu+um0$8#|Q34SWmPNISlqA|2 zbjNBHO)YS)CysbrfXAymIP!rQHi>EE@5Ma(Xk40Qpuj;qkTFE=@E57Qbw&vVUMRrI zR0j_pBp_ofAfVE2k&BDz?!W)R%22N}NOUqNV1AhhTt%BR%!Zj|9B;rSSCl`k)Pe%b zA(zFlCrB6tMUxZ++&4zYvA<|!RpXXNR_(X*TKKYp< z&%_~vL==%ec(LY~#QMLM{9&gNOaTA@Yb`em+;IQ^av36}cvo5nYcNRw(dIyiiXgEj zTj;^fj&MLWh?!_pvqrU?YH5`w1nz_*L_lpxSweKneSF-mJZl6~ zS^^CS>|y~BcpDuK$fYg@nQ3GNx1bvo<5dC<$54bmLZj^OCDJ%5lDofZs=*<=h01#zRg8v6O}_KO9e4 zwhvIz%_O;9DgS z+2K?4#QYb;=rW|K9liZf?_aOUJ|8+JYM(hFbz z+E4!6g^TA89(?7(=>v-^^Pu}lJwxS^CIEg55GFSOAllMcWkILebd$=#2e1Fz58nFL zgU@{Vh0j0#(377%a`=OjAG~_y{K=xTg0)nqbwVXR27v6K4+?9Ov$p6S_~EY|eD`~g zKK=Dao_&7D{s#`e^TTr={$O$bB4$OBC6J}M*#cv3UHHL|ZXDft&zE-J^VCFhddt3N z=Qi&=e(XmlkA8c8{)9>Tx|S%+T-~qqRN(bcEE0Z`)RDIpV#k3~YV2VpyWzx5u9NtJ zWr2olt;7h!=97P9LGjJ_HxeInL_q#3k|Guv2y#0ZtRp`YNHU$?6%9#woO5CC=+pbb zxF zDC<1jqVr(nkMI(ri7){8WIGV?CNn(DkTzmY$uJ}0JHku6>j}q*A&M@8hm(v>oTiC~ z7U#S}l*2t$vK?h10~+K6*qLjwpSB%#O9m?#iLq&T{OhMIL1dNB#n z8%s8Ytl$h{y=FNh^$L#D9~C#lH_UPf>%!`>k9x4LGr$*k%-)kuyN$x^4AX1Pn=csr@wP6a^)z3h3w3j}0+S_+J@Rb>?_j@=;D+NFdX+53a; z;Uk9*A2}piGBGi+d(ZBDd-reKzJ2TFEnBv3nVFd$8y#shnvG^7&;5p}#u#JCoyBXc z2|B0{2$cW?4C*hsI6TdBm$f9$eXz?A4~3DI8f&M=^V!L|u{0P|_4>%@h^dT8lBCxg zRHn@HOr=s3XKRz1zL!R>`s zyQNgp>2xQj$3{jQ^S77gZ!hJwwA=5R!sPYbInGwA=@vI-X_{s!`SW2IOgec}E(TLd zge{Jo#H#a<1C9a^cq<502pHouSNISn7@|Z03xa36NDit9ghU>YajCPunB_^YyZXtA zlOlvxw8R<&eDI0JmnQUigkmlb$=8phy*bPxPmGoVMf*{&vPOtmdFWKK@ehK z{QaKOKQM34Rd=!t`9iYUXL_w8nXe5XVtVL_HL0P-hhx4}$kZI(m zkyqo*YO-l(M`)sjqfL`HY@XUgTfbnD(1IdDOK3>}C?r>mh2E8ik}hwXII#6>0=BI( zRn4;Jh0ziz&J_53RK#XLSd$eE&V*qEAZZPnm$C3kz5xEz8s{awr1(54B860h@W3}1 z;XsU0uq1St!~A^&+Ry_TUf9oC44l6OiSZ$lrXZ0)Ohk;`c0-aa2wiYohzh&-8~N zYm^1lNqY=!dFA};|Ms7~J-h9Z7ryY5Kk-vPd+o}#BL`nQck+YVOIIxPbkOEPEVm39DMCxAAJ3r_doO1=fCuY`=0vDu>LR2kw3FE1Ng%8);2H^qD`ldFNw?-uu0CC%@HQy@`3Hm9~;*4yeNs zM>1~_*NEu_oEHF({4h!;u(=l>8{CVCLu5Auh?B^=Q%FDIz+O{iG;$)0=)>j_zD@2~ z@L1BUM7qPj`q2@Ddh4BODIatY%V||QgFwpOs#@b0dQ1KtZqXW6j zrR2?-0X!T@KY2coadQ`f#w3hm%0R?ow-pOUPMW3IOC_^|xGLBi0}o@gh2bmq{*i=9g=FZO&~mtq z_`)GqAKx3mkUS*HP({CpwhX;!%{z?##z^bG{Sr-B+c4fuD;j=}AS?|aAO<%g32f*- zeJ&V8vxYl{6lhDs*ZWHZ-pT0MqWO1sHR^$?NU5tjjw$5@(Nli7ul%63gb{&veX{7z zgj0b2>{us#ihLyp8DoG9*b0mRC|wbUR!N?vS*?-fd865CkBraEuG_eN!}?7dr>ECV zPEJqH%uLR#8yRh-DG)$WLAPg?mWwO5d$&*BURql1tPc9Us;o?9h%6{Tg-R#7mM4vx z0v|E~0g&}Em-X{d-jKM03<4Kma7;q{Gq{kAELqNvxF1nz>=)&o<=P`pzxdrB{sAH$ zIq>1H{2%|~Z~TwHu(+^Pm6Rk}B}%}MV*qi+c{naYAmZvX00N~HaH#~aG4N-OT1qs| zOpngZjafs+SVT}t5%AV=B7p!(siLfw7FX-_yk1YOrHW81Z+D1-FYq>x0LU1FWI=1V zJ>T#4RyV9`Y?^B-0Ijv#RL)qFAOgVQ1BX^u`vCCpBM)PmwI?R;tX@BO;8>>B3QZ_% zxMERvPO!Ds9mwnVej+lqsOQOG(D>i}$?N~iUw-i~{{{G$zy5EoT)PPXj*}iMLoiLw z-ia0)x9r;m6a?T_(SQLHOEH1Vy(y)2lBUXSnCgC7?X152*4uBt^|re>OR{_J*|lfS zp3Pe}ZQHSP$Btc_Hg0egMy+NmjSh~fj5VgRl`+;@W-)@>Qc}gR!)qeO@De?_861ej z5d(5oGU91TEJ{mMA)rDeYwI;v?GD?vOq8XW9BcFov)U=FA|PzE>c$$S)AmRMfX%W% zk)c=c-t=ELx)3G_*n#p^xZWSD+xXjvRGmZ=Z@RAmZ+jMSF1f(?W3*%FQN;=>M4%k zc5(0}0U-zFI7$crM!AfiO9+6k$s%%TZitr`gh4ZEl6!GIwx9kLit3Xl!L=_K$ zTyfkQbwXBq*SJ?3-u+oCn`yV~A~lq+4q&khpIj$vAx)^=Fs*tq-L@Or)!Z13G;FJ8 zlazF7Q*BWpfg(z*b=yiJkx~{=p$Y)N@s{NbMUFk20@K)Zq0dr!jDdjUHhV!g1E+W} zL;~V{tD%KNlh(l7>$sy*-18B^RW3;4!;6YyvnbuM*1IhCr6u4aHP;4GqCl)xjMf6O zM1SOqkiuyzBA`DD21H+n+jNZOZEK=fSLPfho6KX=G z(Nmx5b|BZX^ePC&!bMT|@e{%G#o42l4wvV+;rHS89%cbH#6ca6?uL#tr zvmDC7;LdG;$(>Jq`SV}>Qgd|T;CpX;{MPrcUp!Ts9;TV9)w~oEd5emKI!V&h_B!Rt zYC3(-Q!oA0zQ>=rb>r&MSHE@X*jwH19hE1lmRX$uAaw_18|$`ycHe!^&WvvuuaDHU zJ$3wzkKX;w>sJpN)z_&eNc{mj7X0oso?2|lK`1WSTrV!3vm79M25{sM(UAp2K!cZr z5M=|z%;L5K=e%_LEGBaN4=g&N0}=&?miS8NuA^|m`zc9S_{IZ@9nN}07=TA7@j4UB zCr_dxMi|a*%aCYT3H|fDxr#z(NoR26PaJ;pM+u?g=SG>Jcq|MYk!Lw%6u6B>%XEDtP6}#h{WKHh8!378Ka-Q zT^H*HYqK6eBy8iy#Z?#_#c{7WWEQl;DKGYyzyUUfOy;M&f~tuQ4gCXRPhtem4jH{U z5duK4{vq2Hw9?B1g{}hAo*d_dvL2wwye{$V$mAEm7g#|t zIWT+{sqpGe%v=o4JDSf8eGo7-)F~1<1L;hFO}-z#R#71;LcAK2Ob`~SB;dil-*0*z12{pbJRZ-4ID#|K3T)?kug zgdx~3kw~LsU95NR+!1H8MYMs?veYXCB7*=5QER1?Rtkx$wj_3SKvHY9ywW>!=4!2; zx0?0&g_U))>=*-T?wznek*SWm)>#yYufKFSIIu>Wg#RHrCv6x$aots;u%_ zhNwUP(wAO){Z(s=fA#ObnP-zn51y;lYAZ|KGp8?}xKys-BqkY>1#5ijn=ID>SRQUw zt7Uf<292iq8-M<}R(bm0{Mx@f@yRFicNYA3mDXDOUG9P(A#n3Wmi&KKNcbtXCFGs} z5S3DVsvs&vR0_yV{Sp~t%Cd~_T{kzoecRSO`}XeKxog|D?Hf03UN<|_YPXxsX1!KZ zI&s9Ss?u8PHt4kE1KkMcaK6SPP80(UfNq03Yi+oS9Ts9dwGLje<*z4^BM%{Bk|<-X zsVsqBS}qos2OBqx)H5=cb%6OS78_!mBFPvCTH}buw*{}^Qy>zbENt#w7_+7)gQ>cnkHOV$`Sk-oapUFog@;-Fhh&5Q$?n>X(y zN!nZKC-uZsRid-?8`hyBYl%I%Y@G}MDY4TC$twHoSe{ z=*gp34!w1(T_64MZ2h-hD|Mr$lp}6r_akr`MNR&#^zM=ij(1- zcRR?IHj1L7_5*ZxH1szGxCj7(AsP8F9HJ0^_}wD{yariT%ZST;M1cu@bpnL%QNj)u z38oWo`0YV8_=5X@qIk;B9Tz3om}w#C0DP{LN#NzlFH%4t<<@-w0Vr#+GTeRx+OJ~yKN6SC<(=$sq%Z2MYQ3z-aS?&nQLB32aF)mJefe6T0 zYrz;IOO+vGtT9woWQ?2EK&e|LPgEa6czy}C6G69#h}N1kLrZw&Qn%Zwk`%~y46K!S zz(!V!Wb{0JE3irSBnfaE1#y8#r|!5OjY?~6N;A7YdF=TyOsVRr!*BM_9$Tq3XudZn zeLmJ#Sh|)u>j6MO7N^?vNADTym7U*t^U$K{XQ^9^<}odXBn%rj6jE@PyfO|!X=MHP z`Wt>CAf*s6nR(V9Zp6yNL8D)kgbGnBMb>mz&0^75zxVmief`0wp6adkk9_q0Cr4ku zdHrm!Sb>^Obb^VtR%YCsC@KO(rN~KzQYuZs49bNCFwN~xyzuBQR|M120XO6t_-5VzlbbEK8 zrZBB3ooHK>-3n^!ckX>@&z>hIM<=ISjg^IKAH4C~pB(x2^2!xR09>-h!7=}mGlhiF z0agOX$iT*&>jlCbR|qh?;Xx+D8pecR)vMQ-h zNKQ3MZia9+`jG?yM68BDcyTc#_6B@N@53yz2mq+$R$)M34l>On@1o zZJ5iS&@0l=~`oP${V3b=rW>PPXZeftz5 ziR@sc%xo^aFb5184HI~YlZ;pZuaGE8d@zFZ0E&jFB^+Yf#bl49J7m@V8+>5s6@+6g z=`gGrqPU4Q-V8wUFn600HfC|6dAL7f+M>jVGaZP^ni8r3fC5lTtGt%xS(0aIo;TX9 z(W&W~O`A4s+PHb$`t@_`*U!w%jZaLsN89x}D1<=?-Cot{RSS!QTerKn?sS$qWv^$d z%2vi(GL1?rRHy+IxpH~{BDWKW1p;HqFSK>#7(g5cbAgy2uML=#({}P#F7#H*dcP%k z66@xhED(VUQ7wVNa^H@`Uj6pp`3HY5No%GWY}~NzpZ)WH@#1q&)EiC52Ww4PR(>_O zw_M1|0w{&Ygb1XRLWNajjdcrX-EJ1jkajg8S^)yNb4!)-vqe6VU#kj$h!)6{X1UX~ z20Gndt5KVr7y$sEV1Ym&_dlH|vZNFesT1cqqpfW7TrN}c)>>n#vMfj1?N830`^MM4 zaqQTkde-{A@BQH5hgVJ>yHIb|Mn{`V%f0WueRZM8vn&H5wu%%fGcjhc*4SE}b_TW> zto)fj^5_eXuRD71jd$L9^TLG-x8`rItakeSficDv2x#qAA~+fF2;RDk6;I;YC<`*A zE8YxJy!=^0dneb*C0exG(L-4vQ&xjw5SyW$J9h5fvv>ENT|2gK+qrAk?Cgf|@zHj> z)oeDC)R%-<3sq&Tb(`T@UxthbfJDlL+Fr9b_907B+a0ih-y+ZZDOcCR`R=PQsF&2>?ftE;_UuK)sL?c&1n)b!N! z%w$!SAo&iS4CUeZ@?q(uj4}>QJ&7oSE_@~X;*c(hQ~h@t+W-lMKqHf$h)iXaR)|9j zD-h87)kD@0jW)A?{D1tP|I=Uj1+BF)#+?9dtt)e|58eCdD{s8Wm&>sH7(3}x6pr2s64D540% zAeU#|AaHhc;-Un8W)@C60fvgShm*V*il2?0LyIRIT5TjWpq zevgzr#&qKkmMB&kluKPjf+wO|Adb!yV`-o$RaK!O&}bW3HPS9-$6>>$-7sOsMqs3& z^3>|YwV*7a1+cE_$|8aC%@Xk*N=!dzBmFxsb%;^NT$2b9RlOx30JjLq+CjU~xO6r< ze!TpXe{6c`MzwGawNixEO`E&+$mgJkImoWod3xgu2dOe-tTl#AMb_G?^6Mp7tJYW^ zB%pB4@esyF1XUT6bs$F|@_gKGN{p~jnEmgfN zO~|@kk|;bkLcikUBJK0vGk`_ue1O*>6bB%s6(qqO5YH!b{>MwTwPcM^fQ@$6#6kb! zxBk=Ld-)HxKKaCpPdxSc$Dexf)QLj}KKO$x7mjp03z(-W%TOo2_ydUZitb+mFs7(b z>2!J>n)2)izkl|#cvgRiesR8_`Q zSRc(=y1(@C_kQ&k-`w)hXMX&rKL4lw(&aO!j{oR)uAMnJ=&n*t*(^y%b1H6}`tUa{ zp8xRvhktDA#=WD>(Pw|`XSeTu{J^{4x_tVrUhg{PO6i20V-o+%zLR5}=sOe~8ObIS zKT3vLwm!qr14|MOTL%#}Mnb~DDBT-LJlmrXfQj~kd<(z_+69o{3{RA>AJQNRbwI*? zf1_U*;=hT7ZWuza&mHOodlM;D;Go055zjQ#A207g)WuWEcw>L=?WvRNkXB@$?Hb^V zhZ0gD-7ustQHF>+M*tKT2f5^WjG!no_Rdz(U33N(o7lK9J;8e);B=4gFmN=^+8Rlm z zpwUP6UYu7#PD8{M005$Kp?rubMv>h4e%Q`|!8}X#%VXZv(Ib)QvQJ zHgDXzW!t8?^)pjbGm}%(lM~b9YntnlLNp1#P zD@>G1b&}{b&7Ela=@;Yb6Wp}CCF7%X@)I)v2q#b@t*@iPzpmp2X`OS1ZvtLOgEANtB)`OAOp8{hcG)~y@b&33I` z(^><8vBp|sjI~78xFTVfVsHls5NKtLt%|Z30LGZAER7|q!1#O)DU=QB5Yf%f z(HgKN>`N3Tc{-hs0>CVc8bj7^xF>SPNgo2G)y>`dp$a5) zcJ;)`6DzCB0C3-Z_iByHE8Qf?2HoOLuY390oqk~e6EX!j55xYSQ??fTSrvdtgF(NZ zXSICvSAX-pmw(j$iI?vG6F>9Wlol>sJa^{YnTr?BU%PqZ&f=ZbPG?Z``~7aO*O%;u zLgm9k#qs@r>*XWsD9#^pG_G0q*DW|w8XNUGSgME+!CHIf?3vSNPXCv0`CG=@WB1;- z@BaJm+qHZ5<}KT{Y~C_9KGtrx>h*fPUaRTM5!xM}OBRgf)C{r#@l(uPW*Aq4iCyH9 zfVHVl!6eZX9}yY@PSC&~z|MRaa?AV{1lRVvIcBGVoEid^{T3}G_6h6i=rf=s;F9xMw;hU zRr#JGuXhsbj=}Y?*pfVxBunK799dcK42KxQRvqHu%9Ru)%`sf+ov23)u~+n*jdCA6 zA(v>ZOm+IqDFE=rQG&9`0>GAS+ZDp%os~=}0KBz0AZ!5e{IH*>rRezpJhj0{nT5^u zpD!Y?UZq&FQAPbdE<`?vhTXdkZS!!yvVrtCJjnMiDoF2ug5ge&Q zn%Oh~g-99f)XnkS^p4Yy$Fzvgiyjr^u8Peo4k?ZFx0KX5Do_A@DFhVlOvmv6O6HBso3}P znB@`SIr6o5l*sV;vKk+wFwQ_kR2pO>D-5^!7D5#3p_|y5aA2JO2fU#QkW&TCK;d2t zjJ?i87Q@pGLkE~#uIHG}zF^4)mn8lOB|_lbFoIiPst|3_E0RgI zoH%*>;QKFMJ$GnnX`V7vS*BC%3h_w90DMl&Q%@uoqr|IME0S0cWyAW+Utz zPpZeEUI;0|+y!TL*?a}49of#nxJY^=wObYl6B1Q*^V5UdL#^;+n_dyJ8ECJzi=R28s z;#^^v&MZ#AO@}F^q0R{1IC&9WNpYT#f~ZiqQofG?QMe?)G7nkZa9KV;JODztJ^EdK z8KQ9TX%LT#?-gtwCsG6r@qNJZhr>J*NGn`+w~Ge(He-JgO-}kp14SLfVMa4o0OTgo zL`3;VygBnCj3uVs5I2K}gc*Wib`R@mu#k~>L{KssZ1j?$@|ENSD8aPsUjL(i?Em`L z{`z0twsXoCaTIW;3%8c4h2`?j;$U&1x4bwQ^ebb?xC7m-U+AC!{3&vZ=bnNmac7#6 zCj_~pY~6AMaPdD8xV0=U8{!A(7pVA)#C{1J3&{<3BJNiUmWcfFUIGL#L_~xHZeI%j zJf3oAA=ZF{*@^1$os0kT|NR%vT%ONri9*%u^;(UIXFm7TqYpiD?|pl>Y~8wUZnn{E zHXHRkuO~@D0LGBD1}qse&f@7*ee<_}{|B$U`qa};Y}>JI{p`%>*hszJsMTtu6cAct ztu@vf@Tp-yK-7qUN^|)KA}fklGQTpKoTDZniCsOn-4BK+%dDSO^ zlGI&hEf7&vS+dqz;~v5%1|R@RdOnyhpXUn&l|iQkwT53LuMdF)46vM510w_W_3Zc< zc!`IKW5GZrj*MeBO&rs>=exZ*5#7=fiSt4)oj{aj1>n!OBum!XBvD$U^}E3gc`_Gx zA-GTj)G&^;fW#%=1Z1oqFrroptg(h?UX+q>M*vPBVw?^OkToivjie&3l8}RS^ zzyA0C{?GmVzxzMGGoo}!?O*v;=i*W;tEEUT{o?53JAsJ6=V!!nCUPM-D!xbfLWHCg z0wF3O^dX&BD9!s^>&SsgpeTJp*y*_a zUj)jCt&kN9P7sP#y?*&@{>jJHpZF8g%eTwL>sl)WMI>w5Wwer6aITw>h^!&Qr`Qt_ z8L~uGMb?1jH7^(}q8!pVBt^qGjm63Wpfbkhxgvzi7gxK>Rgxls_1y4ackL3&h#e#k zK_R@Xm?FhVhrl}oKu`)5@=}+X4apNPjwUrKR^jmL#hIh4S>4R{1{KM=?TAq12?Ypb zVREGY#NM%9)&1>P53N*#G|^-UUB{e-l!z&Uu92)m_#$Yi*9cA~i4Zz>M5sU~8JJ?Qe22=$`UjqW_Q$_6w_(faV;>)V>pRy^AMO_`kZPTz zpb|#m2w4(eJIST!6Gf?NAZ8Rb?z+Vs2>2MM$LNjB6AUNAwT4 z(ovKTC-DA*{SXBP#lW0nFn0JHRyeVtp^#>c+XmohlD(DWO(Z-EgOOYd7aN2)C(t2| zbOb8g^*&ZjhWeb0h)EZ?2*}}BAh~Obhhx1V_ud*skb(~uFV3JuVnF469FWAcaqoB* z3EZM5?q}phggqh65h%z?hT>lMP6PnnIC~8cML#-V2*Rc^j4Ws}TVPJsM3UjgC}0I6 z^yjG)qZg7NB;IGalZ=>)90cwRfgy-t9t)PFzarmych7m)6p3h%1)ms{^)3NO1Rx6l zQTB$s?H!(TkbXwCT~t`shA`70WVnPplDml!BI}wwrjs%H5UQ4ifdQ+;bYoBup2^}Nlq5vgbE}iw4wf@D`to=G z^XH!3x7_LX`n0%Y=2zg#LUntgTJ74Rq{2S^~TQKT+?lAPO4IIT3)6Vj*(mwSFfB68GhPa`CE{ zB_zZYwJJ<++sxp>+4*1khkx(b`Ahx5pi$4N$_)B_7lG7j^-bG0@7{gSgZJLIchCOq z+jq>&&9vL?M!nvs*HI~Q(~Z{to&WX!{p-K}8zUp_<;9hHt#QwNd-vaS-@g0qnV1+| zKQ}u%Ix^C3*6Q^vPZ5xlL6(mSKvW6=C8F~Z%`}u82MJp7rpwWa2*6kx3`%Qk(J#)O zov$@o58Ssd%Y5_3n93Lg$n*S5KlbCV{OCtj(fbF#{Ex>c_r3GRIRH{v^j13W9=mQj^u*Zo zcza^3(XMM_x=V|9R=ca$uU|TL^x(Nm7dpMws;c_^ez(_+R=c3`8=*)FjB`L_Biq1H zMdY(%SP{n?erb_A|HJ*Oly&jCCAY!_qSxqXd*{xbk390&-o1Oa@7T3r4|{*YE#q`}7Bxm*PdKG^X@KxB<`@!v-ZH57Nb zu?W=k9tb=uoe_@AvMi0^rU{I(0Hl-xjQQjTu8<7bdM&?o3=4Hgj$2&R&(1ot>Uio-)> ztBUb4_C#0Ay_r*e>|- zXas8(V{KLZ?Gf0z!ETLN(6NC65 zLPBdX^2CuVD-`fY_q`0(eQ6N^F>5?rU=3=ZRf4G84rmZ0KKM-`ze@wSR+UPCs9NdT z9*(Tr|KgAR)MHOR+_}?v=dD*y9Dj5E=H&tgD%C2>5L7&$DCG2Nt+YxKWy?XQWBY1m z*At)p>en~#+H>yInFFuBeB+aY{lPLMT4xEWgh2VgSOE~IG*w7uVZJi?riZ@rmQRegZ(jK1qt}1y%9&S+ z;tr-tB}gvUE@l2fYu(Txeo+Xd+%>LWzSsjKTn;nE!TNG&6>5Imw(? zFt)Oif+0UdWYg?B5Xv`65F{HzyUw&*5cCxpcX^Wgjn4^3aHXwGi8#Cz zP{)CDJQ2fs>!}|45;?)Z3eTEQG8mCfco<1mL`>%W5X(3V$FyKP7W`s_h{Q+%QzgQk z0X_dT*3N;BF}WWW*>q>2&-C9JO6JB!uAa=E-*uJo#Yzp|CFfT$IcQvE?OGq(EFp6WZl^P4|>^UdYerGBpfjfqZ_Qe{~hQ<=)RJDbh+ z`b``5?Z5Z_ef#(C-?wS=){Pt2H5-j)t69ry0IuPoB8>r zH;>)vLL;wf-o*idhBVf}So8qf2TqLW4cSU-RA{QAXR3ZV=vHMnj_7| z#CUuC?9_&Jvvac}VGFx=M=xGHx3s*t(&-dscTn_;L8!>!AXG^Z zBROTi$AK5IVIvZ(sJjm(BkXC;CR!nCrImKI%Zl6%u4N_JkTk9D-n(n-=8Ze=*|lfS z{ylqk&&*7ZkB!u8^;)f#Whr7xhK!-gy8U?x{Bg@nJ2#QenL8-tZs_4B75*ZE%I1p^Zz$~E02ucAbHY`Yo!h4WS zdVTlZ4Z}!)B?+TUBI`-GCb)6Hc<^H7JYR`BA$=f*g))Q~Iv^XSAYs99#;7D>0RtlL zK=@BF3{waQpfrHnV}qbFSd>&5Bx^ElM{9$baWgv(>nD=w$z*a=w`!`ES_??%f?Fis zA4E_df&$_rAfoOdl7*sGuU|Qno;jm_=4YmsZWc?o60Ol9dC(~1aJhJw=5Pkjkf{Ki z+xPowxy{qbSTY8P$QUxlClVc+LO@VMs1rjma>PY%8I`GQEmx@U^2OEFvNU5D@~B-z!M+geV(vbIi|dL(Q&3fl^6gN;9`HdHlr@U8iyt5588MJ<`n^_V#MO z3h|)vX$aszhOO`9NbSjc$9h%wKmFjysu`qOdx<1kV;2L=bmN9$sS5^6oCQYOmIl^^=c!{bkUo(-c&S%55;|(ln@(M1d)n7b_gu^3abx{^F0dCuU9` z`RL=ffA{ve-j002M=YZE-o7}ER@{BkA_Sdt_vVay?6VDL{q)(B>a1A=HK)@vvL!~si) zB#Cbg&>;d2u|$wO+ryAZVd9Ab;Eu2a`ph`TU@^Avy2-O-um-ol+IjUrpEz2Qm^ZSB zB9DA)RQMoWj_11B?pleny^H2dk4H?B`0ywB1qB5TW}PD=0Kf23#gFFvh`U8t>4gK? zh1$ql5ID!ptoJ(2yTmY~k#8Ys6NaV=qP#@pvOI^P;Ggq2;$;}E2-&4?J})O1#@L$s z!gd)TO0@+!3;aNDh1Q6lpr;t$a_2mcJpq6PfbofBuqbXqL~AI0?bVIjAfBv1xtMhyJ*z%--=B0E6B3goU1`Tj_)SfN-|LUKITMqazc-3~TcvVMG@kdDXa z--COx!BxIt{7{qpY1W$x^fSjb#VAT10_!Szu7L$evD+V*-i^oiBx!Z~z`?hU9Xo#U z`qf@6ay?gdP^w5L%-+%9Y`*u%G&9z5bje5iV zys|uh;`qq}hYr90_WP$#pS^J5Du8L!TibVS+qQlCJ$vrivwQbF_v~!7n)P}k&oim` zFm4}w4~IxL_h1F~6bN(!^2h%I5P{S+DD=yTT@t63=fCtz|LE`j-T!TV{?@|}KlT6l z<$w62SI?h6x2UzsRdx3A?E_~!m8z#Hcs~ZnYxc>_j<_>(T+Ryt+{uCpECL}Kjg>WJ zSq_XDm~v1Jx)lw82G&$yNrBa=&hsQolQhZlT5Y7=m>L_Io0*wgzkc1i^&4iU8Vxll zdMnF!Zr!|k<;vOfXFoZ6`t+@ZJN?0+tcr5bAC#2{d6FdTyL~WEmfU#)kv>zTb89qw zjTR%g*irix*sjJvAsTA|psLEMs%+R89>&JTw(Z!yd)Kag`}ghKzJ1Hqt+O+;?e<8X z*YaAPWtmdS6_QkyA+mmDzB|YWNof`LDzf0*U5QHt+62_$twNNzet4|-tyfJ_Pc>TP(pU7*#0dx2Xn2I`yqyPOs5Ahw zw_wLD1$E++ebxjR8wfITI^oQyLa8ADKy>?)xcwisQyu>-SA@wVmo|CgEJ6*8xxn`< z5_?`16?2q_F3Asnh^ja3u@sTudl%JM8CO`{z#M|$QloxQq*WAAD6D4(07BFV;LaWK z;yFB*<8f%zu{Dvsm?wz*lmk8JtEk8=FGUB~EfFbG*+FTG!UBL!OuI?bZ96xi=El|f zaXmGL%{pY6LhVeUbz7y91+qxw=H?N=?FVW{OGOkUOsbZ zwYzB1sx(2BdH88vl>}RSOST+yLf@#a)m5&5u8P|HSLx zTDWqe#J;NM+ALl^_{|#^-rutC*^L_?ZZ)T8_dl^|@57&b@Y<2Le{K2pVVjal2~f#w zFaWRT3dl4BF%mZVQUw5%d=zsiiW{9%h?bv7Xf`yfAMOvL$k`_!ta3=e?F1(m@G}Qd z!>&#Ynj43YnJ_?N$$BvBDMWf9T#W+};-JTO+J}~*AHfF4el+rsOdkXcH5`#K1p+`N z^E=^DY$Y*@+t~q2YKd5K0ll9D6gILM9!k4}OonI|4vmGv3GqG)fHL_Opq1QBe(?eO z4DLP-xvppo5WGczCN8)N0_Fgpu#QVoN!${T1I3jxYK*`lyCIj>VH$G;OM*Q44TX$s zBx=!s1J^k0XBLp?!myZwK-AEl{70fw&g!of^t&1h|8N9O!xselhZKodDv<%=jD<*% z2!ebm_SYwZiFHw|WccD!vJBD<1PCSI^03flX_WOAxUh`)StR2aB7lGIM<*8H?w80a z#`lGOQuH2pWe`#z1psnUhm4D}M8o}|$Ztu%MRuaXI*KkJ76YFLjj~OFFl-5jIUqJi zE^>Wz+dvW&fR``ykDa*v(L{a2RAckTbZ!P`CRDqQTDgPwOrxQLodhz__3uj;c*6#s;(%Fvf+a7%AzK0*U z_rXUW+O}oKlISE!T-iFwIk}NAg^VW~IYcFO%l;Y{k`WM5X^cXw zjCuLnFE1=C0l?>;dv1BTTwLrV3N2Ygm|qwa7L!Z`^2f8{o}}y%5rHs4NK>0CtCdZ# zLNLZ!Ym}k1Dxs>dsM4~~RasO;uPO%RpzJM|hyYrvL}zJwE=`jp(K^ku#`xIS%=Gl^ z^yKW^%)0d(9)0HiFZ_5NVX(ZsytKS<>*m!{#}9q{@v-Yyudnt8gK|(5MKKr{8-!f> zjR}Sl3QUwBFhe>iZ@mKsGDf5?WUdi_AWJiHCm#S3P`BGVeCY6j4?mJdW~Qch?cR0I z&Ru)=?cKF&&#rrRPE1ZT>h(O!(mY4hL|D0{V%7qZ+s~iav^ne@IwU$SF;mdxXmH>W zHUTf;510vJQVhLkZ0pyOfK5`&Yk8+r6qQX=-E8KGR^8RAEKRFzZ{6+~W2YwCNuqpo z=Mp%q#prWK-kv2|6=g+$iPmnz3$Miles5CXNZJDtwtU=E@XlTN#Is{DaK78SEUvAw z)&eNx-Dd^tAo^oqxb(yMyf|+NC_Wp2VG#Qd$qU`(TNtHAPC}w%0s@XC!f=2k(GppN z(p1NeALCy5?1^g;5qIpkXRMViEp#+$LVa^_U=0E(OGW_zv6Om@#n{yUL1OL@kOiff zWj>Ar1^$WNSy`6C7-mLQ$P$GCxNT8E(krgo&rziUr2sV_OpAyb1$Xi^izja+#LmRX zh&*py)acPlsQK`@2bQ~tZDC%-HL2J^AfnqeiR+fgmqQ@%nh4K31OT697XZM#1^`q* z1mLq9LJ>p+GEPja!^OPisyK1EmYXHAgz@pJtm07+K*MbCnHvBE;|G%@n5GIE2@qI- z%EI|u^vQL!2DGMnUX8b^*-^W3N^clXX51DbIf5dq$QWY*t?}T_d1~-y$XHBJYfuW6 z0;SOhufixEh`Y;LJ~JNtx!pu=a>S{p*s~$Pr;NjZLL%hGgF1T6a>Ao99KQpPO>cYp#V>z( z&yH;uFI{@?z1OdtKfJnpyTA%G=p-Q|MBY-C4Q!Bn^ajx@ze1&zQmHMAveSWnKC%7L z&wb(Rd+*zO=f=&$@BZ-g@wXNiZd7Phnn9wKJEq$MKyu_KP_B1MC5X0KT{K-9+xgg2 zU;4v)9=^9TzjE~TAD;f`M@x5ZRAeyIm}o)`zbkaWW~|q__1+JD>&$`QDf+jtrcrB4 zZap{n4f5I4a2CybTEqyo`}ujt9P|)3N}B~)5WD2xa+{L87Jw3&@z8{SWVDbZo&{#y z9C8@^6t+9z2aylfIYAim5HW{ga)9+Ja$_MxL=3aOKt!k{B`irFzbKWRrq2xzC5bq# zz>93y{1aOZrl!X`?6;%tNjoZd8=)_oVFciVT8P4=c;wW(|T7+DEo-1aXureSdQFq|+4OtVEV2K$m(T^k*f+%8$FEKoq z(L_Md6vFQ?eI)P2n6lt=rbB8mxFd}GD9EUU&n!d|rL$+}W*IXPdrA=}hSMT`B0#O}4hXNn`m7m+P!PTrgfv4rq1fpwJTSzU%!0i%8hHc zZ_VGjU6kFTEXvX#Vv=Q9k|bJNB5O@mmBpZPzOz4ZIeOyg$>Sfsci{5nix)3m>vy{oqwTG`w(BH)=IN(@=Fj|0n&`?{ zH#{6x3K1(dO~j-XrXv6;1VAjxa-`Mz=I?&%fB5;IyM6060Q`sF`rXcI_TD>ZY?Yv@ zfXu5OT)EzbtX`9KE{Mpwz@p1bD*!+v)KgQ-4MK^wvQ=qpX-sKMWo+U0c|kCiEPye# zB4eyAtFox7qB4c4N?TQ6NolRpB+K$7sijF;%kwO&)oQhRz1eOzCnv_{*3HhYn^`wA zIW^v@HFVkQEZv#EaqG(2(;pu{bokP>%Zp1(MOBn#Sr$dXB_XJkR&Ey$E)YVn#)=%p zpj5oEHy*_3(+5U=HK^hc+P&Nvx=Oh+a%-xpD9iXz-e#Wd+_m%m2kzUy@BZC;_iWz0 zW!?Jqjb@`>%hN1#2^wq3n93OImxlmGSSao#h+|?U0KUkFMQnlsz@i{9#5m4l33fok zl^}@LLcdp8f?7?Jwf$aY5wwCcXK##;H@9z@MkH$q088U`Gy!x6AzDfjtq?6ynyRX@ z?|*Qrs%YQ7&8=nzM5q+F1#SwppPCGaF!;z|yjf|Z5e;cUe2Z-BNnG>g!Y>RQa~TxB z7(-k}08GAp#sIM&AI6T6r2-C7VuNmtwbrV{FEByx&BIVESgn;t^V5IwPrdxR->lc` z0Z;%aB4h1e`Ah$g|Ml%-?jsW(8gli% z6Jy1pm^<G z3?SG%G2_j0W*jz7r<-T9brZVX(n$h9rXp7iK?bYQMso2?cI9g8=YDSV)+MuaOQ#ww zf>$czoHn}h4Pz`BzsQCB+Ln;)@Ed$M56wvsOJR9Hpea~DP^g%m;P)hFQ$WlPYiupo zI#CxdbUTY>mMh}3#ynPMl1WYr8L+A#ffk9yRcb)krU>9epYKr0?K-8kHFj>3e)OeQ zTBovy2YyhVJ<`kTX1+Hl#0roJ+5)oR1Bh^HB!A}Nk*?|e*2^dQrbrWB>H@^I2HvUr zVI$*Z5)pE5q`boiC4yP~M<<2~&?-d0bmVD6%J%w6d*d@tKL79&pEHCf4j(vv{M|b@ z&JL<&N=c^)0B>$8IGXK%NAC``E9D`rQm8YuhWfo~d5}+SfB5N_o_OZDG|@*7ynpol z*KXf9+poGR(K^wD2*ljt1Pb>Wlui^9bytecpt*kE!!P~eho61|fsVZU*0HyKaOcYD z(hO9dVv-Od`QVlSQL6;W6rBM!XLmmJrAMCsOe0Z8-g)!mxBp=E_C=eJ&NCEca=a@B852TBMv{jiyDW#G zg_BrON+KxuaOfvNL0k*~0RR9=L_t)xktNCf<$ynSdfwd2EddKa3~`Wgp)XQ_F!RUI zA%wBsSxLYjdJ!xx+qU7rSOg~i*=rMbmZXifRO9e=&G5Zl4^wbl@DR5j#%`tO5kF#T zi?Ifvp^Ml)FykOVCh6-CCXoAhY!cih=>El*R1&)KNDDpgG|MMkck*Qer^7N4ffEG;(UV`64ttS80__E_Y zsR~n7R8|#P>uOv9T-mGVJfCNa;5Yu@@-5=8C=&orUt6E+ zAeS@X)*Ntfkjn-&w_uDZt4eEYtL}}fpPV~;Xm#mUEr-!|ebdINZ5!8b+cGmgHdd>t zZntyo`i%>hE?vBE>Du+H%PUL$!604)p>(2kN<@{ZOjTLyt6xXP$G30Y@$^%V?c29! z$Bu0?({o$5?`Srfd7irq7Va#ZK6U2Z58nUelasH#_WC0aJoroh=pSWiYTQ3#uTP3DpQ%Nw5G78vSbMikU_Lw0>Lel zB(Q+!w|z0jR8?7&WmWb~WI?+j*rb(9OX`ba--e@$M_14&EduDcG{p`%#-1OAs z*yw00Phn6FI?KyT%eQY_x%lykLnn?MyM5<&r@LC2sw&H}sw5q+l_Iv=N+}8TBMa+9 zg#!|R5O@Is$fkq-W5_SFECZ3&$`WX$h{zHde?kWUK&te%?c4V5+4sm}5AEEsW9P12 zvvcdm$H%fPOVdOt4S>diJ3EVrL+%5hIQ4@P-E9&YJS}LB4jMkU&;y1NaqSxK@K8 zGNJC%5H6G@Eyy9W?`f2y2SlhULquBZp>~AGKtxo_vu=0knP;CnbMjb{rcUzt$u`mY zH~#(aKKsIRzwvK>*u=)liusjqUcJ23NK%DhkgQWHM~oPOiZenV?g7Zbqb`E;oz*T6 zg}f}qB{2Y%tQI9-Y8yEw$qfY3MF`zb3;8R66D`D-yG;@pK_P%5sc#A(M)(@gP{`{$ z{u2uU3p?LRAagT9Zvx<%vxvL0X@GQ|agMOteUu*qqT=_l#}8P>z41edlSeW76PyM< z7b%|zybeZSVu%L7sKR&GNj?)sNW<_;0r<7$07S+@QQES!WoZ;pJu9b2VQx~-O=Xj# zdS)US8>K9(rblX*&#H@8>p%B@nYencSen;qqKtFlZg(#W#)2_mDoX~e0b3CfSVLry zh>Rt$zFg5*_ttIR%0B@jPs(x1OjrOi2@0;tBji320F0%)rV_0#U07XNEVJA#;|Ucu z0){w$;H%#`SRHu zQFu-w0Qd!egp;HBvyY9emc8Hn_KATh(}e5ayu=ZYQ$&bmhbK^3x;V+lN`pgSTf#ek zToWc!pT#6Wjix^^-4591&VA23`S_RS)~&mC?fT(^uU$Czez!Mo6{*Z0)XPW$gPJ-U zg9@E97hJ29QW{a)Vof~jJU6wQbaqeFhKpaI~-M__JvID4yDnu0BqXP{)1@A3yiG1p{ptWk#7 zi+1cm9pO+Ki7FUy`2Jw}QN$=V`$9MxB9MP;qhc5INFmCnM>fe{@81sz0{S{KL06t~ zfew;@5GyY;TY@gxO9^pV_qhClhMJNNMBXD5*#Luph-I8F_r#4uEC3wZ9z_DnfET$k z6xky2<&p{!X$JQC7??b4yoe{A`6dL`2?%K>PDDv>gV0eR00;;f&Jl19DHu=TMugT` zM9BAspB07$+&gjH?8K!QiJuTS9$cC9NURC42o9^Mw1d$PYu#Q{ene}=BsWX`F8t_8 zjn&I4JW8=D3y>3UE zcEfBoHKCE9>=j^5p6l_^bfl%U21M-i;PUn2!lmw|>;1(Q)9>5TEqySivQ=fRHBQ%j z94P2I98~Le?_fg99v?Dc?~@rU&hdL!=pkS-+TxU_irmI*zHr9x0FMBctTCn-^akC< z&fe>BJ^Ij= zty_2Q*fu>g*J_W{YBfXzs8*L(ldNW}_1lO7SWs@ZRv{=}>CLDIL@tQO?C4h-W36pA z^I!Oj|Kq>-7yry1CG%_l>bDF|yz%xiq?`!a_2lg%S5M!lbe5wcv;w+(%WVC;N-Kag zsp?skU{RJuQ5ID-s7ygsN!IxPlLau~7D9pbX=`uG!LOHgSP2y%I0jm4jUi*osw%6h zBx{^lYfRFt)~MxKo@PnjsOODFqt&Xn8@2KAvB|0F>8YuSvG&+VJx!=L=q%j6b@Rsg z)2B|HI{V4Ri&s`wJ7rl^RaI784}t)MN^3+^O1XLgV~kIi0R;090h8D0F^N#?b?y!mrtL*-e}gh?wGB5{TnwIy1g>blFeIY`@KQ0Kd9x|)YMp>X=|w7 z$eb>_{X$rK0YIufgY|W0!LP<=bRM80!>~XQ2)2^KiBVw=$;r4|62oL=R~9az0Qq7! z8+l53jxp9tLX2t!ZvX(;cB^sv+}S4|eQdS6td;gE>=bFKI0S-2U`h|l)ICltZZp)uUlD52-t4e$#J`G2DZ;O@?xZ( z)_(raOH zg1lrQ<50K92Y|EZnhecuQjfmWN?TNQ@uOGDvqyS)!`$f=rQuu}k?cBvfU?rZTAUnD zpL=R_boO5(fN2Z0xJ}&X`880SEE3Ls4 zRkxr%Osv1>@yEZi|Gr08R~L>Rdhe5u-(Oz5T$VjZ5ffQ7;2sjx4ge`+X}Al{tSE(K zOlR3peck2Zu?_U4p!=hM)EcJ&$gR%smKq*9>A=s+Zr)+G~qc1)2 z!i)8~J^J>WXWn_Ww{*=atyGGt-I&<2e*1&78+MM5jZM#t+vUage)yde@BGJV@E%6|n167n01ub5iIV=r{w(C? zhh7|#hx`i$ofAO7-#FbB%PV6IF$Rh`l+MH>05*WgK7-h-vqXEkZ~|Dy?3iWM_h z3Ig`=!7j=o7yu%JytFJhNr}gN*lh!Dh%Inb1js0x<3J(^Jn+{bud(Ash`Sq*K|l>m zXK5y|0|N0R118=UAYw#Zh!UPn5dnst=|nCtMx3js8bmno)A}QQ5SB5%j1p8Ftp?d3 z3D$v^q6ESPK9hW8q9BA-T9Fudc5_lZB25hQyt)wueTyh1y^Ee3sKJl+ZWBW>O{@i? z&7t8n!2m{=;!yi?yC~i84G{5fxJ?qraF?_9si&w@aw`ru0lZ!&vNC+F$JPvN*ycoq zMbg2Tam<=eqXUQ^Bd(yuh*oD`>wo_@C$CQ&JAR{4OI;&|08uNYP@zUu%k|Vmb8@nI{pRA;OSfykv2$$K zj^^}?N>CLYQxydQ)f(xIJk7BcW0^JTP+917$Pzr@>pw~HbnM; z>(Y6Y1mLT!okw)*WITn)Nh3K)a7f&uhgeoxrYNoH$m}K*{QYjHwaS!5ce%TKv)^6l z_c}$t(^>8G2dn*}SD8U&2F4l$GS*a8WvW3{R0xzL$>hYu`kASnJGbuKxn=8?b?rtC z(R4a1*Kgjqc;V8OD_1UGxjKL6&g$w?Ws0IGs>&EciceCt)>zB?bOPjgeY8FC@M8}? zaQ}S|JaF&K%=Glc^wiAE$VfX$k|aq0&=C5~+khA{BJb5_B~9!MyE_z<2&~0cEC0v; z=jlmZM3c(=^G_G*7cetyaq#wR&rGd}MNJY-Va|W_oINc678=PjVXc zI(HW4Z{56p;lk;Uj~=^n{mSCfVo~&qqAYt)CWBQ+`MNEAnuD2fCL0$^dU zLhY56YvsN#v1hFB-Ro{PKitjTUltSvM80_MzI)xx&CNdh+}wO(d~Db5T~9sz)c(DD z4<9+QckjV{d-sowcB@utw9;A;D01MeldTBseJBCzh@YcP5;z+CzQ@41VdAWxAs;)r z^+ZEMm}@G-){rUq3c<|89&9$}l^#%aP$J_P5E-tJv zsAtcM(ozb@}`_)2mP zF#%A{*~aAfB$kL4I3d_f8S;Av={e^xoWARymi7~QFoVFo(e@vH_0=Ey!sm=JERh)k zC&f8~afBpA28q-zE0XPIo$Jr()o}6vwGvv3|-oU4p1Bkra$7)!3KRh9 zQ2LfbQ6~^GF%D=PgXs5ic!R@H1qc*OkTshY0v8g12qF>^34%_=omjC}rNln5p9}~X z3;81Oklo1t1zZT|>X^vQ>_IqmDF`ZL6toe(8O;JHVq^yE z8efcb4B9{O(w6`C-=28$4R_}nk9Ae2L%IY+v<*9FZ3E7twd@*T2Sng3AfaE{U<$+s zK&bHL#3%b2N8Vv!M%^b+%mNC%onc_-nIvXLRLVJORjR1ets84AOMa^hpeQ3o;1=}^ z61WeF=}&QCN$%iO9ajmqPaGklCki#7HkusI>{c&)zE||fZm4&@)?7Q&FFSU5qp5u| zYEVX5z~mGx5aHyO;&Y!GUFi@0^}oMlq0v5tPC#o+`N_r~;ybZP8B3S&U{rb;_dt69 zp+dl7>IY<Vl)GHO6TR0i0(^;Os4_`#ckaIa)jzm({;hg_8A?P`fYuBE z1fZ1CsL?R8Ti>8!V(-U3_u>!zV7Fy1y!E{g-*|O>{yu39MajA|GIe0r!RNN^IJ9+g ze0+R#_U`TXzV@59&V0EUJir0~oiE(ZtOTD6#TfM{h&r;HLtZtb#q$6*Q4C^4!d)ax zkKQE^T=}eyQ3#5y6V?K0d z9V9~{hK8kI@wU>C>=>XRnfNlwT?Q<&PH~J=A7$EyZ$73r(G+AUG_o9$?N{P#IAjkC zq#WR+Dm>JfgbRFAOk$7GTz*fE?l^g%+$RLh!HX* zjf-~|gOiAlj9ZIOjzu^!jT7jQ+8`q#{fO8?7JZ_@%BWrGTO5c?IvJW0{woSj8W%XG zG-bS!<=gHecuL(#w~rQA#y$`q5Hmh{&D!kV-N*i?U-|V5_b1L@nC-R;j|e2W9hFvE zVWHKQiT1=qZ+?F5!TlwwYsXDp_sasOrh5DLPai!zwtGj}?V+n-bJaR)wSrc=93Rso zZ9s*qn{em8y?JZ>?(EviO4Ibou}1+cKeD4TMO-BHufn!qPuNh5OaPEER2K+0fw1r^ z-^k)Ojw1mO@~nX!024WK{d#kCur@d7FZcVao0}W;U|?%OD~){cH;PntR$#Y-12%q`5Wt*<+B)>-FV zEYhKs@{CA`_Z&m0p$S39#C`c66>{Vg^!7F=E~5CZWyT;ZA^L&{%xtZ-wjq~jm6@$G z`}Xa5_St8T9X-5f@4;Pr_V3xVqupv5W3(|!BRO9sgngZj446qsHfQHnW@g4#A8o9x4a&-lk9Rj# z`%K*GwG=P{%q=VzWp((#_FktLG*(;{3*QitR801j1|}SVVvh6mw%kr;i|bwl33?@^ z&nyxrbRn9lq)KAuH&P_b{zw<4RH~mQcMF$+R#3HyfB66X-~Z-c{~KjdIwl`ZC?JJQ zj9>i1PyFWZ{Kwz^t@jr0u8ej&U;ggx-}z<(tqy`kWCSN?J0@2Jf|v5wGW33!IK&k3 zY+6vU<_SQ2Km5-Vc%HsPKU)=s%nC$6Ed~OZ!z9nNKD8_H0}yQs%0tCz`ms}<3qcI) zxH{A;1`y^YK&gCJ0>ohlfw}3?3;U$c-}?B6A~(+A9;v z{pGW`ROBOe8IGh_UOQwaLNc~PQ58hkkd!n@FV_IUMex{9LL@N`VL)Ji*mmHC6r$3K zwPr-yrq&<(#Pl!yjnOat?%-Rmx>f;I*+JfyGlL@+sz^y*Otc|UDSU7ps)TUJWDPP8#0Qp@x^;d1(Na@YO86~~s5KB7 z5K5TF?NKOd5WL3PrO;^YEN|P5ANzc_8s&Ojz5CVX%9(!Iw#%DM zLs8gi$AG|00ANQrHCcS&vtujk{a^ccmmN1s3l;^)7wLycjWCUbjTG|*5-?_J6=rW7 zZy&ZQ1OrTnD&cqnVh4z53Nu>TX7Y8Tb{&#-+S6=^vsbkN4=A}=6=F}%XaqYto zPrvr9`&T~bZ#;q$l_>$y5hE~=H&y;vAOvavL63}SfR#J%fBFA-``a@|KmN+k{P-{Y z*++hfb;M;#R|L{7vm9>W#FE2e<*m-1m-?J0bdndO~{m`HNr5k6z@ctkCUrSfN4%k3p zK=~M0a)#1W3YI-g#Au0ffG!cnP!tjZGXn8zC=1#Wz((#pS~U-Bqf(?eQvx`z4kR1^ zC;hC~{zy6U7F*X+S5e=;J1C-KfqDS{a@@E&yvc9K=f=lyPiQ|r-Lt5m=!=DP}Z%rAm z7|}-r5G=Pyp}q_{M<<)ej@(|e@Qfev10vCk&y27nh>21972XRShZtBw7=`3J)AG`g ztIXscn<)vGL+yz<8c9|#WL0xeC6@%zq(z8k!JWx=|#4L?Rp1_m6-Yn_q@ow+FZGtv{UK zTwkx7h8YM*g){B|IN(VIDA^u~Bk|@NquU9qH6pu!u)^f>mMD)m0kTr<-p-M=O}*|0 zOU|y;{ne)KBT!Ljtt+<9DkNh7D<)!gKIw#*owZG4t+fnfook$HtRuSen{k0 zQ4~r~Pfu*$HgovU-o3lG9y$8#^Ur<)3$?znu{=L_?N;Z({Oa5iD$~=2 zVZZG$*eijYLFA0U3P7PXikUq7V0|Jo`gLsd1silY7l0Tv1Nikl2q%+fsu&bDrwNAa z0yC4fAa)oKl}0Sesw!K60K|@+^*dN>v%bEz@~B1y$`lA#6lJ^J?R2{Bs#TOlQ5IEM zj&{4#Ter;2Y~8VA`pFlLedb4gPHDHcw)|*mVR?D(+SSYF&VF$7+Vw}vORH;ZmYsE$ ztYtuDbU>eoi0GWlr~!!uGiMi~7>sH?oV^^UA-RSq^%+_2+^#EFx~ckbM=Wqhnvl|~mz7YN`SIbv(YYKpDHkUSXuXXda3 zGRTTS3J1m4WTOJ?^)nexPH-kr?8E@hItBnm&~8`lb~QOQrWB5jbS5_I<;C@;rqS`v z`f7jv;iJi^(Xp}C>T1>Qc8aP<+uuAk5D0)nniCyRTK^GfnB39Ok_JcbBd{kSQ{Nrr zu!&|UPecYZY#DIgql8SQpqjC96!wDTyR${4#0bblsExDs%<0on)1~>obL!y1qn2QG zxv$YU#<``2eSHpQM1Pnx3jpA342Q=nbJ4!5a{h{R7l=WHa?gm!M4tNy?f0NCI2}+5 z2}As)RFEMmMvTb^9ytU#iC>H_v!)5~f;0>azPcq40+Ip%R0^CTrIk{-;mD9wIFeR6 zpO9sEJ1*8R_{c>CbQ2tf+&1LBcn72qeBu{|LUQ5J;rQa9aw#H6r}J`ljbH}`08mO? z6iO~ka8dvf`pElXA_}4$Mim_Un}C84LQy=FH6n>8VmXkM?_d|QwMoAx23a?(eHpL9 z=vIXaVbI{lhMiv^B&!URC64s;=%{M9u`s$&z=}yRD{|KFdqHL-!E`}4AjW{{RAX;E zLdX%3vJhz?Uf&OrjB6-Y<9~*RNu_Z?ncCuO!V^`%4H&EW`x&i*(1O}yVB;XrZ-A(j zQG^PVMq|KfIV6rjiOH0fa5NC;#{`i9wZWnU9QZqH8K3|y#ask`5{F-5;`&mPBUKh8 zn~oz&E=9m17E>?#90(MaqoeW1+m&|ple$ibl~RGOPjx4FU8n%MvBb3gbaC!ToHZmhol+8>>N z`|E2<4_IrgN=6Of>u&)>y%fKRra-NDW35?xWG42W{N&HP@Pj{4nAW*>-aGgDSC(#{ zXC7c#LeWB{0^O;!LcjH9&6(bgQ=j_8XFt1V_tvWy&c5}HKbX6AnFz7fE=Fc1cbwd| z_xQxNozvU5=*C|B)>qEH`fHmD7oY%DXaFQaCOKTnH_w;(7HAL!FHb+DCESO&iV_#clSfNj146nP zq?jJE_abl?G?{!%jxNM9Brqsz7o4Og=p=%Vl+Pl|J|cZ_0vtz7N%fY50ETu6LiP=W zOmrAMt+YujMYt;b8Wv`5@_lDj8jlD_GO!{~4wvU-#N!-Ut^(2qc{)f7h1H41NSYJz zT9j54sq{CFO*kSYdZS=gliV`R&f?LiLLe{&hq4y({zBFqSq{i4Gf4f-QJes)MA?a0 zhnoh{cX6|E1id`tOyua)K@6#kPqs@usYJSe25yq1!>k3!GZ#`bQB)|qOL#sfElW#8 z@z2b+4=H1sdNMhZxRX+6vK{)#0Co5?&fBbGQ2?nCK^s9nl2J}pKpw`No*ril0(4eBxa|I5xY9OYsaC#`z!zQ+`TR5KAi2f3&jvlRSQM> zK`AT?Gd|ksbvyG5OY;vFoU=+105b*j>l`@eHU_p|+rp?VGCr%ulI&!GHby73n z$|G8PG+@_sx~*+9<Xg6 z<-mahCypOGarDU1W5*60KE7w~zM?2Fq@dHv;?f^{?W_OrfBpOKpE*+$1v5M6e&Nsm zKYs3Ke&M(N{nuybH%(b+RLJGkd(G0I%S8vYhfgZ%5wq_TgWnGVtbvVc6tCNQwW&88 z$qpAL1X2pMlJfX|KQ|zRED28>d1L3#Bm`myL=2K*;qz8JIRccXSh9HtSvGTsTtYe) z`-~84YunV;TC&zPH4rIOx~y8AcB@rYWveI(qf}was#T7S^(MDW%xv4bZDwlg%;d!Q z$oNRNRdU^5oqzb?=I!g}KKS6$hv%CyiC;v9&@KPJ01F3PY0N2aHz zM|xeYmDUGV6BUP`cMD)kNvTq|E+)hy^GHi&l@)RmD&`=?24!$1 zh}_SJfK@~gDV;fBRh02m!9a%Bkd0&1Q(qSXe9j0WqVnZYG0>fejM^8FWWOwu;j5?x z0e%J@${{p@977Ep<0tQFWzzUVVU$t`2-?>YQL#L#8bXkHwvT9I2y(^AB1+^atW^Z^ zoMr|HM#(~y(E7Bh5Rp9!UXq>&97bmEIDQ8@W>aYCO?pMF;zWsGl3IP2k7G!2eyR%b@`|_h+7vmmNN_*~bAxdS69X}5JSKC2h>WH%?0D-=eEyYAXM+1H z_|6|Rm)`H2wp$rAp74Qy^&k?5k!G?4Gh53qeqm&Ox&Leb@|t6Z-t7h|6EYG#&VU$k zBzxu6j98L`1aL--D6l+OFJI&1B~N^asZSRNRYU}TqOC%0}# zJ38Ju_tsl)f8)ywcP~3%!&N|KKYR zu6@AdR5jX~*fF{L#LS*2r}yld+&;RwxODDozjO7wzu8~9hDNKR@UvKAYr-@eWfJGL zHc-gS^iLj^3sVFsdJzsU^T*=u5}m-olSwF->=+U&yWGle!}#QH}?w*N(^AQ6K7Y5b{7IR4TKE0NLzJXYt0I%ZTuXhFk1e%sRoe#GmXkXOljHjnD>^eq{hlh6y3g zm+US8nKlkRAdZ?~Pk=1Wj&e^IgXms9OOqnS@q!3u<@(g%Aw_*m53{bNduJcVTM2)W zA&8;j@uT7|jZc)U3rzA!#^Eup1=4rpg!C?#VMvi4>r=Xxr43PalNg4c6vQCvl<8~o z>q&zj|2D|%i#u78X-e0Na7CJ!h?orWYD?S+fZkL?+*#CF_!M{9vZ|eMqvm-4X zXLUG-P@zIBj2Rv4bbIZ^xuv;>3uJ3VBKC)j$Ua^Ue2H^hH?*;7t+U;>+Ou=~P{J0(PYG z2S<5@^~6{;&A%zLdZe*CBZ+F$;E{_gL6>)Opl zqg!6A_m+6m?AdfJwvAHEfP^YaFoaRe$b`s>odVKWgL~8rR;?W{)kKz=5J+p#N*6Is zPu3YY9P5MZvpzg%BO-Q0#2!JjKYZBpEX?5unK-|~wt+Z*VwDY+_#6S{d_t`Nk%^ed zg$$i$ut813Y?V^FY!yXOlx3^cDvP4sYPH*~R=YjY8`&~3J~rB$o}SpgZED+&ZPSw@ z?E=u!_$@zv28d!uTg@^P7#O%Ls~y|6A3u5g@S%f;j~zXB?9{=7hbOm;wW`8% zG|cQAHI~^Wr{Zz8%(F0ZazN7KmJe70V-ArrIc8#Qs{1{e4S-X-C z5t!K*@Uhk*fbx6yW#|+40RTX~Ugys3J1@NO!s6U4pdcdvL?m*f-I3q@{jW`JJMy*P zJ>4zL$_D(Se|vRlqm5c4JMjC+f=7%{h9f+P(*Ys@0Fa^PpiVOU3c@ZejE6U*P|u4~ zw)Oy`EP(r)`_jZ-O%o(WA@Y9B@QHxOcZ)@>(`cXqz!`CMXl_pUs z0Hsi&uMYw$C4dAfhri$G<+#ivv!r3bEbIFuEsR(?aJqsj5#yYVs(=89L(>LDMf`UI zhJgqhlOc;QGDil|dyH~?476k&gU0}rAk~xyz#KtVjDZ*enI+<31&p@*v!9**%YUu+ z>X+R&UUdoq(NUav5Umank+a|&Tj%^NXth+gjlGWQc66sgqX3WqoG)wY7#smO6rwMe zNE~JWkn@>_Q64JgtZTQh)iJlOZ$4URN+CWJU{qBPgCYu4vet@XNm2$ypw6S+jU{k+ z$o%?Zyhf4b>7DBNSK8fa-dKdUzvM2xx2aoht+v*Q3L|(%Hth@p06<4Lv$go*kM`!4 z`v2lz+#n{^s&2OyI-W-*07 zyrJ+$1`!P-aMSSW8gXaash2jh~zfbZ9`-G zj=c1_A3OE*lXq@h{@$0qeDA_J=Ni=>t+wo*+WW+=BTw%*xNXap)}!0A?|$WXuYKpY z?AkS5m1vY-c<{;}qjGWfD0V-Op&pA+VgM@$J%t2m5#EoG%r*$O6@$U)&tPUG8kaa$ zZW@zDvS4J0<`=d_?1P5`A!f?Az(^D+6NZH%i9L&vD#UJMGLTv5mCsO53uLP+@;GTy zZt>!@hT#bAvFn0)XYdvaRSL8tpor2>=Gn_U3MY0g5=*I%_*nN5A(RDS%w#KHi9rab zbh}78VRQzM>j_9mn4-S)$mCzdPM~N)HeBKnQl3aQ=+W-Q;Yy?rh|`ZAG`<-o&T#3zXiWSO zqZ`^Y?EK@sb0;hx2*VPcbdb%^%_9i)e$kY?3W)ANg86Ve(xcaDBSspmYoldoFdo?uyrNPYs z+<#!N-QK)$XKi+VeSN*Qwew!in=E1`rhxuf>ZWlB1$~mH{Kb%7B!e7`135=E*K7T? z2WzVjn!%dZI5ILiHa6OAcdDxL(F>7v&XJ?Wy1K3h&7dCCP2JRWT@MDeZERCFP2-^+ zc=`hvmGbLRh{%Yxu}xFUW(@LsLz<@VTr(JK0uTbUdhK>qOifM9Om97Kc=yijTX)WE zL8ks-WA5R?((>H(%a`xnzdOIMI2hEc>nqk-Ugg|Uq# zW+wOZzwj6SFD30oleimYrkZaP}ZG zFd_RIrPd%R>{I=yu2-lTuxp4d0BHnMsIn-EkfETI3f15}cjZrr^J+uH)>$I!3#|}Y zBAX6%3F$sezWz(V64CXf!%BI00{X~5npzC#UQAg)>dqga+tjtS18`O$8dDgpby>DM z?N-(4w5o2eXNsz7wOXxKuhZHx-kI4ty=~jfj+x1kPGMA(O7yd0rUSK+PiT1`FMq-gV^Y(c>piKK0~N2M-6nHT<_bWz@K*IH~uN*3#E#FW{#d2Fmx@RsGfF1MT9TVF2*wAR7Iya zegP!QKlovme5yRvUD_WqUO9C#&4dX30@%r@vd2_1UK50pz#W3zL>?j_OaN58L6o8N zCB6*`fa~hde{S->`|rEo{0e>jYXrc)1f_(Yp>0g;WCl4RVsOrzUZ$upzdjH;jjWdhJ5h5njO%{?xW9t;m1!tacc4UjW@sk^&1z?(Z;%NkMy?h+j;2e z-N&Dp-aXOjn%V1j-u=Vhx&6K0p|xA8)kYoCJ2_uWm-(7UZrON>%g^>Ah%FO3AWw@0py&M{JAA==JJ5DC6BW0=|^@n=jMOz=9pCkRT*?v(3O zY7h^7E9+-Y07TatTb`V8N*ov>UxxVuU=~6kx|aNoVdf+lGYkUqAF-p+>?RnH2Z?cL zSj=$L1|A;Ryvg#uKvzx|NH5|&zh8a_q}-KZ$){DO+);!MFvsRc<&4}=RGv5xLm(-s zUhX~uoXUJ&w$%kO>vY!hM7(e&6LGfLi}*VY9}9pR#+Z4olGwK)Zz=OA^eo9OYfHUzW87(@0~&xZ*y=xyC^m?vwOB zSeQ6WAd@_HCccVxNM2;$$>3BJPO?`?MRNOQ1WI8jXQu!VwNgc)M@GAyPJ3~FY5u{yYx)=}vU^XA zz7ltG&ED7sIVzvyWv%o4z-Xu3wQJ<);i*%{wjSKy?zZ*nikqL?*x0D6su-VWZ=ES8 z#y}%HTA{1g`j>A!y7gdfd1cczjDSi9!|a?RNGH66{xbjok@F)@v1O27#*Q4bV>ht< zrS(Vm*4E|`-Pp+J)YR1Esk;omGnLYF2sBW4;T@UKIuIv8hU@#aA>Oo!CP2KqI zC(c^u!X_PNR!RpYSBja5xv|bUZW?Qwx@l_bYHRDdw*A3|b%SQJ&#nP-SQOe6lapgx zC$}6pw0F;ro%;{$9Bo&HVb@qh`{4eA^B2#Z{^0b(*#~Ruo3(8m6EH-41wM8~2|EDy z?AiZ+`-^{h*VOK>eeK)JYov>Af6#2KuFuUq>g;&7z3=5s?4sgA8O;d9t{&L_MpJLP zL7!dC)Brhf7Lfp~Mnc-OgT-dB;#>pNFk1vL8mqFb%AzccF(zc8``V=e5}=F)QDBuU zSitBr7{2a$&cVqf{1;*yn@zn}h*Q>n&al!bzoR-ubyF`hihUUYGnvChPnl zk|o;!Ga_iMO;J_tR=d^hbbFmnwnjU$ z4F_1ei!RzXVt!#wcb64CP7;d#r#V z!;8o`_)&Mx0duGy$n3d?XcCMr4;**mh1skxbLDofX5DP(qFcagc>;{)Lzzf3=QONT`H> z;aLpI&ud@j$y;&XaM`ku^~Kv%$U2u=2Eu@VKb6d9f`E(z#E37fRc5ylz#hQ~L`BL8 z6A0wcWAUflRd7NGONQOB2Y%t>5FP{>=g~z@Ou+ZG0T_Ml@K2_T0X_+aOxPn~rPRkX zo%~mrrGN|#%D?c+^k4p^?(2US>FzG=c$HhyA7_cBJYh9;F3O%^7m;MrHoiCwTd2XFkgyYg;> zmD{k6B0Uf6H@=|=2Y?Zg2)AxCKlan@xy9z6|C76e#-auyVqgRc3dJOG42U60i64*< za*X_jOsb&IAVNlvjE~6|$nOLS#2loqv#2SghX5cjd7e~6rCj6CvVyUl`%gXf^b3cN z9J7P|xwG%R^VZiNEnH_srqcms5Huf1PTx|pyImEbq%V!?bx#)`{6Gh zI(}&B-mQ1P`SnZhyteu10TfEN+N@1nPxt;DSOEiSL|vdlaF#YUS#`$upE~){EBlV0 z+&m6vnV>cmR zI^gVm0XJb^D&!5y%~*Orn$ASEf z7$|z;ter!9Ap`!4wls60ANfi1NYr5HQ)w2{OA>HcLYjeA|?fskopMP=s&g8j^4|^>Gz`lBx3a4?R)~YDX$VjK% zX)n$%%{`cPwnlJ7L`)tbrAMV>C@8~vA;9n+ju`944Vqdh-ZE+q?A!9hsa+=yPEL&L z#`4OeX64Z)*{0iTZ{ObCx!tr&*sS^9!}{ipwd=Rn?#->OtoMmQDWw1*9z(bB(hNDpY7j#zsbaquaJkY@43kxpUjj?NhsVO;=j?>$QdXh56aJ>({T{zH|HD zg9r2Ti)))}jvN4MRcxKw{>*bPzVNXZckbGI@zSk}7p{YvbmzI`So2D4ucC45> zHBcjBXB&Tn3Ws;B53wnfdU3!`vREAWglcjO95Z_; z9^n%yF0Tf;%Z_Gn$lD~}2aryP2>M}bpA%wT!;M1#rA*Okbvm6^yVYu!op!q_icY)J zY4=7)x}&|;^wjwD)-Bt&Z=IRkGCEQ=gUv@POAqhdxqkc7o8SB1rK?x&KbUJ=ZEeHB z@gN}hb4(;Pmbn@>@eesLK*3%hd3wRefk-%80fyl$%LrNo z2}E=Vz|l#hYD|dXd}6It9Fxc$paLJ&DXooGzx?Ljs zs+^sW8?bP^?LDPQ&ssX!Wg#`N)iY7 znzq@hY*0j&X&Xv3`M4ZWDd9UbfI_W7D^v!RLQ`tCFfqkX{cy2m4?KDRZ~Zo1d*5N{ zHXS?14j2?@gkTty@F~Ecl(R6s4S(Wi+YjgKfA){=4I0M?JMx_{wEibYz0a&n`T#~|)q(dN+Bf-@$@fDzE%WNIjP_`!y9ev>k ze(*y0V=sN?$&Wo-^5D$t@16e6ch(== zHDe=N4nDEx@KZB~4@~dqm53MKIQ{=D#L7K8y*XrBGRNM4W4}75NY% z1iCF&w#~SYG>5{fbJ~)S>@md+zK{kq3Pl#-r7=P#VKnrR1fHId?k|`OB>f@x4Uow$ z&x}YqG8BI%{1DttKE&c7(>)|9L zz5OWdAvzhP870eUYG*Fj+r-6VqVGOl#rH3 z1;~t7*3778qT{N)T zuqe#PXt&*NFU&8_J(wjsfRN1!;V3Q!f2yK%C+9*ki0vsC@9GpVGm-6Y+J3|B%IuzL z9Y4PHiQ~Ht?CrH$xV+HJ&#eyzgH~y#XSzFfwx%akWpHJUFI-+fdwJp3{nfSgx^WIc zDFw(18Q7m8BCF-h?7T06aKf!~euFt%+qh3w(_>@3POB_S ztra37Gbz7d;x}Lr*@o)I*1ljwV?(k>W5a(<-PpRWYui}scN#ddPR>4Y&RJ@lBS+RJ z7x!uzw{=67VjRm#{q_rwr)x^Z;meH}DJ7;$7+_rbmj;XQH zUazvYSzTM5onKnpSnUtoXm5Ocd@G>2diCbjYqvKytOK*Oyt=Zw-cZ@#ZYBmRTUAz7!H+|k*a_jfpV29l*DPODS3%2vD8*|KGHYI4iAZPPQ;<6EaE#zrb&yR`IZcKN~eD_72*{osT5PCuBv zx3<0t5v(8<#`u?>n4m!FB*z*j^$<~;zZuii2czQpa7KisoxLhK+% zG$a|jIp|KqlzNeI8)>M9Tvl2W3sEa&a4`Ua#{lAx`{7(%F$VP~tFoL$#8H_zHYyi* zbS^~1T5BIf$&GzH#S8>YRarLH{>;z*+*iK*C1Z>u*_jY#0sQU%*Z=fC{EvV0H-F=M z{bkoO<*)wM#N53UQv(J%l;FeX?U0J3vPBCqd9%Wo?T!BXD- z2=$PZ(xgsU zO2yI5bi)4%&hpfD{K-GjzW=cPXaDG7U0X(98+`yj0zcb`&s80+l49%L?kAOa8cCMn?i{+lD~{5LhkNoEHg9mph0ML#}7X7 z%*#(a^K?~KH!oi~fBLm+m)~jZ3Kpg5b`TVY)7|{7FuJFR%+nZX#RT9iHv?0R?>+J2 zsh2*zbKlOD`GwQ3ef!dT-&tS2uPcMC3RMw|0|pBMfQZ_cs#6N6uI-?2+T%0FUON8b z4{n{Ap1XVN;v3(-d;Z;Ku%bH^w%ZH}IMlnPrlH!l$EWu_^#jkm^ohyQ;@s)8Z@>DD zN4M`3qmx?>AK7!_={?5}PjByRuGtU1{^o_>|MkJ$x3L(Y2`8}u`f9HN^fSorV>!8O zXkHFc33>d3(E?~*rbnP*HfdU_z|zcQ-h z`vxw_6LSCo(XRXFCZ`{}E>DYYHr+5v2!cc)H-AIeN$yF>Pv(zE*_<*7vW9ak0}+g6 zs+F7v&ywz429|u429Rp9x}wJIz26VO^gpp z%^o5?a_S-L&|@q{`iS#OBg2>V;_)AZ$QAt;h>v{6<)>%ot;wX2UEv=uj7JdBu*nd# zBCcepX)>A|#S}Y~_cOX7%=;kU!S91@q(?@h6Vi%o=4WyLx z5%-KqVlexq<{0S^AoxmG&T-Q;1KR+@ma%g0t}Vxo>^gpMYWqy(94szwE-$Sq=8=)s z*6qDrJKLjOSg+ysy}_j`D_3tldbqG^twmz3l?mVin8~qqewzUiJ70p{u|JE&_Q`Dw zHs&^0XA6T{w@&Zawqxt`OtLD+cefWYi;Axyg8VCK#U5htRtUG(pcLx zeLHAsJFvDUTY~}wZnfK^z0Tz1=t!?OH90XlGSTg|or6cK>-X+0EU&CLmYB`zT7P5E zu|DhsxYc*>7vqQ%aom>D0OK{4Pd$dXlY~d z?qKzy?LT6>23#uzRaN#nt!}F*3#mC6jAuB=AXI_@U}oo>bqzV|cTEv71NoRShWgCD z{)gWc;z!akg~NZyg%{2_W*0sWm0uh)_`;?fy+xdr8I=&l9v+D4;mLA_C`|$Sdpe(w z)3~~6EY$;MQmB;CrYx#fr`PFryPc}2I;~DwwL0Bir&~>oj!kVDpV>aXeR^ug_UUfR ztgSBIdvNF8-J9Qh_0_W%&pmwja8M5{SqTUMv#kVS(+Z$c!FICjsoU+Wt!=#U z;)^$KTvMn#+2`pl=d9NHU;X-Te&GvW{EdI}JwxTjhW#hMdF}pcyD0oD3d-qao73MY zz08s^0<)ibKa}@@!AXhmFc1XHj0_MCwv8c4idn*7NZ0&LVlaa!wNIrH85TV8NLXrm}#BB~N0c`|Jq9!sNVVPgHzvcS{eXBh! zh?qP-itG|X30g1>qh z22@Hlj>{H}jjDTh+{yx1C4>!mz9l(wV^P^u4O#4n3V=z4ooNgRN}(x08`N6qLTLj= zgEpXz(gl_pi$Vc$cZ?6cjAJ`-brwJP63<>_2X3`bma!q`Q1aY6bivQS0AL%O+73VU zCtG(P*nj?yW?kb5**SkIydxk)_GwSxEB;A{859|1NTc#+cF<=@g$a%_{s`Oxc||^7L~*^qIYTXRe;V^4cGMXW_=Ra9KzZL9x&gz$DGd^lvZ=ptjC@ko|ZK#(l-#A6mAfW zDas8$8mGn-G!Qm2L>NP59Y8)w&OAg|X?KPP8%p>H_O)|31>DT!rIGalP=pxU2Q2Km z-*^TgWR$SpPlS|BkS#`K%thjBAUrGMS0Ki^DRsqrF^TJw_#-tI5ZchtOPtFxLMWC4 z@)1gWTXcmvYDD2##h~PMXM#lH{n9`~uFy$42vz>fRWcHBLMkyQ zp=F{9PKjk8Ig@k#(C-)dL*^`VE+E2-T#kvFlm@59g4i}3dM|qrCp;pb2(F0Tl;;c+ zA1tJlEaQHpJ6TdyKIwCrKSq2+fs0J@AHj~21t>dAf@B_>O?gYxs0(EZ@rxX9EQ%y6 zv7v7w;z-Sh=>dunL-n|@p(O609{QgEZ~$gicAAy>ZCfV)*8lR4FWlX7_QQvrmI0q^ zuTaOis?liE8|k!K?Zx@U`G<4V^if%s>ZX|vvlM$LX@mbqrPK3JRYZ_Kr;V#ki{JGXD2oSf)( zI#s2$LS;ZH^ze(tPqF8T8RcZ#Q0s(=opp|AFsKLhfXD$5`R+80BWFWih_#mLx)}`W zet%HcO@C0=gQjU3N3Lg+l=Sh~J8d_`AfEtxix>Q;#EKrxI zjVelAlx0bg7G~XjQFNyWQ@L_QrbMPN&^6g&rRr z-7-3|bJx~Adv;PBBy2M-=R zcI?EV!-scl-_~ol75YPd{Zu38oUF&`xJxAn+Uo{=|JDhpj@C6me~|1AjCCs_42#Ax zctZhE5q zBGlHAgc_*8Ia0o|X~?ia^r1Mj-<$``yjGY3i}Dk*|F9waJN{ zzx^NH+tTUWyu0>K|NZSgbhP$a0xaQ4Si_5WHg|n_1fRJ%j5}d`3@>gGIU-YtOU&WU z@lFgvb9fh%=YWW(lgSb6O7@XxR&-zpzV-+DQ-BcxwF6+2-m0g4H$$OAbczp!e_@ow3+Q#73D_}(cS3~0!3F~} z5CKsrhU#DVa*^S?A&Y|%xlmA6*eX=3)SXUI6gbj#TgKJe60WVV((GNmqnLpT#B%l# zV6u_POaK5V1xlklL^K^%=JJGN`cX+G-`!Pp(#)ijBMe9FX6~`SX;o0U*UyI zY=Krbsc)ffz%fv&bM42Dm>3bQ<*9A>Q-88^_hIwv|9HXG#EOX>Q(#3|_Qg_ABF@5r zpi1JNa!`#KgCXz4R5xGT^!Xq8)hA9pxqW)<#^rPGzWe%>Yv=6dlG3a?1uF%p*w?9z z`px+x!jy=%GzE1s&lwdtV&`;2sfX zsF)(Hslp)}{zxBYu#Q<*BinbMeDTEd&u^I;xqI!(d*A-%{j2BD)uz{lq9pPs^fEaM zgFa$w{NRaaKK=QVM-M)@dHuDoef!S&4|Ua^Ja+uZ3oq_Dy0>bnxtsUSzxw+(Ui*Ew za7C3Gi~)8`44}h!1mi%_jxrC&7ZO2nB0qtL!v4%tW)V%K6=J9vS%DaYV6p;`>5>0T zA}e9c;`^NM#TdR8B0CdrBG$$?Z^)+$B_X|*j>aeyq!(gW#87IFj}X#YTwSWdvCI9AqJ(kM}`oWHMhW z3J?hnfuXSv{{0v_ijxx85gU+(Bf=c{3gjOR1@^)%q?YBQiXV9=>+EAcq3?je@R1KA zQIq;B-;x$dL!umF0!;FuNtDv&sMsLJ(2rBxW%hXFpU32JGD1>kgrvDi?=o^?SV}|g zT;9OPuJn462{^B>BESM=;4`78febeOF|o%sMj`=|_Q}`25A^|%6Lwo!& z7w>L4_u*{2RRFUuMycf_QdCN7?K3{+=NA|5&ypRWasU>$<52gL*LV+gj?nuB~+;1;qM;VtjdgzdbrZ5CmrQHCoBp##!6nSl=A1_Xiuk{wnyh zXbF^3%2cR2MQ5_?PM4kSgkwszyr!s7BY;wv5`HOP)>bQxTB9nJ)=C$E+L%HWr7^8S z8)d+hEmO61Q5pjX&hVh5egS<&n_vgzY9`0v0^$w)5fCwZZpa@P9CjmtXM8|tSwLW4 z7SykV2g<^4gz?M=P&gHU{SGF7FP|jB!j3pA4Ipsg=-dFNiT!araZ`y`YKq((iz|;- zNwTTMN6OUn@lx0kWi3%4yP9Ue&VsW5N-J&J?cT^pyWQ!ws$REKRb{W&>vdY=W255} zBQskkrY6Uyrzb{7x=O*);=?>>)<~~~j1!Z+hYuEvF6S3E_UxZ3p_yN_59bF(r+wnc zc(T20tBHlutQYscIzA8`qmGB`m^m$%Q+|Jk%`$h&C$b$U;XA= zS1vEU_RY&%$Gh*IUHG*>SWsnKrJANFDHALTC}K%n4c7!GB?%dsj4(1HoT+o}Lw!;P zWC@ZHX=8L)88GsV0Wl>QJ!?uWf{3R5PNGofLpTe6G?3SEAwj^GD@CsXQa73bNzT$D zVjFCXU_T|5EaSK%NDfHUm&oEkT=4Uqmj0)D-?0t|COSxb97fO;1p|-_NFqd1^RnT} zm->?W#%898ixMIP_4c=xDV|S*u%sAjMFV@ZXA~D&waTJXnr>NkTc+L8C1L?+7?9ER zUEQ#&$u&@@a{UoJ`Ev2+{(5=&0)OKx1c}MnNTw&`O15paXOt}+IU@jFX^*puSm zPfjNac?ux20z+5~N`Y2rv=EdUlt!&V&Tt7jqO{aL12hH{;rLcQ^hq3kR_{pDdKb*J!@_%2ZhK2ZGM90j20f0ej$g_h; za{C=a5*k{#df`hqFMO>U*}d=B$De=hjuL!RoRy#4vJb(uEu} z2n?aLww{Jh%EkzaZ_SVBxVQ;C>h%k1O*Yi zaI{g`+u0u?Pl<(@nK>McCr$Nx^MTTr0li{!=sH4-D?=~5)WpVc$|(&_HTz=h6N}Rf zfqu@2Wg<=22R|N1T+RU=mXmxH1;vWzW)^A54myZTO&mW2V2UBL04UL00Em0y6*wuMevf%g~XJJ3Jdn*{N9mS5PKEw@9&Et&5FZXO?)E*kTDz=p{M(0 zG-PabBx4eqN?#c6HYc7&ni@M0C$8L?vwOv-vNvHvQcbueYo|zqB{)e0O1R3a2eYIX z1#w3-7=R@UUy@pt^-*Tr<6Vu*iWwRl8cm3;L7^>V?k70GJ_H4nY3(tj$RAN8nyuP} z2a46o5xs|Yfx`3ZssNS8qv;IFOpB$p=r;g_6g}WnoEZb*5sLOidj?7C$nRP8DGYI+ z0}%NvWsXA;M}Vb36e^8IX(F{&+xI@a_x9P_g;ra~yN3?!ICf<3;k}dH_Lk+9`p(Uj zb00h`HE!9`+p&9e*Uqt%M<;Bv`|iWRwHwRVZY@5TUs|sR&H(}_)POc9ked>*+{2&{Z1c>r|I5xK@%U@}G*MS%+Zg1ocNIoEEHC93P% zh5UfpTIYxd15bT4&bfi#^J8mkZCy9kI_po=wbn6lNN-@jcBBAM8>3ZK6{9@XcwVJ( zmg@EOjg5^>>ns4GQe@rc>Uz^G7aeVkQ~v1FVCsZb4M|~$pa2|`b*vQ|r7f``YP2qh zYjy+FeI_CrASzu|MY~sadadqQr#C*@D@H0XP_ygX%|SERtos{vy=m=$LroKZkRds; z09qjt5i2p9EcpvTmk1D;NJGfY0Pr!6i+UESod%L5td+S?BEyAxvc5p7-$vj_I1wiW z6a%~DPuL9PiZ?VAD4BrD0TiP+n_vxCNErYN$cU(mQbS?NRx51WA#x2_XPdgQcK-ey zb~ONMZA`1xYPZ|%PPf+^>2*7;mML_v*Y34CGuvl&Y~QhC*UZx|9{Kbaenx8=)SC-) zvkzzQojv`*nbYrH_~63g@_Yk6Y6d{1l+xNSn1d7vp=?eLo2en=AhRz4uV4ke=5zQI z2q8@p^r<__Ip;Z^TQ{!Xx^ev*U;iVml`*E%9yxOK@Dry_96NUG$njIhk00N)b4Rb$ z@{b_rv!ARl5Wqg7R0^U~1_1Pj00XP&^ui%Q0VjaCbIR;*#wlm3I|7*ynOUJfruXrM zG(>;m3OGlFF*7rhgMl@L>2|9AX1%)B%xoPU+oDFtI{lT+rHyjknzz4wuWD5njkTEVSBD28P=!1P|;2#r-C5RzF5R-=&g0_-W zhD3xBKtTdEF+9mN9yuHiLgXBSMl=elvgnk$Q<-jM+J$ZzT^d~|KxVEjH;#!FBeGJc zG-^{KE8tRT2HGH{fe}%M15r{sDTe|dzF02;5`+Xb&NM`#ES7}+z$}qKbl-9nbeR<* z7$^vRM99p*=m;!HR$w^644MXsfmkiCH%kky+rq*?Rj8^mtxB~@Rg|h_L<^A}hdU4h zAZUd?he(C9S1{v!#i%&;TMQ9^EMTaB#%uu{*a5p5z1OM-G#IcdnIYh7rGN++0TqOt zMMxPL&VdW%T9sBn$^j|R$!rCKYw~o(kSFV%6hK&c3gjm9klt|ICy0{wfdF7&q^e=< z-n};;+<5bQuT5^<{=}(MAAjXX{?x#3T>kLfg>$dZJ~;1aRaK>Kw-^{0J?|o0&Z757 zke?~W9{@hJ1_2P1QD919SlhgD;q9AO-u};12c?>v6!*-w4!3qSSp z7ytO(%a<;_`|Ud)e$aokVA_@Hv{{$H2u#EZNCkCM8VYo{xp@2iH*cIdGqru^spn38 z{F5(#{&QE(UwH33-&(kFRV!BA4j4_Wl`%ke<^F|l{Q8ymckVv+;wL}##pRo-t#nb4vyRv&f&seR z!G__I7r}`Y%F0O#0Y_7#3C_{|4m{UV-S#xPr6%fs8nFy z;~CIV-5?7;hKTOimlTR&TCWGmo{*b}aY#-ig9af`l0ZWmmnY6)ik#wh+Q<@NN*oAL zn73s~hygFeVGuR~lk|c~%jk_X12M@&1et}9DW1$TAjD~AHxaCp41N@j0EYaY>7gvU zVTVx;#twP+_qaYumm-LXPs<219)4tI3bNjRM7z@%$m5eX4t+CqWeB9QNa%4qn|}O< ze%SnFoO+ObZCF&_=PtwdOK+jP!&yrciIDC+^kKYR;1PmC=gOWMmDma#>Pe))z7NEF zyq!Z+HBD`XXiT!+=Co0eRc4=tg`F)9$;YW8OoTJp7AWsnRn{=S;H3~^t+haNg!M8n zsMKtPe7r=un0lVnG;wNzcQc8^+v(uz5q(S_<-wlmQ<->%RssD_JWqCrE0gQPB95n~ zLDVs$K19}m=MResvaI2+@{t%R#f4FY1|q($m4uHa3WzLZy{1bjhk! zsIn{yV|1uW5DuUV7J~u&05P+i#s~mFe!W@@F*9hTG?~(wMzOz8q0&Y(gFnN8n2n>d zEXk2i*Kp1`=TC2Sb!{8lIP07vzX4`Y*G=Q;Ma_Ep9 z+rD+RtxdDJT(5ST&M1$MVm*SrPN!W~*OaZTEPH-N@=AO7TD@^nTdIViL7m$vrW@q zTU&j!xPVAX>7r=0d!0_N)2dqCtJkWsJu=!G>Ga0O$H&G-CMHIEy=u$UjvxM!C;#}* z|0P>*EIpc^y?5u{?OX4^^TzoLXYSs)+aC=4la=gQS6YK&26m485_P~ok3}XdH3}fP zViN0;W-y?&$!|)j3$e=pT5CikqNcIS_2OG^z4^{NZ+ot!YPYw~%p5y*%QGaVQ0Au8SRZywNulN95as3y55pD> z0fe0Q-7PD=_Kb70(JNPXG7BZCrA10Bdw&AmhTOFm*>AN#ZrR=7$~m1M;se)rU)xOI6>72Z*ts-(gSvugQ;U; z$EC)0p*v-(Q<_doSA{CIGQb*G`8DT;xN(fAwbEz`zo$cKa7+ZY0q4LWSpZZ($gB|% zScL>r)IK98(80*4d_c;q5LvVHLel_@>^PhzB&$bRxREAElsB>vl`0sWz@afXzwIUL zpg~m17-+S*-NCX@K-5@p#Df9BTCEjm4OIcHN|mK)Rl2OOELG702`l6IqtQTkWC;L3 z1T++^`DH`jL{f;xD0Ey8!1jR}a1GQo)D0`mjboB(CaHr8fR#@d3~R}L@kgGDsFe1t z@WF|sn*#enXi-%uYyt8oh0oxAo9*5@x>JpJMMH6Zq^I5EdnvvlNFSfKSzkLn_-^^(|T>sX&Gw0rZ?ct3N!EP4amg=;XE}TOqW8byN?|E*vIzm+qL%K?&;UQclF%qX0WE)E!8d+Dt5@^n5;(A8k^qzdEmoUcGSn-8WA!F0E}naPZ{EpWA`s%IMeqSwJQ@s{w&Cao$cFX`_ zD>aDL2m6~Gs<$70@ci-v%1W_J4-lFoZ6T2WH_#vQR{$$<$WZud2?)~TJdF}lq{4l& z@1VR6+MF#OV@w*r?50CmEy18-syPzOVaQvH*$5!$Dri%_2LKRavF^0ZF^TT67c#H> z1Q(tNQxF>3PtqI?_w!ojS7w4=42_9Hg|U~(r;6Cca7V%=3zJw(+|U$~21{RL_CoB6 zbSI7h=i6bDW0$}x3N7N1Oq!5@2V?`3O=)4SK0!wt`xu+Ykq&w&B8csao`huygNJXIY=TTr zImXZ9K4z3ZC6ha7Zmf=2FsU?%01T#VH|r}i6BB>yfBu!r z54N1YINNGz;Lsx(FH~9^t$QP#cDubezc_dQA=$n{Cv_u32CH9uWuZHXDB?+<=m`?q zLI_xV7@1@iDG~DdvSIu}DOA6R70V#Huy)VZG61xV*|~N6$ljgD5A58zebN}cur#=P zcXf7Q)iI5B^wiewj$NbMW?IJZ!`ao#SMT4vJ-58PG^kew_3GGoYyX~YJGM`bjP$Ci z)Jm#qM9PK|Tvke<3J!%hS`s8;W@j7M)J&p|_Dvvoh@gBYWWsw}Ej)$We;Mn*?QyWOfPt5&Pk z>P$^fj`v2!Cr7r7k4{f+86Rtba;qzg^RxGF-@JM4`n9uXPhYw8;p)bEG8Rf{qZKNn z4G{y7&lL&Ve262<3P~|gY@75gbH~F!L2qI<2!?ZH{62qQ1BU@XDKw@qrqEhXjE(Ku zvHSR`lP69bJ9PNqp~HuF?cCQL?H~a;vP5-5P3?%BLIMOprO^2E7+5kKyg|>rL;`u9 zf`Js#K!PIP&^_ksGZ>Qx=l&y9zzpQLX~>j}jJNM?Ub-@;EAs;{?y0o%M+G;;$gH)N zTd~;P=)1TotN!NtE3bV1t+(INT4l@;GLu&7AN;TX+kgEFzwobq?KNeUQt(gz?bX}M z9a9v@E+`Uaeh+bI=0*vA8G{7Fz4H;ai z*Z~=sGRzh+ZWIQ1k)>!)OeCVx+S|xTFy-@sBNk+VOx~hNYK(YK(cTCoW11c;zPmqxdY>9$O_rMm_yf3UP1Zv_AfKttHRSQOefRVe^sYsofpLU`DsqWtz4 zg}AiDCqG&KH@{e3zRchHiUUNYiB(vI60ic^#>xR)0C9r}3-e6q8yupqz(WB?!4BJ+ zH2mQ~2oTp7PXm9M9~ZwNs<9e$S&onIK78UA z58wargYV7XyXKm8({AZ@g}Ng0Y)>%ah!_B>+?FLfgj}PX)nLle>8*#KKKSIvcJA8I zUw?G*^t)Hyd41)<4OFDNU0wAUHATQJ^AwD4%_{@ur)Kegx7VKjXE z>b(!X{QD2S{d@T6y6&~n7-tz^Nft1?og(RE)5Fygvr)EvC0Q=ca{K&!Qv36_^B^sIZhTg9jv)e!W+D4v zSUivQLBc*3rZ-Cbp4UD$O3YRn-n6<$bwEx!6pp-Ap948IHf&gvWrt)nghXG z$a^eZmLbZBk9fDC;Y+b~_UyccDct4%QhSGVDPDpCHv;O4q)$cw<@d|@9pOKQ-j>-Q z(hrZ$=oJw4mcE7iMfaJtb66(HnPN`SX7b3DHp|c$djE0JaSVY)AmQJ~CcyC5IHFM) z!l)1dRz3`z3qQPt|3$TmS7thp)u4gLhB&dN@|BE*8$vbNw*-L8qzu?jEWfwUDqNOC zGOVLvC_mcN1TTUzaJ>GbT}1#1tTONxDWq`J3xlcJc5Qj<#KhnEpc5#?cx0D%IZd8cw)S@ zW6$W$og*V1wXwPJ;Nkqun|Bsh=Euj&J$q-y$49GHTO%T}#HzzP1(1D$wU;z!rd8@m zkaeyeG>#ov0>p5hiDU)j!>tqouvW@1Od^C*6%uTd-{fMgbIvu54M)QaY`?CXx*iPb zL0$Vdo4WQTC9JhgZ7n(5G(^4(TUAxvqHGhIhKq)bqr$Rg*8VIN56t{r#$=&TLMV^% zHKN9l592pf0K0*;1Mmy;rYOtSNT)kG(i6xH=0U4{PKq=zmNsK4+MiDQ zRSFo0T__Yn>M zY;1gbYP?rNgGZH87^VORIz9Q5jDCn%AiNnqiKo|k zWPyO$HRDupDJTz}iLfGvsgRry z!Zrm6#XlewR8d+fjR4vg5y5b12O={Q`%=Bbwpv00&xPg$S8gRQQ@N{I`u>4FZ{*w$`$_h zR~>^=ni-t{aX-1eB||14D=s=#Gj+aF6-3QtSplJ;)iFqbRYasbL+7zjz?Py*N;&3s z%Wd7FmgjkS!4?KnLKp%f_+%WVloJ0INEc*8Hb!Z!j8WQvF{l+F1imIPObh|}hW~l8 zS|g)2N&`=AgOi`pllx(9f#3N(esI}gNt=!wVPj>i#lQOBj+hymnkT05Cx5Ozzv%w$ zudXR()+FgjB&byIz66YvEvyd(FYK*dsC6y0yYTQx!0ecS6(Xaad^u#{q~Jh83Iec* z4$89E8{M^c-^qRZo){hLEicbqzjo&Ghi|Q|+ykghyQRwtw2|bKsJLNpJo=3DJfQP< z!gM$R8GNcL0%I?&rqvnSK6CKofoDFpb=%D9+?|W>zIpld`-7!Z)Sa zzLYLth(8h0GuMiXsx`J{*MY-NJh6A*%*N`%xwlVWI(@ocoz-nqbULU@P==fW&}G$` z+Oh4}sZ)Cn?74UE-kYzTU0hn-c4+VMr%vqv?LZR0Ilj}BeEa;(vtRnXo8S3eSihrt zJ=BV=i#ep;HAp6g@DVW>k?=ZbaaJhE0}Q1kBpk?MeL-|U21dG)7U~&5lDA_nMhL6I z+|D*fV$yWLVYx(WnCP0&+ekw^#>B`|qyz@Wc_g=y$IC&U)kV;yzj4xtH`t)Ou zUREl@+)|VtkkfibP_4{X$E$qg}KGK`wzkP0iE}+&PSDi zvcsN%LS=U#1`wx6BqKyJfU;pBsVz&M2t@9K!2<^hrO!ZOTlDFQ!LG9Q#5{^ldw^cl#X zXJbrA=k=2?6trXxRqg`Pj!sJO9B|+%h+N~yIZFJA^pph@0Qd*_!yFMqMhyCx&^iKk zjsOC~M2@U=byGLiHG`&Z>Uv=7#@3Ck>p|1h&e^)Io4T%>rf!-+zuxQ*n#K`;F=e+i zQdS*QEz8u!=N@=AKw82X2vVx)@U~1~Rzq#JuSRV{F>Uuz~kzkpF zLP@!bT9XUy%=ZK>H#EiBnz59_Lv1bB+nS(wlAK@~v zR$6ShPg@GrN}wo8V@z2T?W((H-|i<)oqXzvQ-_Zp-m_=lj$Ql5#=Blqouj(3P3=NP z7_;9?=+mqeg0BUT`(N+9g2lsUg`! zT_nc<6XU&aee;{2`Ru0)QxG}N75F$c78`1*HWUtPg(eC_gBrEc6@{?-3DuUaDj z3PTPQXRs&o8#vTXf8h0MHU1FsD+BG3N@na4nl!q#C|~^z}E(f zKomvan4QlobEJ?BqA7H{)UCp_TSd36TZOI^76=7`@_v@tC@eHqrD_#e6{x%@NU>#W zxo)^_xUpa@HyY9OS~HJVB0#S}}7~x@}WxeVLaR z$!K8YcnSj|ge51Ch?^l)2omGX48DG|)@TeCh1Le;3~E3Crm)uv*i+Ww_#veciMQ^= zCqJjB_QUENzx}&>_mabkHZ3_q$KdkvX3_-U zd2pYgh;g3*VluN5iy>rDEuws$EeU~OTc4sxz(9bCjI8;lTB{NOFe-$Sfq6hHn~P_b zmM@(@`?c=G&Rx4t9Xa`lmtOw-;{2`i=TG0feeu!4Er2y$mZnny8h}`7N?>@lq$s6&p8wRR zZe0HG+*@xf+_^!#R&+XLX9SDN0X)2SclP1^ZI}0+c;?AJ{-=L*?!nxfZ+!UO-}}bp z54Ih8>gb-M`}h3!zj*hRAAR@B|LM+ae~7F1Os~rt$u&Ui_X2qYkgGrDrqvsu5IBi^ zqm7lYUA`EmnMB=Q`!hB{;yui<62|X|{zQ)f!Dc}U-Qv*320@AjBS|4{MbtM&a-txf z?0p=6$`U$A00$|2grN-vDYy>m2Z_hQ;O8>KBE*?Qdm_e=ubAMka52e?r`w9h4Kqkw zgMT8YuBCfH`lA0d0|#*oV^onko?nq&8ip;WQDP7+1MP(@Bw;4@{Nd8-1O~$B$51T%J84ALP(hAtyPiY5Z)si`f%oJeZT3f`AScaP<6ED-iEP*iaSC3xfCP@y6`UZ@u?Mrz&?$P3_;i zci+z4yLN6ThQ+1+?A*%LI}2><$?5L?gX2dJ?;amTg?6yn-xzH6HwQI2U{ZcjPa}|r zH2HDcW7m<=i*GN5MkbDmfMQ_W;K?7uVQ6glG>-PBkc0Cr$~ z&`}^7K&?!HWRMCp8mt*O+c?%5jmDzrjEwh2C!DqY!N%t1=H}++x*ZIxZGalT*k=rb zub(Gdtdz=4j_6FJRs-Y&$RZal)$Pc?>9fYY{`gD|e}({q3r``xd&tutkp~GxhqJy? z2$82htaD)%$C2|}X`Bm1OgL<=34tqxARx4p0hvg)bNeJG@I^L!?5B!$Rbs0>!a%mM zMC5GaobA`m`o;n;Gzg>+P1!12onCKjWMp(=%jig_)9v=!t?tN3ccfd5jCRLIM~hs7!zDIJ0kFBEeDXp04i?pb^Xn@yA})v|K#1^_8mhUb9`n{>zk+L0=$yTJsqhenr% z;xr*$0Vr;X<>}!X***>OZU{NO=~p6S@E^z+huq2FC_U?e0pF_NYP_7~s|?`42Q&oi zh_#qqb;D>TMCEJy4dH_kSet#j5hn1E?9NlHRTE(23iia+HVkb#(x5fR7&Gbn`y zQER0TjZzLlq4Lw7n5~1lAxBmd6-sqV)omAJT{F@tssc-mg~CDsIB$KOF<2@r3v5-o z-BK;1jE2gf0^ID=+8S;2skYEGQBVdSJ^kX5*d>Ej9}#Zn9ij=e2b#@FYm$@Z6T zubsYs_xv|oy{%Kbj_x`3#E-r3$$EX^`neA-oqccl!A(F{bh@Thf-+3Vb=WXzWb$#?Z@_=c=4ql{h0!P^6r%@=T5(U=jwUxFY8{b=vGRV zM9TTfx{441jbg;rmASRIXK#G)-q_6EeNR30nV(*vye5Up)TT?tbAX&;H@> z-Ffq?w0hTcTg1TRYu$?G3pfsut$3vng0k;p;_xv>`k2BWvCa$;N)pv+qTb2K9&mD+n%*BO7}v3K#Dmo13+nvo~Wbn<<(QD^WgSdS@yW z+sYs{v%>pgETXhT?AeF$ZXk0Zwlzp6!AP$uqKOfcW!p%yw!{$cRMXYTNiY{v@Y9yKirM7qObnu9t> zXrE?c)SEcS(h6CjN@I)>2qx8J$aF3Qmtm@9rDUXenEi$Tv7#pf^u8k!nto28fLsxy zXj0PFq&nh>kU69QW7wG=s09?cg<0dPK>>#q3{m;W4vIv*wlGN6CWLHSpQRb?gd7Wl zD9QhEKsehS!w^ogV_)lGD8Ax=IKZc#05dlnDpNW{R?1=NPsQu6_UYoy2UqTVSn2NM z=$1XZ_Z~TL@W_#^rZB53{ngd}-J5oGZmHYFvB~ztcz1lu=;$Z|H%)(Iqu$uu+-L@@ z6ev|8g3`(_X=QuNqADW#Ajly4=vo0e#sY!SIp>j|KW8scL*5oArhx&0o#T}FsI)>x zP>M-&Vc0oGM6M)qY{`YAN*q~diO4xuH%)&~Z)~isuC6XWT3uOL+w3=k^$nwRuV)IS zjn>v+V-bN}si-D$;%8IET1vA%xJ?2?Lh$+GM3$V53>Yg#wr?9%7iek*DpWWn$0?-{ z6p;q4$QkX7Hl@-fqjsPH6me76N*k?ItJQ6{yG+hD^~(-F#(fOgxZTc zP+$HqhmXu{5@W_Pu}HXh1})#!9L{Zle6y7oMcUFL><2UWLxd&i!x4j(LlgZzCkG7v zd>=nb;a`7(jPnTr#PPNs2K&WOr2#+=bxyM^xan{pA_Jl>Dg%fW1Te!)WSN|`jU(#! z2kUDO9^AjHz-evDcDL0T>GeiNM@Pm+MtYrAyVEL*a(t|}WqfRAX7a$Xmp=B1ANgA4q!56b3Qd)zTAu}TG-*>psdSP)L7|6NVr2(S> zRKUJSQ1aZ#m`AW6v1a0CLR)2D21gv>KUX=V$++`iu>B4E1}I4+%n_o%Q4H`gI&T3l z5l9EZ0|R6TF&<}NJB61K8Y5!dqV30mfq@k$5ZH`icB|B2e=rXq6GdDZ&R4`A@sPbs3v&W>WM+mSz$=f3X?|0 zP9ST<%yAZLjm!)Lj2g5;>%PCZ# zsuGwRw^lE&tt?%;bMYJ1=*-rGPaHb_+{qU|yZ&hQ`o*(XK6q>C!3|JUc6+AP(pnQ? z;JN`7`&vcpr#vHqDHIk&yiu=jTz&h&wfEoq#^mI#BYRIg^{Joy6N>DO3m4A6`@Mzx zSAkZFZdbKBU@GRA7K2(T&`{In?5#74cg|fHncTAf*uG=OkG}BnmDvZEPQQQq%9Z}3 zho-7py&j@@c=Pt`joW8-?l}JRvtRhJPpqsydiR|hZ~oEiQ#+=2?VFk0vF8W>@4vYC zxi6mi(r@4X-tW`kq3L$HEZ7l8j|eCsg$$Mqt@x!7Ne9Y7@(wVBiqWe@e-#xBBhXAm z5>kv1quDqC6APIh1Q2W8arWiRQ9=xf#Ar*n7hnv=7%sS|v{(!wbi)*cr2FL!3o)Fy z9Zs$(i6c@Z(*6)Oag<4DP!zIEC>B#6q74KTx)>h&!8leJ#U#coP67d#v5kP)t#IhJ zzUv%<*mOni@*zT%4F1YY6TQiz=SaJx2c+kTaN^(DpbuS?|CNKBOl8CO6WbtHWZL2F zF=kB#88?5K#Cvk{*l)~knRNn(UIZ+_Lwy{2ImGnjS-nwL^S1&anO%tSPfp?sY8>=Z zhCbdkjZW&MXii-7i5irImy{}Md-_1eG*Y6FIg%l*iZflzM%o`F&tG3r-6@&{m+pYhR$2P+~&=lg;Hh>WR{ z=g|FzbWl3ZamsMeK);nv<`Kty4aTq>f+4d%<81)|P-R8GI3Hw|neI!RN)?gZTnCbsTukJcZq+`Vw={n6Iw)~#Eornc|gv1`xX?PFst za&~Q{xq4y1yiv6EmMzuz_{hlUNUuxm27|gks0Y?L$5v}l%E(~`z8mC)piqGZA|}NK z!8z6jePRKkB4FD%R+Ltw{SZX>fyBaC5iua@P}0w*lK?PS=NwTHz;qB%%tr7VE^2EB zgOSnEk#>8ztg6MOmCenj-`_OG^g7y=+n*`8w^$I@egM6oNtHRUzb?vtx@&HAI?hpINao+L)rKj473=7&I!?umd90+LR+B zolyYCwzkcn8T1GJ&AQ%jjjtW+*SUN-78{Cqg#E-G=rJ+_#=;+oFOijhZ!1Xj3L?Yl zuK@;TmukfYBu5}(X9jXMoE#B&4q~#N*|4^;=;%%xrCKZR1>BH}%Hc?83u43P@>Fv^t&M z=*Y;}*!Wnl)#`RStyb0RcE?6~6BDE3TSm7`?s^^$r_{k@pICk{t!9z!8 zwr(5k25@L?(;wJD-8c%zk@|UP^3TPUZZb_Ry|lJl&lI*phSej;7=7nW+b4Z zF#W;CJ8!-Vxi}v$193OR+M`Y4QvzfbB>a1j1;c?)IPIS5ix2h$_6>qTbhiC&Pn$ETx&1E#`G1)tgw zP7hCx62it7kOdr6@x;#f!uL|@fyjzEf}jp&mID}xNh8Ke6WmyZKuQCkM$ibxsFu-C zAp=-TwR5#|jitts-yZ1O$qcGc@kn^3(VrDdOk}Mia*oMSp;fymI<2bPD|_vtU8%Ct z1!AF<20&_D?UZwc;&x?5Cd^2$8tbX5M5DndbQT*6{Y@Bb@S}$6nwpwijgF8RokL<@ zK@$S=<8c83Fe?ZJHR8aeh&e65duJceNa&3MQ}|s%pf!L$+lBqGBNIeggPqV{qHe*d z`|JWI=~OBrWaaXuAx4(sx)`WYj^_^}_ydb&Ow+mY$}%;LvzCox4(G-yW&kOm;9^Q2 zq30Iab0Xg1i7i3}3eX>iEUFF&q%~>fw9-Z^W7sG#Mj80Sfu|+2XfG0=z+ zZ*E7@LLB=#rHlYtE5%r9OSIA~tlV9?f9Les(_`JynO*yj?LTn*=@)*m>91bCbpFhH zZ$6y82DF0h%Cst77DVXR1(^Nzw6I$4;anJf^fQ)|dX@$lfL-l2Hy5w2E?>X35bN+yro{>J*)hDoX~b9|b`H{ccfW1Z7YcY{(I; zt}m>9_}cxe@3gv;r_?+^ctUz)%46Q{rYJ2zhYDlFdAy{@WS zz{J)90Q>WUL-wb{HgUp;bq|gza+VP3k3676PGMR?6A(!A4bESt4CCtIONjNoF*HhXfk2R$NZRdhn@>5eI~sY&IKTUk+Fs81_(aZlmq@e z)r<`6GoECM2Q83(%6eInR)C2@5haHNX+B0PG^C5s)`h$Y#x!Uc1tuvw9OW2OSN#1| zNE}9aA`1YD_CrqCh#{g!6cUj6CISG08P5w8YOku@@uikTT{!}SMin+qEc)Qz(vz44jxt-E&a z+P;0)_U+qt?H_02t~a$KvKWrQMP}tY zrNFPtl44*0rO1zwMr2|nMx(U?M_M8JqaT<7F&zE^7`P%L@aJq00BYq6@%t%LDAXRm zF_Qscb|GN{0DNf)XfwH{-RV?SRhgm$)Jsc`Hv3I~bF(l-*O;;_6fht-MoY#L6FVQJ zF%!yKNf?)~(?9ObHiH>_ z#WU};2t&d#`rRyHZgI$A3dbu1W%e3M5rTO~9N0LojwH1@C{+gW`L+yfon!Lvgo>%w z*--M-IqMHAW+?^2QZ^*r%t1(mCP@eh4FJ#($OA7wDc>*YARLf9`2veYSrt{wn@Qg! z+YD^e^!tO&xyAX1_cV~wMNw8|yVLD-yWQUC*x2Yux7BJHQ%-Cd9~^ z@slTB{xkpWUmZ04mDR<$hY#-Dx%vJFr_a3e?&S|JZ`R4-D6O?IezQb4o{^;!QZFP% z>A|}S%=YF=is2+%knEiw8VFQ{3g@)?L8Ja)b7OPEIrs3v!%G)0ef`yMv|8<|s;a8m zxntL}&pdhHz=3@S4<9{peE)&{Q(H#-bZKf^H+C?v*1DkEN+F<98pH-iWCZ|2rWjMx zFk1WQ&~j7&3=9- z92>qT>#&TT>h0h^`6TtJo#5Fnfp8bM9CKSz27B>0se2HxYvaaLkUtlA}-{6^N-GIAE@f>QuU2b-NuiI#!PL zbf;1!Vx^IpUCpj>1cU&U;dZHdy<()JI&IaepwP$``v8{qpW75jWfUm{Y`WOpaIYTT+Oy-ZP;1A z36p{+5fWMcR#yo^dI z13U8+fs;S^ z^Dp!_makpAchj%ac`wLK*qT5kriKvOv+42z1Hjse3yYy}3u_3hgF z?8SxI%QrvxT5oLo%z=}8k3DJFWN&Ir_st{ZsYY%Efc%uAaZRKEGf}Q*?T3 zOS7lHHGB2UnSCdp{@6=TIk$QB%DsD6uRpwcZG8LIgU1j2@L&Dw8$bKAXI}l*`LBP~ z-u=L|*t9yt=&XZC6$W$f$0}?W0!Y`bVofrZ{zYgIzr@+=U_mnTlw&3dcJub7_i(5IEEmeA zJb7?@?Dy+N#!?LJ#mvzg3qd;k-OO3V=@W)qz83$EwizHU5kpK`;-SIGhDi?o4$X_3 zae_j|SU&EW5x$2X>n7mPuu5yvqlN_uc?09bjG{;YJ?pk~F%nr37-G!jc&rU54h&^1 zFmxr+9ikh-hh>dnL=~o36nQcmVWM%^Y!C9-I6tGBq;89JK~h>V6fNZ;CpW|}?2nc`|V?{(Agtyk}T-@WIav%}ge{8o~{VfArZ$c|#5*pan}Yqqp@D`<)U1eiO( zF!Vq+o2NDnwM+~(N(J&jRoi4o9b@7oyh`4)FbX1RkBA<9@WgFQn^inMS+3eAM=$o? z`|!izc(_<>-`Lr`y}h$lSMwNYwW6aJaqme}4`DI)+qWi*#kdG5Hml`&yIf>n&d2>Oo(I`^Sz|4*vup(TD zK_C$>i?Rx3Q8+|KU}EBn6lv2mbyW|_3TxmE6HrT7C7&cuL-io_jF~(Gv4T2z!O&q= zaw;itB#Mzs0Rp^pvI4m>Eg}L@LPrcvDwIi`GfEq7e7n8?Cw+v9vKo~2pe(CU24E+u zB!F|S40TzLCIFP$HnyA1dflwnZQM|b0@65DwkcV=29(u7#STD^j3`BVdJ!ZPUL2Nv z=8;<$iM^03bVw^7Z4<*JtzZZMQ;ZqiVCm74A~Q?fTj?xA#3{8&N`pjwUyy>75_4if z_^cSJSXQzpWOZUPgVb4(oj*X9_BhB6960Afs6&NA7*8eVrcJSlv2EM-?EL)X=m4n= zKKP=nN0Z59KAuj8!_jOq9S*9&XfUV;TZ{Q(G2L1WC!?(|edYCE`qh7NSm4F^@!tNk zy=Twwee%I~zw@1U-}~UIZ8E!Y&KE^MMDHCjx`Zil#yp+Xw9!f-78Ebj1-O>Lq$H@= znXE%{>hPB)(|``Vb5a|U6J1?iUR_=S!1HI%e)OXslx0;E)o@hb*xq^L^*6rwr7wNu z%U}N77e4>m>u>Jd&?+G@!lr50>rLCXmMsC&$H;l*ChFM7o`lzzsYGP@RnCUiIkH)# z@tybISuQWDsv>Eo!^Y15d{=glV713bA*SE~@blBn@tW9pcdS^%vvkwtA! zB4os*Q!}lHi`tYK$58&RlQg6RGN6&)=&ly?H-DcEjneN3BeuXbku=;5KyPZ%CsL`v zqDK&1{zt!V^Jivfo_|1RiPr!q`I}ZgM*6LQ9&15i)WMP?_M9rIs+T(nh1PP6s%~;( z0&N!K8~`U~X}8?85s(A=y7H6BaBE&J=59RlqY8@xk%1C%+XAM5Pkp3=zRciDJE$`k0xw8OOH$oCbb9H1y>f5_`Hsr<^J8{!v_z4{O#fV*7m19 zfBUmv`m^8q6fixnR0SJ=thXjb|3txCj)KWY- zesFs7@Y%cH8O?7k?!5KNo1gpBf91=(zIyn{gS#KTzyIVuw<|xWiqSAs1ICaNBSS!s z%va|pm+znM-T!DZ+1mZg=U)H%H@^JKfAr+pi+k_B^Z4Gwc6AZz^73f^y>I{HC-42_ z_GiCv`?GI->a91~WlzwzcPfAp{Ycfa*7|Alvc?}tD6FaKZ5kG|u5 zbi)xiq^5;PT|kx)YnK?t_J%1eY`@E~Hk-<9GOLl05cOA44_1C%|z^O z%-MkHbjBXmtT3b6MW9z362SRhnX+Y|YUe>u?=-qqa>=ZOY*})}KqXvdjm$YP-$q~x zDdbD+uyvW688ZW0usr?DJvF8TfGusa8-Sj|erTB%iO6_M-m`q@Jx- z*w^!IYy~?Ds{e$%JJ(eC?$H19U-;4wm@EK1P01FekNy1fr^`R}W}c1`gbo(2(fswnV2-=V$ESB-?YiDBA#{LP z|IL?2q~Fa{Z3hkPB%zULVsyE~OSjT_Ic&tH z#UZkm_`)obfUOFhffwC6T(`i0!O9=@$p_7TGvT2EvbQ|l{!1=2GZThlbo|Bnq!tWGEM?Q3EM&qg(8WS>X$Zv`* z&1{7~SntN!$c50oix3#~&$hbVne-8G{ktEf`x0tpEL9eB_$vat(d=1@5Akbxs{vq?=GFE3ZI zZJVZ{dJsYpQVNNx0_zqVClv=|xulP*s{DQVrW&n~lx1QNk#o{-G*F_HC?#@8BH*0Z z^QO5<`OuUo5|GI(;7A1E941KUVsp8^EPPRhvZ$(ISr3Y`450u70%(a4oGZ$@9MxmS zl-g#!Y1Ykp(>5DQktwlD=mMft(_kyAqI8m3EvnNO!J${qVWD3(aq}K4ce5J2Y<@+y zm-KgGC@UgLoREPjcgoE@UlIclr9?4CtD8!xjS)0uMw{nKUlr>kV>i*D943xR?iPV1 zA>N>fBs=M|3)(e|Ol~;meF#|9!d6piDa9B!DYng~xm+$UPEUcNbKpW zZ<=PkUZ0*GKYRM@?H~X6H~v4rIj9HqpdJk;ue|!o=RW)SpZogPKKr@Py!F}7-g)J< zK^*`f5yY6h6=@h$7fa%FCOM-7AOnOwEFz1*4*Af(I$Xst6P0f!?5=4;*FroH8MS{)H(P)X zdNNqpps6lgF;fbvLz|`lgUMN0Q3UPvi8?J{aNh74(0fP*TjCg7g2dkYvUJnZ-`pCE zCt);l|PWsiYFNT%NB4OS+>y>#Vl02u}K)KLxoap;4ls{|*( zoWKbfkR;edbOR^Rk9wc1KT)Po)3X39VJl-$2-x$9V&`uaa$)S~E{j1xgcP~iNFGVj zIztS=l!O|nr;uhPlbO3T2yNk}<3|KWLPP?Q-O{Z_03?adaL5#)ZL!@zdj;(pVuZFK z_S6bijada!ECV(DP4zxzXg5Hu>|M^s1nm!HbgXCtvxy)85I%U#YQS!Fdk0E4oU>Vn z2mvW#7@$WffR;}z7}r1myk-z6XUWHdMH7?*>H>zq%s^=wPp|e*4-P-N`~J5_(>sfu zPv3s!&0qNCzw~Q=`Y)dy?cKfm&i%VTIXQR)w8Xj!!%xCxHP?xOfl*EuIt&Ql0eIb9EuY^#+@Y%z+zcat}sXL$j!dL&qpZ><5 zfM*ZxKY0IxlY@iUY>u8jJUe*)_`@HKcV4~u*5^O}wa+`3_75+f+3Z2uQN<(dbqu1j2vyQd5v^3*`&<_dI>z}!Z^A;cqz!hRFG>l(G4NX7rR}z0SIC{ z_a5FKqm2jiTOC*GeZU>(Gnrs(-Hqs_(*f6B(n0as81bjC$^^kZMJqo!M`-Z%hrCRP zcCYJW?6JB0J-$BXYtQW8sxK9LBgi5Ej0N_^+*_QbbN*(XCV1Av49^IC}gezuO6a2`&~b z)nHELL!bpb5fH5%4D|j+o0(BLcMoOgF#*j`K{aZGW^?!{000ogZbvxJx%UV~>$~Ff z*Or?*7=?i?&rtGTY#{lpC{U6B5IBJtlZiPo1gY~1M3JaHSsooP_a7gA=szlFqs5Kw zS8m<>)UDlHJ2&SJVYN#8&zh(AlE*li<7{g(n@zSSL}_!eUN1MBbxe_LZ#_y4YyhAf zvgptQX5NC}RG?`uOpxqgG2e_FpyTqQcqr{<)*y`^QDTS%LP>!0NSk%L+H9IgZ4);! zwoF3@W#L<*0;npW3a*VnkTnBV3`}yK+31Kwa}~d#sSqFlKq5*pMk)d^kraV*NGxth z$UqbkNOZV_jvBo?BytW36H#(5rnG4{ZgX|%LI_1&R)cCds_H6)fZj1-n-X$z;6gnv zhhrp8V!m(Io6V|iRzwkj!9(!ChLjm<#sKV;k7k>UbDE_yxrbSXN)j6jaLA4US@U>= z5Tzqt4+>$1+~!txR6di!G>UwrBzZBMQk3GUQOeb(1OzFiL@^NoaFS*-teG*yNjn@d zk?GGe7RMk}SVYvAs-lLtIKV`ELKnObzN}=vL`1QTF{TtLQPZ}|v$NylLl5_Z_n{mP zhSTwEHknLj)5&Z+oQ{XX(P%Q6O-94XWH=esv)LPe?AN~f7ygC60`24S5WKA0lHeJ<4bl2tWJDWTKa z&A~Ygfrw&^lqfdMS<{@Ip8@RMyZ_1W{qFDlz43TZ*Y#j9pUyw`xzB#>t6%x*H@^1z z8?V3msn@r+t#J%+ifz-z*hY;VlbEcWvSCTi#!3N^(XlZn8V~Dl|L%AG;eYY>Ls1Yh zNY`TLh#82S^S}CQzdjjNPoEqXz5w)lCkuP#{MfJ;}blPjn0f0A>v3MZ)24{6pWj$*somTgD&O6lLI0nsDV@i>MF%(#aP#3Nq zgvq!ZkHc^PW#I~k6p30wM;?=BEWr&%ZafOZ0S+rHJaEEh4eK>5FZr}hoFKO3z;wdMma;+w&M|T4%MmX^XLFF9gWj9jGuuH+oj%CDqtsU|JNd0^%EsAANklrT zID2kRi(rjrFhxY$HUTrFB>6R%BwXKhN$bu=#d?bDu=Lbyd7KCgRL67H^M!oa1q6r8 z6eEFWqNIkBwy?3{nmvU!a|r=rwEiu?`Ai#TqSbzN zgY8FqbvDD`o3L6#AU7EJx@Hu7Tx&*Y11M##Ssz7+ z%XE4`r$lI>10+rn5>sO5Tv3ne$<}Q5^&78${?;3>4XbeW;_1VW-h1@P`07^qQO6VL`Wi=cvrn|50zVX=`uiXhihfg0rx%c6V2M<>3 zi=r-z!Pr+dCzqIkiQ1N8iU|fH}2mPE&ak7IiACqm0W5lh&=fk z<=ChgGyKs(eJV`syXMBsmRugQfr5y61t9;Phut4{h6eilF}wBzd@)E!uUEmYxKci- zU0y&&H!?nGPc+@U8;m^BLg9O3)9?2LZ_jEqe%@cde1Qqz-YbiUjPm5A44qD}> zcDAC4{_f2l&$;#>y9FC0s~@8+_D|nD(}_LmI=mt~*BiO1DAz9Q596ob&*Rcjo6&5s zaD8I#eMemHoPy1SLj*vbQfw(4f~;oU)o2V z_G#lb-h~X7^q2p$lcDxMUc({qrS+77`KoTOmb+Uw{{6r8e|Z1t)*t@(#jy5rSJH@c zkny8)lhJT68XoQ+>_2_Mag7+oz|+VNeE>;`4~@-`dIJC%fXHbqT8aP&fTcZYSA0P$ zMrOc5JlSe10@%+0ps|arZCrp-ZVoE7)Pb`#D0k|ZFhKCW2p)i#V-><=RE?`aSrvtM z-iZbQ1Xg^337L^y9fqT7I$!ME*m-5==Iis>tPI$!)5ZDb;vxdjc!Y~>m~W-gI3cD@ z+gz2)A?0+3%V||!8%TJC54r4&}K9qyH9*oA5`Fyrm%x9B9Jseb_s3)`W?qadMGvD5x&bOw++IwiO zRu{*o$B!RAdhh*re)5yI?|uBy;mHZ)7eZAQhz^|x)^HUKPS2q)FPAJp}5G^*=*I-1^j<<1ws@`bN|(-(Vx{?Go|4?cL$sktpV8OTgc(^OUUAO9!+ z+0XxxZ~dLW{rijQ0-gT{|LDoRy{fE&M%YoWfRRqH$KkIjvAt!LWw4J7R&bqyl9j;j zx;`e0dJ}u5NixWGkzQb)0)uKz#G*rs#L1hKbt2bIL@&u1E}9Ey;7FFt2w)w5de7Fu zTld?Z90#?d4dD0a5IsN+;AG}VDG>$l2W1!z!e9`_<6=Gu)2SN{TouqeB%)@`sbwa1 zj*H+2100TAT|ybKa0m&S4Q$rbY`ATB-EiA*YB5Fh-c=QpC3?@Luq_Wvx}^*tOhOuk zFmp{%gr;RphCo}ZSf5(Xy-ZjoA8R=Y20$^EQsPKp^*sU1rct&)EUtVN{f>)( zCl~aq|4Q|*{+00HF@OI%#0kMkpKc&R&GRE50!sRaBNHJLNW@!883-1hs|u%7vVa`o zVP;FFUk8x&Q=u2dEYt&nL_Dama(wUotE0WPD3~BAG%t1xSt*yE(kYr(2GBD8mlZJt zjk1)=H;B$5dPFHXp^a|6r^H(e{FOg7-hG8HPVoExr2XXGRT$7k+maka2LS9<@MC@m zAOPWf?*Hg7jW!MaSO4g&r8fJr9BSl4FW<<(MrLiVWNIh&l)ApwsP7npVc$oGjDO~@ z{D*0E{`Aqkd!PKIz1Rm2II4YBqc60tDpNi)$~Wu~T|a|;4x`7=+=FO*FHuZ@KDcr+ z+8)nt-FW5mH(z;cdwa)0d$9NL-bX)rbno5e$#d}N>tQh(p$~wl9niEnAa)}#bjHI#e)8hMM+Z+HHfP7+p%~RgHNc`E zaMu0@!66W`g!2Icu(%u$v8w9fe0zNJwVl^LbMyAi0l4G+7Y{yo=lT7+o8={zA&iD0 z)EEi?z|^L;O^}#r?ETu z*9KvyS?Phz((F)=4#?>ZngO!LeC-)Mqg72PP*Vi6V$nU*&rh8`j}>k+Tr z-|WWj+hEJ^(!77_S5@qPmhlgnF?3cocjhE_OZQU`=)@<(%X6{TZ$zF&x;_uOn^`~#cUjIatE?wJ|byY_*3 zcDrHb$=md8kA;xBbZUTp_%MpX)`<_57p|A4s)7{|v}KE^cL4os6g|C%vTB#>8(TO2FMsQQ|K5|WAN=^mXdvklKsglvkl8uJ(ReTz4G;GY z_n$texJHatHA;^#NfCL#O4z8Ll~5`y;Or)I)=*T%KJKg`vIgdqd*;9eMGPV z8qy=K%Xv}XnK_L!GQ!4?WjQ(cPzKMCfT;|AI4H+;Rh4BCyu`YqF=sSMD_KHJ;4paf zRWTe6#?Z6p?ILO~CtnjKLhT5p=Ovy10@Cr_Uq?4O*p zZ5j?ov&pCks8ZkQX3H%n9z~(DMc|z_rt771P$s?Gs_N! ztc)B)?431>2O;w-Ye0rLXANFu&AkzWrGqml*F}|jAu;0|QUZh$vGkNliiog&IZ2hg zL5UxQHa45MY1t@r4hn7={ zMS*oy4Tht84Q+Y_=AQ4?ceMkG}okANX z{`>ZbN7!3We=DNh- zfLpw}gv$$FF4NTouUBca=C*|tfe6KV_J|(QJ69Ais$E$_Q9xBWl$OveT-qQHRuF+1 zQi5n*m>H0zwYv!Le7yo0M(Zr%xWhg~X%P{c634`eEcu!Fli7$Xg5e)jv7ez75M${QkGwkKS1pLq6YZNMc(&0I=8lWNssI zc5Z84{K7XzZA1U$cTSoVStiVgNCpJi2{7{Ra7&5pLWv^ICsg1?u=Hd!Xctr=AaIVu zd++@4&h1Zs_3OX>Yrp*G4-cPwa{r_I4?ny-dIod}Rq4l-q%?6dIIhz=`1)Vn-2u8? zWaCok5MA&gFcZ_}EowQ-x-$E@$A-g`}N&dKJ)o+{`sH(wZC|Ay8rb4hYvn} z=lI|;#1&Lk7z{&I0~B37)O@r%|w8I0zG>GpW{ zwZ$uMeDzO#y$E=||McnI4_`dGdv*ANb0S^6qA)HbWb zCl5~cpFODtqs7kR&g&_-FVKyL5h&1{EO&%eeDD^ zMCK(EP)JKzenvE2c3P`u51kRE%1%xaXBG@Qzi+qBL1w*ThZ@#loCbSCAn1xw1 z66ez!6w1(5_sxbR=P{ct##RAE!4>6iJ*629^R?29`OCt#&8*Lpw^8(?8S*50C!+hz zL@B;}bslfG5S%~wsoP)sP;QZh-OF2RoV5$-xpoikMr+6bU;C!c6<&IouEl!i>lbk5 z2$^6YlM~yBIN$$gjo5zbVMn^*MlV7aW|!GFEG6To7UH_|KO<_r^|WUTqH=B5z{sx; zH!!fpBtVXy${~yvL&9n|0wD0u7?!DKKefgk@?cb8KV|^s8f*gavsP0XHumAa?*4K` zK2k>FSyR-bVW$SoF6*Efkp)5q8Ms8lFr_#XqzHkBAP zwODgg74FyK=|&x0W-&7fL6XX5c3|nV)UjgLW74w~^XV~tO)Y4UTQCEo2L3V-U_T&D zx}Jg+iPYn76-t!2v^)b7QTD0m+KD5~p>rM)(K`f)+_uf}`TB7G`6 z;GAM!n2BBI-tk~YM9NJw06Zc)P*0C{fDnSO>v}vI49A1&@+!9L*v7Uc?-{-HwdLT! zJ8-~B*PoS=O+qmk6emnd_m`zK2Qf-?j3jGl6aqPt=wU!+wTLJeu8@cYkaj>!&LgsO z-XW&MR-hp{1ajz{bLbsAb`F66Vu}qmzHwLH2k(nelvO>b>bfioUlbr=;fWHkbH1*J zgTWABmST)eY&M(qdL3g!)Pj`0@Sg=$VX7tJ-P-Y9Nk_#54&S;Yx005H7!VCc3 zcU_S(^8#StWHc>EhgK_P%84x6>6TJT8o(jtQi)}WGxR|zQUV4AL?nzx!I1>ODKT@h z&=3XIfLTlR$y*8|3JoeQA`@n2X{#&s0G;T6sa5$VyXRo~S+NaN|KSh6|J_g&OpH#PqA8;=A%s8o=l|khFnIe1&r3gU zH*kDT7fV+T976(SseEk?rk(X-Jq&OVnw984nT-6z`J&GMrk`g7LsB)f;gsKZ8M4TW zsQf`)73wMN#bSuT+5Orx0&NB?1x*$5H|0XqY6Pe9Prs#sa}F86I~56HyE@QDwaAF* zC9*Chj*(-GDKRjX-VX=Gd|J<^#bT?NP5fx+>Jo!P51b+qM_4CnT?oD`eLeA`i64%9 zT|jUUqg!3UEB1VgyRans208P=Nsv9NM}I(%A`kmSKx&=TI50 zLyXjA8mO9Yf?YU`LJ5pnLUsgVxL{uq76E|>C_TWl>@YRFa}!y$h3?4MO|-ILUy4DAN~Dj~dXfLlTME8QjN=jI0SX0F0Q3 zh#a?=8c0!KSq{L`8J^8IOeoZ-bdNyj&?BVCag9v_%#_FkE;FFD16L!XQ{7OvD5{ny9Q1cj>5ZKlAcr=_%c3xfF zd~560r{}X#TAv?0z4!Fd$Il*r(p(&ZcVRRPb&WnSqiPFc=@<(#og0;1A&SEToC9Kx zQ5vS94`n?Xju(TiSH`zqpWnDO9*umvIe+o!@!j`cJoqH8&Rprs(Fn^Sqt|MfM6zgz z2+pZSkRlTU`ZCnR+0JPD&h+MM^W7W6K^ZSj_8#7S@#OyH(O%PTuqb^wECxg80}-`t zq!_&q^>{Jb-d)^&ZN9TpmF4RE_~hx6qvv}U7blRy?3G(L-}>~cpZ(l)XPdZLZ#Wde zmj#99?D^9NfAGD#zxSJT_Xp^%{CEZ~aAJyytrNJEXG7DNdmtLDGGRcd1P zKF@lm`gwW=aL)wlzxt@hHB-+XuOl&O^pf3W4}uKObhl8ezO&H!_MVLkyK`d`G@H*R zV>-l7TaawYJJ?;@UqpCmvSvVM#3YXv;aaVRyfEDm^9JSl{j|=aQd%F>d%tHDWL9T? z^?A4rCeH^tu5VYErQh4}{))Ubnrn}{HnHpaLErknw)oem5b}5X_s%BwORLeFko?`5 zcGtxg_E!C;?_=N16Y2nv{*JND18n&*<4SmaiS2M@?WaG?jv%i8pij&MnIF!2;Pg(| z3F{tqcx_(&1?Vkk_rU&mx+MT(q+nvFyF-6BZK=>=F~IPpOcMHCQO7cikg^CGK?#VO zzYI?8CROmn>)_-9`&%@@?u)7!P=_$+HZmIxvJqIHXhp<=E2{Qtd2{>L|LJf2@7{m1 z^@AVnkA~hcGdkTq#zfkfax|=mqv8JZgZ-z^Ijs?;BnD^W7l6njam6o104PBpYAA|x z$_SL%z5>|*%j0JaJY)+7V6?;l5Y5BlJM3~vmZ3ZzhGfl47+34&`1IoGi^FHn_D;{Ph}?KQ8V&{wyjjQfx>;@F+12KJxoM&% zOaSKNaP1^H=N*7mjbqe8BtC>tm1SMmgKAJ!RS`l_6wW&h`2fvOmfcM$#l&qABODN% zEV2ZX0E3m?1@ZAJ@ZlV44vf?CC*qCMAd$j(ABwUli>e+}by<~#RH9^xOax4lb%Tfi zL~YZy>t?fQHcd<|r3jA2qLSDROXku%EMpMHlI(Dab!?7Q*(5U2V#mbN^q9LF8r(o2 zURjCKCQe#0Sm`U*43T6Q(cF^&9Hj7$1&pMWC~=~wl|dvTgc(vIq9_U2Nqguj6q2NF zBirww`O3yj3>wv6%|nYl8UtV!?LVfrY2#YD=cW{6Y$zrN$$MWERZ$HGqw#1w8x2SE z*>paiZY^f>`D|-zzO|UlW`oIOG@g#8!$DE-a(VIM>9dCq9zK8Z^xh{Q{_y+%?C#@7 zeXd=UWmy&u9RqTTDWw=&i|`lCuP4MhO{2&Em969-?ISZ)q&=|EjCW3!AjX(tOFho& zeKDC%#-q`AI^Ml;^UGiQ(&s+=+1(pA4i66QKfM3;Kl|ZN-h1cd__!b0U_43!K;?)I}H!%GtEqS`@QcIUcyWa)ozj zp;pYvI|$y@wI2`NWa5ScC_OMCpku}up=o%tq2*<|x}ataO$+TBQi7Ba5;Gx@?Fy@? zS8WB^J2m@40i%H{OD-y|Yn?8$73`J+FARV%Mo0uHGBH}UOeVG#QzHM!%KGNOY-J;Q z9)UGeMr3J9oHPYSd#$P(rL{sdNmPl-aVYTkf`0j5DgU+qcJbgbz5RV=MsQ5TK&bI1 z2_gX{=7>4l1tbPUZFvQH5VSKG0Z7lY6s2!Orz3VACC35LJM>Ns8cq-!YSyV)rDlb( z;q`{fGEIlDcd$M_ZGu-O*3n@X?xbhJ*{&dV>Q{bW1Q}YWp%A#Hy|7AD6d=p{6lrJS ze)W${=ew}J#2o5&2v1gu@$2obgD2LgZt@BH?x`o@=LO%s3j2ZyWJ zIJL`2>S|#|3Z$5YGat2tEh!sAr?7?rE2bzQs#8qPn?tnX9UR;+G`KoGKRZ5t^!Uj; zlX@}Ty1m$X?dGjl|MXw@8qoUb)BB&?`|$DOd+Fi~5O7%fs=%TU@>JrIW8Dj1HzuP- zW5-eDL=$VEiVv}YN4vaU=Yd*oa2Nd%T@pB?7>d+=fnVrMd5@QQEE3AC#&<5{YU=(4+q2P zV7xWmeSP=Uw|?$V{kH|&mZj#M}zs!{Pt@%KL7Kd`njT6o*h2lKX~@yqqpCC@czB+Ten|%>$R;{Ul~tJ zW@@=u+(&{^-Z=|MowKKly!3FZ^`NRV5Q}n^+A7R{c1yjNuR2mdvJS z2U~E@iK@SKR+AZFV5mrbKZ7g1WD@{Wk)x~PhO&d2Ipw+j%m7hM?{Lsnk<Bt5T2p_-hAjWAi&dM! z{`d8BHi2->PQO0#{#`lS-!H9eC*iKYI;s|Rs-qRVE%%<RVUpzSNO^HS$~0gs^7J#TtjzWrqS+q?xw5yfLXoru@QI|fz^Kz zpfyQBbzT4`Kx7Hu0b)XM&dQ^T_2f`<9`c(B)gbG}CHgq}tfttaFxweo00t@2)v-aF zB^vxE*1zZ|dT>>-^&VBjwi>92;`Fgda)l98I!i|eL?mX3V9{1CdO4EBiC6#=N*Zon zVk>Hh9WCe#z*sYIZ-33au}?Ce0_H4^nOXCMI+g{%5{C|I+)De@WcUXTc-RqGmh*yTFN>mR466XM1N)!?Ta;Fq%zwcNVv9+_<&1vs0F{b(7A|Hv1oM z=%c18dA{SfcZQ3FzcB^CxL&W8tIcNJ%4VW-E_eWB()=cqOLE?NcsMB_B$_KA5owV_ znLL|uRh7lCuIsAuZk=Lko3;(XI}MXWAn(XKM#$+8V8DusEwOG`1DBvD^#u0P%9~QG z(|ey%0*55I)`$@0u%hL4km5#?+st~F5x^lNGw+blIhTkWg7b_iN&JTcB+?jU=NvN; zB}XpC&1UUaSC=k?qA1F;tOs>n*JW9X$!TFq=*ntPm%}jwaBAAN*{nBB+ioPr0vvlq z$}E1G?XZgp000go)>Md4>JlQe0}nZ<2tlY#*GwiUv`dx<#*E%#(3t}RgOnx}I@B?< z(JphcfEle5ED13ZX%kaQDH5faQtl8V!8j>J!%B!^6cZ?=XuV?u`ba5h6B&(O<^&nS z>B~v&FAB-oyG3f_Sb*m^H=3+=lh5IPoF(~{NTZZhj%~v;QbGN z^zOywRmZ)ZD?;#~CbAuNJ3yqT=p}%Ijc>dpq;-oD0470Zqp3v9?0m2~n!5g{C&#BJ z#{lrbd+-1L@BHp?G^nbo*)*%wx@{W(D9VzVIi=37(niJPefZdO5wrdtFkUJ!=8D_@Uwm~=?&af0Tq_bhMkKgga81(r_M<)j-0-dO(^C*9swFM zf_B?{KQS<^F&K2eDJ-wmeH7CdxarfJNI3Pg>aZlw&FM6R=r`>qGEf5N zunc}wm-ESBF)QcuFdh3r?Mm;Eky>io*lgCc;^eTb!(TjK#@{pia_L$p=}cmfC|(UL8sg_D+*>#@#>^uT71H}lj1@-`bG@8!G+q2y_ zcW!=aXX{ppd~~$;=)v8mFFw9JeE}|FQ5J&%hQJ_Yv!yUwFASuY!jG8(4SBLMc)Af< zyGNW_NrDy`T~U|A@pN~xcy)5~jqMw^rsDx`&JUhHdGg@Hy=V6}XUE_et5Go=gra10 zEX|@N*V?kg(YaheSDKlj^Je|B^iyMEbfK)rliAJ5?rW19uS|A!>N3R3+?d_IGu^o{s>`&#eDUP@-m`;tvni{3>(!gL zUwdWuQ?~|V&xorAo0ygt`%m6^|Kor3o2#FE8#Ygh@dWBIvrCO_4T2Tev*Z1;g(>Vn zD;}_&dNmlYg-0mkH^C{=-dKoQfQD13?2sENV8UqFtf}msTM@H)#?X!DI$(hSWYs32 zd(YTb9TSPN$dn&<8KjhXN-8b`Zwi4Y;^OHJi$yQo7 z{XR<2(Tx1d`Kv~nwp`bY@%}3dv2Oa=ISifc*Cz=Tetp2GdtXUcDO}H!#x3?ZTULy^ zRps8JyW95G;@T3c!J3bheVCz~%(x6<=m`I%MZjwmr8kCdrA-#uYO2+2G7`G-z(!iV zcZ_nnglAJ3y2clZbz#3JJE$4g9SJ+3R&-5426V!yL4Xagav|#3cC0cvXjMFQD}Wip z%FS}D1hZft36zAGnSEI`m&==5H~)|SxBur)*V^3Q}Z5+;zpX?+>Zwlr?*rvV`e9IH~@g= z0Ce#konfZo9%7-%c#!3$8uw?b!hq}?2Ja+hr0`IeVNlm)5$ZCCUsuS2`gDR~@S3=` z(`Wr@5CE*$J&B42=z-jzu1ACMV!pk-b!&TZdpe#6bjxMDSgu#AD-WCDfET;fjT_}+ zC)5Qu&H7@wyt-`GO#%sW(7u=0@dE}8EH+`mz+%-p=e-vwprc!2ZkqPua&>TY`t;fU z-rn(Y)r3$C2X*kiO|)LOo2Ff^oAc#nxk*W@Kq$Rt`KBa`nNKN!JBQwR?|oU6mAuNT zD2pQa;DVMhKu|1?QGA4LOHnO%%%sG*-vE4Ln(=<&J+eX8uL38#p_#pHM z*_X{~(g{CdU}t6zDf{9~Q3#5vi33+cJ}gu?*DxdlPS}!A?72D+g+PckHj0T8CrgS+ zF+~AJQcT1tr4B2JsTJTUQHn8kXa^;Y0ZDC40(MXmKdL^En3Kkt66-l&IU2-W^^H!@ zqlUULfaE`=7~9ltQfeu+ZA>wym?Cft4vJ8g^>0suU;B1pEnAw8Z>_X%K=o}Iy>4OUZ=&9h|pn_!U(FiqW;esPAFfJYg|?% zOkeB_KFE^5z9A=F%j7#}@=+Uis{oZI-U#wf_8L01;;dW_7{-yc83R55m|1F%IxlD0 z$@WoV30NX#N{ongaDy^T#^u($n$L^r#E*t8n!F|daaf*THTJcdDq z0U?oJuVK07%S*bvh*y_!wdD26HygKZ(|7=HzFJomFl)q_#vl@>WKkH2wGXFM_f#2W zRKj^FS#A&t91O9ls3^G}c&qV+sO_diG$AF4_}~~JC6*j4G6~7%*RXd$D|iZ3QK!Un zM=}fy#E_)pD}mvwJ-DxTXZ;Aldpx;Jzwj5!zxv-R9z3KU{UH-*Y#U0=5e6nJ!kU00 zFe_k<=9N+Hk)UZQZa5_dLXIGfRf0n?P??w`G%MJw;%3S1hT9FrXr6tu-tvkfLX0#T z@q7X=4la(*HX$HWGPSm6HmM$(6<^KQvZSp{^v)qTXJW&8CuaZx0FXTbA~@%L+oqjG z_|-o;8_&4e;E(=U^WM9cgE5_N){VqBfFNGI69ouP4f37&@T+ev+NS-3cV3*gEALEa zMgr~70#3bKfb3A)+Dq_;9u}6NDk!2@qMait4Bkm>AoLyrC(by$^Cc7|Ffqle&C%7x z!P6J_>W@dW+3s|HXKUxxPk;7nU;oCpu9j!do;`g00MQZx^xnrN=a*4 zK^KeNu_VrH)2ckka1yKehUh|pMd^Vk#Z5e49h@J$xa;r#;dnHk%x;diURmtk`SLIS zi$7Po)%nrh(+AHV-96lU+?<|5;r(DxR0H${18U)C5~xR6rbG+@SzBl4T_~|E0RU5q z@#=8zlf(T7)hG2}I-ksLO}FlhZhiVIpZ^+AdvUmT_~hZ~i>J%eBZ_NR1z%U8tkD$& zD8h~b&;<Uvz z?(N*%{?uEqZQYy}BflElnf%FHpZdA4J$vsj-u?D}b^iT-3WpE;aOj3JUG!297 z8#UYkvwhfO5Gp>v%slRP)9jQ!vj8(U@TImX*(~hcC*Rp<0<$Hn(3w8CH5|R*#@?s6pFQ;aCr-DD*XZF8c^46^3rl6WN{8^#CGZj z*qh9(ohEc<3>K#$A|mg1e*t?I`_Fjswe%W^5-jA$Ns4^yZQmHZO`%zYR}Kj?Rg9owD^i-9WHMn%&&gE(QQl zSCn4Fn76UFhLCYK)0m-;DqTN{+llEp*MVuXVRd;m+?GKZ`k*tNuoo%ie`ps7FK$L+t1WS>hr@Uqog|DRJ;Iac~x>f&c>AD4C$HsBE_)9!g@u zkU44?t#gI1H|LAR?)Cz~T`k-F=j#WbEM36u z?PB-#V0(9T`xXH=tIg`_YPH(Lb(C0J7o681@zkA1t-*@k1&0>6mjPq}gb>krR|H=c zh4&r+qz(icwTjm40FW)b01e&*m6j6aDAtJqdDPnz#e-OCQY2#Mk^~$jsbZB~q96tY zB|);BbcfL#E^v^T!C)ptPUIwsT&MxTId;;4L_{DZD*}Q@P75bEbOh);$3$(@ZdS`H zAIdU>qO7X2D(gX2Rz7%1IR}#Z?da-JRgZv)D7DRI-84XRAQA8i1NbZ&l(S{9 zLudw(iNN<5S1|TW#16F53`%Vgkv>LJ0QyDCIZ|I!UTvlv{HB3Ztgwa_KxHkp!rCgg z13n}+1SqAHQY+nI5+x>y2T2++5_9!b4Jct^;H1``B&Rb-z>4P01nmQ3B&7?5fFyo! z=Y0sF!ogVVA%#NPHl-%EO^WSm9nUZK_xGL#jLrvN49a>u8cxUK(Re(bOh)6$XgnNE zX5;Oxt=qSE@7((ApZd}tnT$)HHWz0n4-yVf=ipf>=+dsKqR0C70Y!q1$$@*4iRS3C_tO;UJBr&(W zRzO{QcPh6OR^7qoDsXKk$Y|h|;ym)XsZh|n6f<&eIiP@`n5j$|EQ+ufQ>-;S4A?}6 zh)j?eC~{1Z2mrAv!l)3ZqLJX?1z=BgAc)ZMlEXtiI-_jjd!jZ_}%TIDhuxr zz{LhuD?U4-XUlkY)}EfGt4rK$ux%kGMu`%))Gb#F^n&VHkR*x>9GEvjq&XQdoC_hb zndm0tg(V6aFDN0P$V9UBFdB2NvqU%tP-N93iyKiTg@1FG^qToO$g%4UK{>hUTzK6Z z+!-x-g8>j>dC6_dDS^cLXV3}}of$pofIUDV?ZH)*W`L9^MUX6kB0xZ-7Lgoy<_4P$ zZ#J}9(qLZF- zVwM}5L$||4F|V$fFBT%oz_I1HVUCzAh9!GoWGzi<%4#wYBxDCjiBrola^lpcHcLf7 z4Kqax_dwJTj$E}INyVE?Edi3s1x;y@xGENDg5V^*h!GJ2fMYGL27nBS84-Qyd<7Ll zqIJAjAD->)-zy%D#`S!*xH*~MnJ-@XrB8j)K{`3!fBxeBv%QDQvqMNNlto#Wp|s!& z#l^21l%~6zZKONcnQWAUSqoQ{UbM2*oSi*CJAMui-l>L@>3BX^+#GM;8gIY;l{dfU zfiDl9y?A_g@AfEs3sVg+6si(*Wq;93ArvnhumgwS3g=5#0}^p;E>51G9lZ$m z-z$gXdNv;}Zd8lg+h6?FYd=@>`s(8F+2OPMr!Sstj*h{huWBr-P?iXuMEn6F7Or#- z7*c9i=O^p4lf4J~_h*C2Vz}5DFK&!pd2{>CFL85qw)gDt>4Wp*{pRY5d|8aA^=Rl^ zYA#P-T%7DZzCXWtXXnPv-CH+bee10^KF{l`%NH+>j-MW#JU<@K$G1LxYv<1PVk?XW zJJXxL_S!eUdUEeCKl;7jd+_`J6du15s*M|tpeRxTip)#~0h)!b`=H#)nBlW*wk51KvD8Nog2lI&KBLE5KMfX0D#Cy?w< zXX|VCkz7@PYXB@lV|-|1P?K zK3?#=4kE~jfJ)b#N7Zd=4; zy85jz^M;OUuJMF?TkgJVQrY+b+KEw@Fpy{v0DvmWoVb&m6t@R<3?6`x9HXbiC@JT_ z)*8lX;wtKLE2mOxicBX3^Z^4TR}-Ui0e$JbM`o!C<-KDNzU2V`gLj3ORvO5X!i(U@ zF(zPjB8tNc0Rf0yh6EfY03=cJ1X4giN5qKM%-Sj9sJYHW2^@=($lzEsI=+tDbzrEJ z>X3L|u=*U-!}I_QAjZtGQN32OM^ZH;5j$dUmC?`{P$;aBdMuuaQTqZK>y-R#1nnKj zpjG{SaNZ9z&ERx-a(s6B=*1(4<#;%lj;1@?H?|h9Y%gxy+Ffimd~v@1@a-lx7vrhl z*>&?9qxp2O7z1(JwC!rMS#8?3C3LRv&S_K5EIPpTDfOlEg5W&*5JK=ictjMF7h?}puC%J1?Xs2G(4M8qgz!OjsNGUZf!StDXfQr%QFLjndT^-s|= zuv97_0QTTTflH#hJJByavS&<)MC=@U)*^d|=n}F=A_|xoJrW{NTEzz4W_9I!5sI=1 zp&XQDS=WQAD1v(4kSH-a^e&9bdc>4s+q6yFG;Pz6WQ!Onpo(!cf)}2{3Okw^!wk-u zUXI|Ak(r(1Cm=O?kwGBKYg|gcPb&VE9g)GmdQXj=Vzy>31StI|G<_v!7l9=iQ6g#K znqo|e7>FWAiZLZlDaIr(W=c#crUZ!zw5n<%mK%w?I)Mh8Bm+AP-`9m;>&8N)u&87k zX@i~$fTi3CF>p*VuG@Ac5X||-#o5^zGI{5UvZ$&-T@MH2;bc6TO(%=_Y_Zr{Eauzu z>CX0SvAFTAU;NDY*9TRFalJg)e{t`VPo6w`^lb0x{re9-`tYN@ql5mrD9bVwh4bEl zOC%kHBauzwy24@1y%R1O;ox!+rh@13#>frpBz)9)z-qe}89@Wl)V@`<@L5#o- zuo#aP)A7z^adT^NXM1aVP?i^0=LbhmUp#xTxBnon&%uMMs!&v}ESQmrNg5gJdB}Qo z-&o12yD1Hr-*+}1ZDPhv1m~SE2cz+5x;>iTnQq-0&v)uk#hcaX{?q;E4-faBt}jl2 zlCR669=K2f24JvaaRj-Dkr05>Y%E02UTUBaCE_+Qkpl>2Sxx4{>CR|=bFg)*n$1gx z>+_@27mtqjo}Zr{waZID4pkY-!j&a}2SzfefSnY4hs5Bp^kqF5&FjH*uyu31b#pKo zxU{_3-#^@Yc6D(U*NqF_`JxyUfCv!K56Z!~8jZGY?#{LsDfiyn|$psfY~tWTI4L8xO<;_^sk&keLKoE@&U{MgB@Fo{8tm0doYTQ#cVFf@CxJg zgbc{%JWr>aor)vq%+-!4J9EzX%g-?DUbY8+hMC!?^y_cs4Y>Z~mtGw+2jg_A@#WkE zP^}v+K25c#6tyq4{*it300E#xU;0oMZV-yvh1&blyUO{}`@;FsxdMDZ=MjVR-l0e5 z*g5Ay&jo~*`5|~puCPd;p0}3LCdEyXzhjf)vTc@ad)dZyife!tAlV-P4B$)WJbEty zp{z1hY!mhIbtLlgE0Zp(y3rGRYP&$+T6u{sdqkP3UkL#8G_(4w@Y??Nb_Kf72Do+` z=sme7X0QKh_rtv9w$PbVtMkhMeeQI=ZVvfabi;x^wZcnu+!1Rw! zM|^oR4BzY@h;D%ly38}Xz8@-nOodvZvrVQzs4 zkXYOXKn%!&q=TAX0BkKmN%aY4WTXQw${iI)o~v zUV%zjFt-hGPQ6ig7+c2*AVhG?PMbNBcTg3fDvKf%RZ%$aJc_4Jn61km90oLZ7%*yH zsZuF|)nrxF02ooqXDH1&=R}$2lu~RGr9_c}3$-6jr`tQzts6J)?CiX@SS%3I#l`0M zbhBPJp5koo7Q23D7Z!6U%h;yPa=luvnoVS6@XkxF2W0=dliHI?nvB3qDbjk=o}6C3 z*gJaq?BMw1oEVCtD2qUZZ5x}mU9X$Vb-Y@~CR$ni9#n3}mrX5&Oyv&IIq!Wai=r%w zvaHIoC_@NEa4vYsL{Y^|wJgJWwf)7Peltt;Qx~S@^DvP2BlA0i(8zM;oJSK`w(>6`h zHf`GqO=*TxXW+4_u`*Rc$<-283T%flD;xTg{_0fJc0z``xC1Q>&|_rTGE!TeGl;K5 zZ$(Y4mMKV`6ZKD021WFTlv+XQNDv-HKN3ssOk!dym@2haNn$@VQ-*{C&@~Te?-|VM zG#4R8EZJ%r?~j0xdz7MrW7?snZ79Z;VvH?OB90!|d0!NDRn()=XgrxrC*#?4Jef=; z-~GEE49o2T z>8g#t`Cs3|dO=#D--LcA54$tjSAnvfgq`|k1st=e5)(%;O4k9%onz1GeSF_~R|8C; z4U00<11SOvx;R>cS8Tv2u>{6aat{wTYS}!U2o2nhXciak-d>okh7l_v2w01Xl+a zB+sRZF%bhe#1LFv!D#5}3LQEow_d~5DqSt(#fq=a)9FP#Kj-C=+m@P^xJ4qVl!@Mf z_mb2gSr0DjO03CsX)(b!UL8~e9V`KJVj_+Sv}LN**U)-MsXNFyNC{&DF-kjI4d0XGPlY#$6KmI7sfTI+Tu*ni)-9;K0W@TbS=ke0 zJ|TO=L{8ozf&_pbu9j)?Y5p7k&hTI#-~9=pcP_B=90E8eIk?8P%+BWo99?R$UBc>| z)=OAj^6E0JFKDxbW&?4<)M{!^XXfOgD%-pUOMvMy*~)MLk!UjE>6D)CU7nsb!OJ$t z6$*Z+PsIwVpccOoHY;MJJ0>VPw-z^*uOrBDVF8RhvIB5{u1)F2R`H8ppH>whxF7#` z{qB3`gE3q->y|_$5HsYiY>+)9n1KC_`REI;ZZ%E&y^o)qwwGFI-9!KbJL}Lbg2m3A zvm+!Rh{7P!@Li6+(=N{%oeRbh1WE`dK03ThtpK42WKIML&^aGGghG^u%k}={<#X8k zpejeR`Ob85b3DH_TfFkkH@+M=o}KPJdG=s$|MAJu3phOm@BMJ-ivoRVb-76jW@AsC z=-85NNrt_c7$C8lbv_gYRy6={Y}d=vtBWIedatZTqsd}4-yJP(4`;Vt|HkJ(Te`Hm zIC=5t@Y((2!)MF$6JYjLSyqEk7VHZpE-etOy;K22Hj*N-Sh%W05t7s_k6&CKK1ct4 zS&!@a&Tw&aw0(R2##e5C{+kfn)%nTg;oix?)03lTtBXVDe5k8X4O}Q0ToNVKL2!VK zoHncTz17)^lfwtT91Uml;dC*WZr}dG*UF;UoSmN?@1GqWY%b2*O;gl$s9L5rwyV|o z>7$Qvx;0~7z<_2w7fLTrx@PLB>Q4xe65w}xA{w|8%jM&oMo#n0UM%xAvz7k>G{ zAN=H_-}$Y}cYhy_U-(hw2SZB4kvLJ`{1&gFHKvou$DZ+(?H+ToWNQ#iSreBc_Uxi8 zqtGN}53uO@8@uwGsHDi<(75*&Dv|=dM`xN*4yp_+n{_6&H0CMGq5LvY)f3(JQ)ley z1OR%V@=KT6b95A$nlX=38Bg~{Hp9A+T(e6JH_HZ-NMV;%BUB@onVI8;DYCeubu}Eg zVNp!0Vi?L%Q4T{G7uB!|BkxDSRe(rRQx7R35HKVnCg7+wu%+r2lz4Wh$TI->3k-p*8aBCZYINYb>xYue}a`(d7oNXxt^c7F6E zs`Uolfrak=y{YK9vNQ+5-h0c&c{TISbx0==i1!yY%m;xP9U?JVcNph@w22j?2dS2> z79;_B%hT~}^~;e_)LINB`oy6w1KybP&0BsjsGv=h9D?)CbP?j%a#<8*70R+Ksw%Yr z;3b(B5D;_IS<*3UBC;fyXqkA45oUo)STYt8h!sVNfS4mwl9G$S3B~t|%uIr*usO6P zH$~!Dw5+E*EL({kOXjQ!+8z>SC|Z^i007KU1CoHR3A&U?8c{1c>LF)~RRKYLBt~B6 zO^DiI7l||_nhB9pV%v1^!3XcCq?FQCdvbno@_3KyM}xYa?k;w2?%vtnd3CY7U037O zq|3|A!-t!@_nM+iTibqTr(A58iy3=QF}AB!vuxT;6BP|Whv)?e2Lrs6MWNJ?^TB)X zJjEz>o2;R52j?Z$9I^=P9a1IQYj#aOwuk~*s6UK=Ot%)ZTbMbMavyP?0~D047Px~B)Z+-I`2mt+b||Sds5vj_h#`<;CxJ+g7`#O9AQ6BgLPSD#&PiodgdjaE zW7{-qw^};qd{vc2Rn>!fP!~mT&H;+j2k*JZJOOqIYw6LlHDtdpiZW_eQ z4j2XD&~C`YK$JwHATb~W7lKE`YNQlHlqklgNvVl3HYu%|%hQW9_kx_mP!vU34~L`S zWHcGiw-(#m^Tqbgd@-Nx%omHD0>d3?DURd&vXDML0jtS)j$k1nPwP=T#x8gtQPXG}>%Ci7zc1dQ3=oksq zcIa$^Zht!5NtIcZ=R|-$2Mnr$(CJ#X$gpj^M!u2qhFX6b?PAVv!QZhMN^`R=hmptJAc;fb}IWFL|@# z*f2$=WJ$M-5(`CW?8lTcWnyY3Fo0#E8;>*X5FlgB2Im0eZOINR(INF~PO)`$D9PSB z)yTxh0iYk~nW3zrnX^ybCC+pU?YX04Bm+VjvU=U=!Q35PN{M4qHHyr!32LPgT2V9! zks?N|*TI}LoH02>0s`l}S{y1F9iy}*^H$MVs=$%7A$E&jlxVM>p4@53*@Q9d9S|*w z76FpR{Q&_M^|+eN<~Jv^TeJDjc)B$mk2qc(9lm(-_};U zXGhPToE<#htj-uI)KyUreGw2nGg^R-XtDy1Ap=^eHm?8^FjHiTl$aTOH5iN*quK6o zvOS#N7*Do_!y=}ulfCE1&z>9~zPP+N;yS5-B)hx+`3gw2CLQP z^k{u{wJrwbe0#jTJ6Y@$)dV8I+5XYvAAEfG+rRthd;b`Y@A(qkU`!AwF|@5jP>JVJ zcLdE)=~;N@oaw@&I}GEx1*PW{dnt^JVDLs~@z_}g1@*46F+JI4X2@2V(HtxDkd(yUwfQTYzt*!qdr#_>H!(TfnZo^1_ikk4V3VSq zKwyFdF)>A;sP(VGfg4oSq^ib6F{-L*QBKQxT==mM1Jq;yB1&y)D8-Z-YTHDc7*{bi zDQ!4y2wI|+Ny2fXB)@&oN~fLK5>eB{i)MYH3V3Y2-8n8E;eCTovS2rTgV^`eH7R#bAyW6u( zFgwd_>jb{Z9`Ay;$+KfG4r6n zN3TPTMOpwtFM` ztMw9H9M`VY>v`NBCyvS`pu_TFj-82^$R|oqiPoEtg9Gz|2 zwg{mJ1p~GcK)|2?0RR9=L_t(B#TYkDyIi-+P1>}{f|fgF%}$A!5H6Fv%utG(1|P~$ zmSv#=`%0i+9#cMzP4m(-?4 zUr~xyh`$JsE#QGcE12r0cfeFbjNHnD9JH8Hg@F>wS=4!sX`2xVE< z!|`-Hp3i5Kt;KjY9nYuJoyGk2&BfMYI-3s1a!G!v}Bw z;1BQJzuzY7V+G(t2%!+@LaW9`!d@m^Rt$~NThzLdTu~NH)2>#_&%W{5zx^Nmov;7G zAOG8b`+N5ug;7~Mk3W9z;oV2)<#B2INk*m8=*DM?nOcUYECR#*mPc5TGe@TTRfO+35YoSen;3%Xjtaz(4AZIT95 zc?aI1NBLV47Gwo7sHh@SCnAM4HK|Yb4T+JNx2OKiSL;D78;PtfpE)In$z%(vrK{~= z&45q_2Ay9-7Nsa)IKU97D6tx#MND*Tl%>;H%9TwqC2LP>8mIuk%uEC+0g)EZ=~zfF z`U8wi1cOV~troM?f-H=(fCXuqOTd7*T9ey~|JMI zq|rD&*}uBDXhQ(j_F;Jg)FY{eyB!MbZ;g?=NE~z>T8NqY5rIG>DZl|c46bd{jYaW` zU!Ru+1Gx9zUH$mIvwFFm~Yc4IJ_mIXBH^W($4=g%MQA3km_jsYT6Wf%;6QKAb5 zd|R7tFpe%BM$8Ce$6;is3}E5VUfU2S;@DD(DX|abU^JV|cE?*c2U|Dm+3uhol}zia zlcW8I2hSdy9=vGR7l`2NL8t~k6jDWxftj=*ju|h{6U9g|60yS&%3(d2 zjkmT2^WAE;H6G9Fy5?qeb$WPu_~Q8B#pT&i+N=;U3@TUFuB^}p#6aLda(Fds)cLZm zXS2a@I+}0Q(|KKwkmJ?K@yY(d>gsHDaY@YRin1jYhMZJG-}U&vtjqAzWQH zheua!!g^ROZcc98F6TQQ9c)gP&)@yz!{7bkd*A-A;oOD9_3#$ z0cDQT;{x5gJ*31=8CZ@(2aRY9O3wKUfO2sQ3aiQep%4nfGqXm8KqI_kmPvl6nr!pzexKL7v}Sa#@w_f_z%G8qaqB7Pz3;r%gyF^vpim19d1?!tLB*C0w4kufQ2gy#J~IE}k_Xb-igxUGr|EE*@dSs+5JO}?61Mi`}(4)ue7G}5D`FM7~ zMn)q5e+rD~;}YFr=?*0rA)5%uC%em_5uVFR*PLZ$|MX>{kukqa3VF3zPlqgJr%!T@ zE@l8zj&)^Fmzf!?fT=Zqk()?do^yzf(F1x!59k39F@WKmfR0rlSiuIOD@JcyYu>=dfJ5*g{!?4|sH#P7YShrXfl| zi69{pQzVKhZrZkK+n7=$@ed2+K`k*Q$z-L-oU{M|5o$w=BvrTptgbx}Y1T{v&M_ST z7T=*oRr`pXf*NFrz9^AjmKer{2?CgICAzhEZ_q;sz6?bOMd3@Y-96-E)3Y+q7HET+ zAt}o@rHxH?FtN0c1D7EhXWkxVXat^ZzgB|bz3X15p zh@iPPqPlqnLW0D*YdJ;1Jy3sf2dGEb1%Cf48swxM=!EjiWCHjEKTD_rg0OHk- z6eDe#wr!f&w2~zvg)0~o;K}e@58~~6EZJ$4Kqu}21OYLfTZb<#wkgIX{MA|a#{qKNF?oUnVBR2f+#u+tI^$%oFQv%}aa zD45$(nCN%n@D=Je0n|w;wV$lE6aY8@e6uJ56FPu~tw{Ecb6%lL(#*9)91{ochh>-y z%ki+BO^d}MY|X-C=tpHycpseO$kaxTQB!%q1rJ4uWdL-T5^h>pZTM=PE|<;4MSHfS zt1Diuc+<8Gr8Y7pbch}q9XpS~5&%%DA*lJ3ZCZsqgtaK@3ARLfykv!u7=d_u>fd;^ zt}A2A%*o(->59S@aVuxb^a!(FRb#Pj5a1)=Z~(sGvV^jB&Pl`vSoD_OiV0#1F@ixG zJ>Ua!Vv7VMLn7^Ek--<}+CiZKF(@LC6y(g9hbmj9vNE$sWgyaut(y(OB>j#5)%a*1 zAAHP3!1an(>$G0->KvL4tk=}8A#QR`xnmHAB(Xf58cX@UZivk$farQH3Te`yx1iCW?(v&ce{wbpGWA66i zz=_(5`e<~LU(61iYh${xRebBK^CFOU?)~@HZ@+s|j$qYpk^&#VK%#1P?WG9_*#T}( z#-F*l)yDSihYwGhtKgihK6Z+tErl@uT=q>9(d<|vGMnJjh(Pg%p?U*M<#h&pd`qk9 zcFJnDLpu>Kk|qlm(Sr_C`i9k*%CT?3u09X!2)9Gk?w%8p{Z;q$CqtT!)V7)v$ zIC%E#`NM;wr|sn#AVVnqpbSNU&Xf8D>;Pp3mhmY>i#R|lC4`yzfz&ZV$CxNZYTFbE zz=v`$oXp45-N|%!IKNTPW`p6t0j(~MPxqf4?>{>|+S@EInaCF*NLY$1WY(m?{|un2 zjwUhdgXHuOnHvw$7o+iLG#^iIOt)?gXFJ7k=mSu@zC1oY*?W9^@N{*4Lex6viop=8 zQBhUsN(L_*P86wafw3s7ayT4J7Q^vkxVq7C4!S)&~NV@nniqCV`#$0oe04+dCazW=Sf!Fj3yuYy)QP(yXZ-?$Qw? z18emG%=JF{u!g;ilC46h+35OdzM}Wwm+sQRfY^ICo37bR?7m=2Wp-qD=k5r1-&90Z zgLe}UOZ_uQ5vTNh4K%*J6)V4p)*1#kn=Xp3~#&$&({=KWkQtdAdni87r-QKe4NkP*~vlT@q zZSwmb$O8s&WeCGi42xn?7Sp1dg)l6NaR_zk0jS+HM^{(RR#zvh*>e3a*R5g%ZGyMTy*0nnlI009ss62zfH7r+(h3iKYp zLkN`c#@3y;zVwA}eEGF+e(Cl%zqIqYPayy-8#+4U{c}Dz!;=eGw{Uia_wL1`!wtt4 zT>^@2Y?@}>ZZHHmwu5wA(U=Z% zO)E&J74v)UYgU?LGbx32OE5o!mY)MKJy^Y6%}3w*?nx zG|~PgaHjj}11Qr)cBWn9u-G?m^Ef#Jc)~Y?4WRQX-hn8wbl50;7?h*=WP4|^d-KMv z`SjLcJV&h7%gy=4X47mO#NjB+XT^Ns7gMfBiE-1m>&>Rwv~6rt)5O)LIXPSI?H}*$ zA73q3-Z@`*1eYSmHm+m4+Qjvy-83mC;9e#cC?{39hIUj%2azZW?|krDGPEd4siW$B z5Svf!F91O4{wNs}EC@^X0O}1v7oSm!qoA`udPQ0kst~mY&G5-Ts-1%%p)Y9akpzio zDG&hX4dIe}X|MOzpb+QmmJZRm;EPaJgSxK6a8wNjRayC>&`PCV6RUwi=hklGdfjfC zv}qba0|Bi8vVwP+sMwf@@t+R(=wz-jF%us>u?40m6Nq!FWBaTXZB(l?!^eydD_vnj zAT#RnIaR8cR-%bTQcE_I=!XJ_NDxS3PALI#vfLM@6l03S6cfkTN;pW2F~*oIPD~o> zO7W3a%g5#-A@VfwS(B#Ff!TtpKM{5zqItcfXH1H*iS0Tyo3?FRN^Oix93Xn{LQxdu zupW#?lj(Fao=wK{*?4PfYkO;P>*n^Io7>yF^WkVXs623LHy6i;FZPd4UhMB3>^;4E z@4?4+KYsYhy|c>;=)ENPN$5=Bp|+LSbqDw_{iXlL-}vkQ#;^UcKXLcr>No!3M+Zmc zXt;BEb@lE?PwqWkVo?G*LI#qMGbMT5e3O{j9?;NzlWf+96Tv_VG{Dck*Sj0)wYFsT zduCW@*a4!pl$LMRk(3oOvZz)d3AHQ+}#R`Q5e?2qeDVSz=>jv)Uq_E z@E*Mf?*PG3a*=S|@M@DTSMB+7b9Rv~mb_ZSdYv{+OcWCXlO$HK59l2T&Xo&{nwyFd z!GZXs347^0sP1}+6H|_nkCj!6LuO<~Cfb?$H(nXkHJBSqsGBJP3*;uUn6jS;(bA8z z?xK>Lb*BmsgAtYmgaV2Zy$3)>P1&Q`K22I`)QHs1;vY+Dr=-(NQ9QHg7+EbP}(ECYKUsMe>>& zovSNkxYG_P=7Pa?(p9N($mqNxeSpp}dWRSm-y*;)+Ck2Q`2@3mYhoCB@fXH4gceN) zRf|ag7D_2bYFYpXSPsYY`FK8C+#HT~2jj(HIIMhHU!5HvzIguP(b3WKW_bpfpeTJ^ zg;1am7S5DgCuQ!1pkx zs&dG2b#;04;`HG8*~#Yc{ELQHMiw9M>% zSq-N3cs7_W>gl2!ZxzEy5j?l+&E?tZ?CA3J@bdIHuCD-{t7=~ju&mKZy%yj|F*Yf+ z4t+VE)WhjuvQ;m(Mw3}p4jj|j+1bVM$@2W7-7MQpBz9Q1>DFxb_U-LEH-|gB)u3o& zT(9D$p-_au)Ze&MZ|#&Wz~#yE*+=(2{`QYQ{=MH>fB0QUr*1HC!vTP2YPoGmQ4Up2 z4fr6deQla9vgYlx62dk30L{`AI74)Ay=Pw~&zFE1W5PpnfU9aNb{+H}m5kr^Z8xJ% zG}}r)H8VCV<`yv7v*>zv00VL|-Cw)JW&q0fO_8z>d#6PW36-&8z9dc!$2G9P93G%{ zep(H;>)}o{*e>ds4WK(aR%#oDh>8|tXRXTGXYRme${V*};~ETz)+kKX2vz4h-is>qI?iSU z)@h$o{+o>M0=S|KyXh=jG=jg4Dlei_$UwLY|+_T!SdlfXIAE&}tY0 z+EFzMVza$F;bH`rmnt7^$gm1`T%;pr6--_LI8@-ngOqsC7!U+4cdC{?K!L767tojN zLs5+*4U!wy_14bz?whaN_~uu4f8`gp|Hzl;Hx~{7TH>P%KEA@kb9nc|_|E(5=g*eQ z)m3cQ-jVm@j7=v>DJDvhD2f$B)G{PW5ts;wREblVgG^tOdO8S@ts8NkiMC7bC5E&HM&caE0jzr0WdYUBF#RnfsDT?4jD1w)Kf9IT+c}aRS z5mSsDnNuQ(tkzS>I(scjIf($0g0cEjkB>3L}NLOOTA2nQqyO0keB8o|aKO`VbGGj=4bHtQlq9|b@68xbd zPtrVwa;Futa1hD@$M7S=L!@>ITeQA1V9h;X0EQUb*fyKgtlFqZZ6ZPh4_KC=tg5OW zjmMMObT*$&ZtQOFY;E1x-P+!n-@diIvo#rvDkcJsF|}=6pBx-KfA;*r{fAGUK6(1& z(WA!?pFe$av0mk^DiChmy7PrEe(@K7;TQhopZMcn`104l&Hmu`?!WiJ5yCVSqsK2U z-@kjXf3(6-OKS@Tw9=H=Ar#O#t8MQxvH_}If#qtM${`;+Go18H8CH<}Vi^h;I_CjXq|HVEE{E)#gW$Lb7@T9m zHo0}=<(e-qH)j{Cv&(d~qN^3JH`KI3765<&FgS3?=vlx>Yl5!J!2o~+ptTHuEtO7U zJ1wMblkicAdBl2%SrE|aJd0J;MO zDwuVkh)@I^j}-dHh#65)4pLW<^XyO>xv}nKsx|4 z1I)NXY1E!#YFln2ume{Q#`DEwXEwhvp52^G7j<1C)78cC;nB0b{imlV2kr6{m>_tp zN?(-T2RTqIiG&0uzKkJeKnpv$mQ`x-=+L9{00>g4Dz?-nW*5rAU_6^l=ELct9&cBJ zSy9!l#1xy&#reh2-udy1<@sT|x@3wFN>>!2D!>)e$Vke%P&PGKLrqB|OMxh-@FF;0 z)T6<8Ydqhn##_~JUJZst<$zOjes*#6;_B>pb$JonHZr02&O2Wg&P%8^$0oIj5d-@$ zoJ>Ze`DAN%w0mPXoFG%PS+6dZ%Zsavi?h|$CACxxMw6}i?wy_a?$&sFs~l9`!KO{k zhQOuK%x&EmjkX;+xP0{NlkdL&{y+WRqwoDL9DD$QIUuct8*xygSHdh1e#cUL(D(+1awo ztcf{C(I+i+$fS5mw=y!d9$*2v@DJL`aPJ-Jsiu7W0$C@?U+_6?L5eb<3k+Huj9KIg zacVfq*c^aIKPbvkRnDtwx2|`KV(yE|2S&cytPal4U!0#kZQE0TH6WrdFa$HuQr4hs zvxTS#ui58pv#@|OwkVGtyHw2yI(Oyu5TR@T^nj1+B-No7ausCI4xmFPG6&YPBjd^K6PV`>`N&u*jHSYZT`ypr!59j+sCtmkR6Z)U4!w9RJEV5xWw2Ie z76rAprRbKYdovHshO2@}ENf8=rB1m=$#c;%-d4R!PohWgh~Bx9L*a^1J-HFeX-rk5 zI?|vXOkTM&|Jr93U;W(ti=UZ(?)Ay-T?Ytl;^i7%9Hd9j+s9AW4<1}Re0+9vvSMxr z6%@e%a2wmE5i=qZwamoSDilJIh(IcpS~m#>7XK~e&?jQ!b!0&R>e=l9AC~9~s(2az z!Ahf`StbCAGWSqj{W@xi2t;Iu-g^vA8qa#C`U1c;{6gc0nOWRfJ+kh3{lLg3Z#%r? zzR_`#cJu_aoz73YX81cqQ+h-RlQ4*wVr&}bb(0CY&|F?!J=;5c_~_~5=X;l{H8U4Q>74^`G0~>kELZJjBhGh; zw2&H;AbtSuI0iZq`-;MO@15kfgb<1lCenP|qjw&iN9QGk z%7x&aFY7^7*S;Qvs;&luqAb86i#u9c(WqsTD5lgjX|su&b(>PmJyw8|z-xjtxR<4g zExU3;LhQW>z}gFjyWb7SHUjHpvX~vp@RTC~ch!qB{i;t}zR?{d(>AP7oqS(<)oRth zlp<-VU`=L8#8L9#7*euIs41mH3Vb9YN^RT56!nu76Kh8oi72s-xmFfLMk&bv_B7`F zROv3I#YR9S6eDsD>0xa*6WiEIo)S|^lv^O|z>zgA zMHC5%-Dd#M8mQ>H#t?`I#7IOYRu{4y>#yk0j=(>9U8fdlY}!C~;= zyh9czJa>es(Qqt$!GRNF8UYZzN@B%&SWhn_I`&|lp|r7u z*Kb#4p*jdlOBAyBi@Xu`A6h|l6Bnv?V239`5pX<0UvLN<3dz=!tv2RQAgL}XV%u^e zNU>KyMB1B~qDq8XKsxKHV4)wzG9;t|8HpruMy0L-905Y$5YPt*9-M~|z&j{Q-M&HX z_K)b3=bL}^e=s=Q!{7b~v|c&qiBy{hDeB!X+iZ)I4JBn2-I$aiWq$=4Ke?Vq0j3yb zSuXlbJ_QVIjFTY@>-gZw`RUmv6bMMUY0EXJP`mYD$3@K}l}(rYaD$A^h|gIh48U4S z1D$JP+FcZX>AQD)>xnXwx{qs$w{cVoXnS5sFNR6WzCLFidgOvF1xgyKu!JQUtnh4X>IK`+8W zxrPNFLpCz|i)4G#Q9HVB$TD3KoU&c4iIC7lU=O~YOtu!2t;yo%V7yfgCq-2|PVMUa z?BwA1c<=Ol@ACW<;u;)bS^A>#Wq}v~o#dej6)U^T3b#a~%7~o8F{VdoaXUnZOhj#z zB4!Le6lFadj%S0}te$KYgLxQ?T~z|4w7lA!9$cR6FV7Cwm#1;FL`)b;Uk+SZI$sD9 zM}X4fT}wM_3c5!Lfs#VlF)@QfUsS_tFs`Tb!F;EhY=@%qAvk7kR?Dl4<;B_6*>STv zZ#PRsWEXr{`+DHei42TQ#KM=u@pQH`o^Ox0wg=;BQPu>|E|<&m%gd9qi}Q<^QV7Ll zdpg@$?B3p;-`X9FONMm0ikEBhC2Z}uTek+jhRglc(@#FS_XqEO@H@Y^e&@T;KEtB+ z!zqN46G^9OJw;u7N6w9fPOsIVj105dDTnSV!y)=mtWt{n(<$}_eB?L^#Ef)gUpx19 zUEx5^_soH3^6k#0-&2{co2HqLdhsQl<(Ta-qksD}p9_UK0FZzarsJiovkXzEu! zg=j-FI_#F50GMJ6B*g{T{y6*N0Rs5i`O1eXgvy1=xzdHw`O>)xeQ++IRL*ii3TXkN z7Q6#88w?N}0}F=2oDd>0BS)Z?NKykf6gQNbl-4P(DXml5P;3ZVfM|#bf^035&#oF7&7)?hfVhdZHM6rl>p)Ltzw_bxABj)5U&1WmjOY-0P}@tS-$y1)>~slT`A$7OUC$KQBYFpj z^T-WRU?b6v#l>59!gL6evGX3z&f(ywJv?4NezN}f z;pNBoPmd1IQfhqw?>rI5wr$%criM~v?dQ@$O5zE&<}Xq`)5<`qenL6c7Wi5;hsl3S zW-_nCv@V_)K2qi#5xv8p-AZ85fO+(bkdTsawzXQQp{`K|5w zE7S3IRgWE3%_c5a&1$vr*bE10IEBFgiz05?<>l(~`1I`b{QU6f>}+|tZr1=zfJ{h< zV*&~MU}xtWP5IFyDwW?Dth%Uo66PO@5DG<-LNMf``?d^Sj82jY_Su=6Bc!C&q%kMw zokEWpn^Gu9<19dQR0fOa05+7wp|=zO>CxiseXR!qhJjkac!8#~*(yW3kkTU(3id^Q#I3ju&BCFYdYSF5x0<=)=K@zHwS7L23i6+C>g ze7v{3Tqba3QTQ$-lhGo5MX*@)QC6UxjrU7fLx$@BGT>px5TcbSK*IyGeh$D$3^7p~ zxlNpy9J;FXqhT?d)VqsfdtNN&ZayuhgK}7S1kVX$8=FY6kunqLfTjL{cj&;yi0dY; zo4DM>%d6)6BAs5w%VkjRm1YZ_j=U6o$6B6QM*K@+bKHGDyHURc)` z;MxynJ1V<@vc%ByVwp;GqbbYvUb+07U^si6L;|Ar#;c5%1 zLCp%#OR^gfBLb6D=|JHV*0qieDra_><2gjg^^8<8<55Wy5pw#tsYD8Ufp(uYYnG81 z+L)#T9#rl9C+8<;o2oEJ66w0HX3e3Tg^iY;Q6d1e!;<BS;A6M z+cJ~G5UOD{p4ap3YBH||^J+M&s)6@RakIWWyE@&!IN4vF9XIPMP6^PtBKWezP@?nb zy_`dZu2n31bdns!zyy?lqym)p)vy{(>gl{5&IhA;QICr%pvRQr=IVTTdUSPiwBD>^ z69JKted(PmTmVW435Y{6sH=K7oGnK4#b`DgjAq^!ZHkv?XXhv9%gc+{v_(}9C)2H4 z^BcDolbefTP_l=sWgBCv2e8mUZ`z0m;{V9LBv7=`x`Rd4B(MZ19>l%9Q% z&w?=Xo^i?cS05GaDmECbm>uc8@tFh>p2Y;zQfwhfOh%OJ6C}#(FNx`O05K8zs!uJ7856;#FV4~A4u7L zU=4Xx%oZfY&May3A-dplKnDcWVhs#H2niq&q(m)qOKHPtP3=``FWcs#ZLeaxqPSs= zlwm+ATwb6H=p3*ICXPf&LYqtQqtR%0SpR?7`m-j>mL*LPQdKis(;nSDrgQE*WL9+* zLf1eQMGFFSOT!D$4I+(H7j!4m@WL}cf}g!9U6Hkvb%team1RW{GDs1OT(%IF zLQ^#tdiYwwSogK=qjF>FPs-9RA&><>ml_6uV&;JWYbuKbJ)je$qIGIX`Ae2t01-F| zW6$AL!aKkPG=mGPezUoIv3~t}^K#qWZrfLPi@))M&F}wU*|qLrC-XVK+0gf1yUhw> zg#8hYBY$`rfBn1t&wqaZi{IRT^X*}Z(KQ%6pyZs=JjIlvO68{$N5kag*Lz81bMBtqBE*S0My+iLxV~M6(^@3IY zl7(|FO%!m2K`Z=2D4NrH8=THJ!Xi`X(ApbOuq3STin!1n;)qID3xbyMrh`_K)(Z-EF%yV?ayK?LT^ zan8zvq?q%Zw4^~J34kL1#BA;l0d(pDKoJor#-TDF)ee@GSeBPuA~RKHl?!o#SkkX> zF0wtLE2*}=joXHKSx%A9gk z8IY86Vl5jnMN}0)ww@SCnXksjaMhKqGj?-n*fCZFF2zO$*_;-IJEcJ#mnOEHIHs8L zJWo@crm4dGxL5m5Xc!Nu-x9m7X)ItX1@JbL#Hlu=woIP^PBAF#7?KXmp zKtgTfSIcI*Zf>?=yK#3n{&pMIgC9cEB7tDe66YL8Ns~+oIHNY{Y7m&2Qj%#(`!Vhh z^XZs&hxB~R$5Waj&oRc#ER2LUR#v+zr11B6wN`Z>0~#BAWf66GQt_J_$wP^=P{5(d z(j$pAe^MElVw4GL%L`q0I#{e-2$nK1g+SzH<-Yf(Z9G&1ZquE`I7~ey^*w5;?4eZX zf?^g*pA<<5xERm}abCPLYh?63V4>qs7;=QDTw5VN%*2@qJ?*9tP>`v@Om+8!P||rIsJv!*P=ZE|TcIFar6~$_G~{VLJxB;dLP8W% zzS}ncy}x#{AwGL& zsne^?U+*HS4aedW^=Nj!mC*#eGQ?2sf(4EU2s#M%Z~X`VmsQ{Q6w>Kow|oEXhi@LA z-_OTqkO)N3HRQZ=UagI3a+hxM>OAC%T(OuIOCWn4FxcHhv94wH+M3Wn5a1M3iV`z` z2kM69X1%=GY+nq^mtoj6{ouTlJWZ#k=Z9|}AKyLi-k*-o5GNqS(7Mo2XvjHGXf6z_ zZ0N;o+CwC;z9tP-5%q^9P9r1%W=YnkEr8CoVc2x*yJ2pxwiKqI1!#sda z`*3)E91qWNI!R9G5Q$t9$Tt|gq843twkrt%o$8&50OZ8FlK`FXL%--&>t?xbR-2|< zINzwbOmU8_z8?^fFv?P3-Bo^zT{=hOap zcs|TAJHXXlzqwo8esZ^cv2A*vgtb|7SkY?R(F(>>zW?PfKL3|L`|OYZo40@R-@yCd z07bW0LFf^+)h|I7mck5Ld{{AqrFHB2K!B1j{HR`|2=-kCb=6?lswu2x4W6z;DP7N1 z>7#zuXW$4R01j|y`@7}xb-TFr z%?&kc=Ng2R=PBk>ocHtiIGrA1dWz!#cm`oW0_V^XDL4dB=muN5Yp^HzW9wo5$KZu08`DYIyM%K)n#)@HC{0cQlnNy zZBU)yg|oV5=IU|nSRWs~ZjfTq4YdHkMGdF=+=A8hGw`z9@hXxo7{wq?rClyfY87ot z@idpyu0OsaG)rWNVjlDQ%*!w9TdMaeKcVU@%1cGGW)h{$b_FOM`FM#s9g+j`=o-KV z$OAY45RRN?j-$i~aR!qS_261)U%mP7{QIB&_-}1Di%(vyzw>7Kqwn_L`*gUw85Vu( zgJXgHiQm3YU*F3Y@AA8^&v-ul+oy3ndy?RZkR@p+f8>zO_2jLX1=!R-V!oMwT*y zS`S!w1;dbJqy(055lACgG6l8LNUUuwQa+?C&{F78UL+-G(F#{Z@W>+d0kc>>U8xX3 zf|^v5IK+vOF=ZXqiF>?}T7RXj2tWv2-1wWGYyQGvwF4J_A9wHleVp_neI#l_O zq8Ot3Ae9X%cZuuYpd1Zo5~~ykC#F>VC)As$a z=!bPTtlNI&+!ARq$2R5eoPC1E`9i{uGaV|Jb)&N5Ayqm?!U zL}6l~lwh3l@f;7wX@87Q&*}M?Ph&ofag13dS;}B(aN2fRC%W0>1_819jVp&(y{zJ? zvj2-!AfW4oOT`AjMXQ6Vrmt!8fCk=B1rESE7odi^_J=M~MiI$VMMu|PN{zeAR8tH07y=Phs6S$0OZ-1=OopG2mp|jdz$NQL zV-UftbAq$!>>!eJ;2Uf_gn&MP^IF*tA!%R4gD`4Uvj{*i@G}@!cl;{l9tB2WeaP+ux1<>fd~5Ryd|9DoG8j zsnN@Y0H6qy5W981_;j^J=3jjH>@hxj=RPJrE-@lnj=S3RT9Z;+v-Q-Cg2q}n1r_lE zqRIm!hTDICy?lLp^Ln|u?fRt)ji)@G_WP%A@9)2Pe112b_rMGeplMwQ{ zN9ukpbtqU7yrF5SUfhr>hz&^5ZJ|~b1%PA9DM`#A2pa%k{9j9X79q)os(Sg71WY)11fS`S5gj{&3pw=J7a>N9K%(7*Lo4j=W#xt#`7_q$8kKVlsFQg zLnLHD*L1;k?Q+wtHvM|lEtX9?p!Xs?pUXR!MUg806trq4_{F{A__h?YG70MnK%99Y1N7!7?~oy9;8d!4TlCAe-?aTr*WU)eqR^7-f^P`}A?7?K z9^-sSamwk;Jm)xNo>DyJJm)lWozD!*y8(W`)R#%!wuDuaOnd%btmMvex(yOSc z`nLl93Yjkj9I8&B2WY(S$@k88!FMin-nA4uq=lmv$m=LYz?||SkI(b?FrT01(>|pW zzzG1sd2)fsA&P1&fH-W{!*IJ?ylLA{ns()In$GWcyDv}s_fvcTn$b6^xMdZ>dIxF( z(L94(7Hh#}*A#!@6rh;C%c3sp_hY*7qr0bTA3s{Q7pA~&W{Clf=liHcss_k~m5`4u zZzZZL@loA_)FW#vfknPn>rqLN(ol9GN-loZJZMR=VvRcBa)+*nk9zF_h!+*rBFkUN zeE=}-Pa#Gnm@K8I1^0SXX3iOMWXJ+Z&xoLH7mL+uy}8}q+}_@9w|CnYuWn!6h2`D% z|MpMceE(%juJI5MoWL~m?m0c}`ROQ6&u}>NH1Ql^zaJm(57Y7NI4&Fc;U~@Se}D0l z?`}VNISdUrmysYPI*xLGIDhqa|I1(B|NNKV{_dOS{b_WR8&AOrL1NA+YO=JHRl!$eiW%zDY~-1uyi=pMSr!9&1_>a;9^698H}11 zfy|s@oSDI;whfzgfBRzd$;+EJtHn#iRmylekGuW(csPp8-GFzu!^;=Lb{o0{W*+aK zc3-}~|N8xh56`>(`H(m}f+hs-yhA9YPA+T=5~xC#4uN&nKUDIYREk-YI9B;)R->*V zBY-ulS-q8Q5sUS}q~@w`RO-XN)96&wIB2;XM9{f=Sjyv70MXf`=X!WFdZWaXXvzsH zG-5%gYI~>>Drs{BA|iV4^aF{=k<)4rcZr&v08((?yS5Kq=Z7IImSGsMX)ut%g`9;M zHJ*u)rzmOSoN~%#E)t7~4VI9yu%YzeR*PELHgh*GS@NRqtm6{|#7Ijpr;dthR1=QNJ6RXK4Rw+-?^=JEO{A1rq$qt@eMrt3 z%Fk5_m2EIgj*@fADdv=8%2{-}j3nhvk`;&8xb33XZ++vSds57hia-%e)f;7OOb4qD8m7|zAYu~)f5YTmwR!x8FnwzG(@$K3-z4I-h0}2ia$qBd`l>s2K?I;lf zi@YqbC#{>XK}n{tqo^}w&H_0LAdqu?=U2;Sy=pcqe{!x zZPR>(kr`OvX6e88rVCzctPE4LNH&d+(yA%|07QoIi$O6LLsleU8>4ApF+kG@IdHxd zJVX=e>`(c4=5fm7EGa=|&`NzxNWky(Z}3-#t8y zO)!s470ap3v4&BjivaUL48M!Xzd%G3flKL;aRgMW49x)-r4J-v0-~7n-KP2bf8(|f z(s%y1zdQenfAgVP$(W+*Fe9=VK3lk0fT9r7vR!<#+92~U?>~P`drwu}N-Q=kk%ZL; z=xV*9o-0IT;R>{|q*e-msDYH|TqK14Z-I0m0@!rR?RNQMd-I|jZrg52jwDZUIvn>; z&(9xrhlk^72Rs7;G>vzTya%*`Rbzn5me$iP^MlLjD5$aLYpROHd86J7v488vwZnmu z4lw}>h^KBj4BOS}W>{~A^=-GfaiIfbNmD!@&&Q|3P8`(Ppb1a-wPLY#{AceLaHr?{3U2ps4rWw}cHET^IkmvKX z-<_WCPlu=Jw2#x7V^oVAng&Ba9}pcNYO;sREiBULpra5Wiy%4I`NgUohA^zUVb!fR z&9G>ElQYLSjfdlWdOjVV<8)3bA!&0%KnlL=nyzn$RkKu&vOx!Mlhs>Mc}&pt@I3`h`1*nRcx+n@dA7ytTCzWS3tefq0Ehw}#@pnic( z3n-Wq=fqsmsTGJC{N*z1M~mbtnl7OeR1ypd!nkC|t(pT$cy$%b8Qi%9h)~25?O-(` zEi~rk1N2aZqOq?F&MdhyHSURXf}DUe006eWUG>AZ?Qh!lWz)Y5%^-+cryIw^bbddb z-%s;HocEAN;0#1S9?2s)1Xmk@%ZFZ)?0`@PNU1?$NtyEuIf10IwHoq^w%fM-#={EQB;@qkXN7){j%mE036X}MbIia~c4p4+XAS{r}dNsWn0+Mc|6~CZg;Cd`aR@5!kQ1kw=NZyiWOO`0@B7=^o0qR%y?p)Z z^{Y=mdG-3ut5>hKH#fs->%)q|kU2~-PxH8sl=~rpoR9H%lH&Wl5l%BboW?KT?tk^! z({De2`10$g`=@g z2E1++qjk#ittCHp0=C}hsubv>H!CkDBEop-5fVUfFN2fdpgeNQ`gIjyTzxUtY5xZcFVwUKF|C6=f|%;ynp-Q;oBU;(QQxUx#rb?4DLl$is(v?9v55*V>N6z^W+QDH5u18up zzYqwKy+{Mlplgskh-0BF$Ur(O2Wxa~ntTpX3#Y(2M2kE{=Kv#uci1(4v2@#Qe|zI@ zZvA%Sw;R7%G>gu8L?qyxrYVkRo+pVjB__$@QM^NkP6X3TbHsDx<0(EL=G`tn?c?)4 z9gg{Y%2S-Cm_^tTI1egpLk^sV73%3K7&X^_Sp$`%u3^t*Puj~bXctk6+_yn6O){B_ zCBibXPioJEnnx-U9^gt%mts5BeKo5<%AHE(uS&zIpbBK%EdBRhcfpmY86}r>AeKso zYZHtJ7b>Q}6#^hv#A5+`fW-ovhKYo{P29MoTMYN1B*?Q&6T~PvLS_*Z=gE2TE%*RU z3r&mOArhKKX#hfSdK88@!#u(1D2Fp2_OgGL{ekxgs+Jc48hjSr5z*Oekt zIE4zQjpQgBRxM+}GEg~^;$tqssj(0RpIP|8!kp(QDS-gE(6sBt`fj z!3CzoX^N-k8Edukf= z8Wrp2MF1)`2Y_@Zh-49TMt4-4{o6s=lIE|;nVLUuf=X0Frd5X-L zV@5~hL(>gizgoU{vA(++%g_Jx=b!({pB{ek7jS+D4w|8tre#J>A~^%HLTWArTP4aVrJ>AHjDVDl zXwdh<0Te=@npcgtuVAm)1GpTlRvcYIw7MECGjj>o)J)dsvkY_+_2|M8aspwXhMHB= ztlR#!9bPy6tI#ab5p&`=-gF&ePs2Odww3d%B+Jz8A zH5!Wj5C+a75m>u9vvNQ#EJL#n-KJ@_zFCuNbsi&O%4t8HA5PEj;VVr^DinZ~7b$``s72r_ZM8ULaB+^dvTQth9+k8NgxwpboZ1fRYBO>$)-% z*X~d;Qi0>;C56ws;DCY#$}92m&;P${NxiRFgDYF>mU=I4;KEg1Q6E^kNufGEflI#h z+Rb3WI@jFR<-k~3iiRN1R+bFQ;caEd7dGU011Eq zJiyQ|w|6(UFJ8TV`TCQWZ$5qX`ja=Gyn1o>a=E$fyOnRcoasEx&(DX)r^APb{fEcn z!^7e6X}5dWO}uVy|E6DTh`;BW1i&vZeZrN@Ze&{JUM&Q#-yA$6(o!)-C`|R`gzxm?B*AL^IQxl+R zbQYc!wWHJ;C?nkzLI~uV!LE@>*%y`)5)c7q$u{v%U#u&(gi;!2RV-#aC!izE2m(1Z zMi%DiL#+nFfJMbtP(g29@T^dlgf81$WrB>^*VR=xa)T?E>JcnfTS~1NSXMv^jMTrb zD3ZG41X!{yNBvh#!y~F1hL(iH6z6%)a|*6&-D0zPv0c8}u3jxypLWe%N_aXRpPr7V z(-|ObH|^^e-OJl3E}8Rp~p_~GgCtM}i2`QhF3;Sf`%KyBlLHhdUBM9mvJ?^Hge zwvpJ@5*7x|oJAEXugqlGCrU9uF}!%ynXk7(pmHN6u2BQPS2`aT=|^h_qEh%lDp#i; zNpuN>u-STMT zJZ-Lv?QF57q&Gu5UZS?v6}gX;ImJ|lZtI^Zr<4l!k#kZrWP@R#G{T6KI%KmkRiXp| zax&sWPomsh03ZZMj=XnG(=1o3?e_NO_O@vT&Lj*O+c|}4bm!4ckrI0bpEG7bW)UXQ zb&7>R(`j>ADZ=akng)m7Z@10eUAW!)yE}KY3F}o@bWI>Y;+SHb^L)-J$~e*-fin<@ z_vD=;aEv}@nCE;trswDBX_t1p^zf7p=X^ND(>acFV$K5M9C`w8TBKUeP@aYjfH0eb zs3HobnhGw_UUiUVOV{dilL-RAfvPQU^=L1thEbl@C>4nhwhkn`dKI)}lD^WbY;YK9!)e3tVGPJ1~XWWVRb zLH2t%O>!Q2j*ybwK12k%6kX{b75G$et6G#qm{@-DWB*V7^Y-Bb{(t`|k0%h6oS+W4 zQ3B9H6}jr+zZg+95bD${5JCmis>x1QF>_f-MgFTexGPYJ0K$j}DaoeC&ZS@ddUyXk zdS4TV7pg$)Bot|t9a>gniNI<*DOjh}$K^>>lAx&j8XXWs&aXH9-~H+B5M)@o-+ng! z^FRLJR;@R0pL1Qc>ap&=KX z_edU0w_e9&0E1Zbt_8~$Gzy>)S$l$!+Im6^DTC;wvW!UNo2FZ}!=_uVyXCrFEJL#- z-yuOxbDU0bJWj_$JRQc<^E@6oj=}&ADR^`Nofj>-0s=>XPK)qJ?^wz?CqX16*Y-`n z=vFt&?M=7bgl^$|0Ku51`5e=9p3mbvjpx%TPb0@j9?^Hr@@8?fS>0@h^}1UwF$6%K zrfH6IO0n&Iw`iB!u3d$8Xk4JsO4H$D@Pu$W!iUd3|MK7d#g~8lv#3WpwC%d-w%y`o)4lTT8ps*yNYgw%O{Wjjct4N#JRgBGl!i_n zrFp4_5N%SFf|ATFl=f-SM5Lq{r1Y&|-*z|6a1(~RrhVz#jSnq30?Cr+oTrq>Jf70| zc^n^Oe2(*8@&wwYf(YbQ(+Lp)-6dp}>VQi&$wGGnDkzm_EcRC&9a}2ZzG0TjkXrcY zJ{PlXO)FQ1{Nt}WY@DyYuLnUeVR8hejm(951aH>`8pvhg1UX99I(`SxpznOU?uK>K zZ9=;QHwXhs;_)<{9>($8I6vh)gJFDO&cKap*30!L%k}r0_63l{)BWM`%fsR8l%LQ8 zg`ga$mbG6Z6ahr8*LjVE>ypN+Uw`zglGZPG=xXU+|J*44k5{$*w!X#edO1$n5EoLs zu;NmsM@!9xn6$dRNIvq@wg)Q`lfrjgKh<)9^6`tUrB#uQT<_>|*n<2-v_084FAbzP zPB~97Prx&P0CeEGwi|9%o4ecX-QC^o%THgw`Q*);H!t42S}tFP;TC)^;4<@+#{Kd1 z@OZqxKRi7i?jQEM-SKohPt%xU)B)p!=n!19Ufun;U*Gwr@l8X{drw5*un2|_IzZ<@ zginXV@o-8}>%>Kv735`>X-=op$Tt*}qZus zzI?a;@bJ47itSkSPB(P$10LvktW;7cyHihCO|!T`Cftvd6P z%b3XGS#=dyX_<{W@D`hkBjYuhS+Gp9Ezph#fKkkHTKRhc!T=;_0{e>pm2^>qTc}l1 zXr=E}eKjc(O(I;F5CaF*au!shN?v@yJ}Ai7MoH{I7py_G$j)-Fh6E+27&(hX^uAkk zH=ETb+x46E>Q&d=dI~w^^Km*IPH7&A@_OCe-7M~2EY{oJx9OaBkGrRD-amYO|M324 zcb?8Dxe2aqLhFMk@5q6uJyQUI#nx&eHw%afRDz)lFz9DVBQ|WLcN?*A6(*=wcld>| zFf64Ts{38>_!5y_Kxu4#PN;jnN+@A{tF7DEHh0U+coDPc+wlf+q4l9X6wsjLFt zWKtN_Nol2C8R%M8N>QFM9?%%R8foHH3_wOc2_kVdXcud47FP#mD!irwPcCv}W#C!a z0ZgR) z=gcW5tqTFuV9naznr79C*#BRLWqMG9xh-ZmOSeOU`fpZ?wF}oNs&2k#k@em&#=ZB}Xd(OLE+8^S0j+3btYegtb7~WLNxZf zB!KXmi>;|E($0H80CE5TKH$(p6U6yq+VxXO0KqvT2NZw^(1G*d8X^b20q4M%0c1KE zB8@PPG9BT3l;d9Z&wM(-=?teMOf$?gGi#&#_?AOc`UBuq)P)#X>e+1)>l-U|!k)%E5u` z>(o3XqL}ilP5bwLde^nGTKeC9KK`?R{UI##m}6o#%c@PFI-aE%2th${tFU~zSOLIq zA3lGYch1C31&Vwk;zg?iq;3c+O;V!mW0{1~7kf$7*n?o*f1*{6+kXh?%nDO^R95#6 z0KzFs%8-%@B{uzbxxCq|?-q;OuJ64Mh>+8GKJNDWhyDIxf7;D)gq*-3dgq+CDk6Pq zc79jMWII>E%Ql9Jm^3`R7=LvWV9~8PcqAa@dPF!`Q%q(61mE?`oAvg^a(&k=w!T|B z9|Tx9@;s&UaXLRA54+>>X&O(QA`25CI!_@uB^}g=>BozqrE<=MWW$?_1p_PmG^Joc z6zjQEiCX}o5SqSQEc@klSZ|wtNlk~YBNqf>oX=@I#^Z51J$ zgzy9p&KmPNt z{^(D?__IHaU;Ylp4-}&BI%!&F$5|m>F2EKHYLvJRE9C*T`dyJJUx*J|Ej9eZLSfWY zc6mHl%Mj|GuPg2X4Xv3%lH?gU12cek?XvCfhW?Xg@rv4&^9>S$NRHEd-c8fPbiSX@ zk2yXAPXGW!XzEo#RPDlsYn6p+&ZN={h`C~P1E0nUPrMu2%I6N z6!Ub-jHP%z3n%83cot?=PR%(o zCvD^wj+|poGv_JuES!K-IW=RSksOgn3YK>f1$|Lc1|h&e-KOntx^^4Ft@k}+$~>j< zc|PCI({qYvkccEAv*Zl2=$4-@x8GX~uNuG1d4G8R?0NUuc)kZ>YJ#@ju(ju?5f+V3 zEE{sY>Y%b8asjsG1v-A_@)IdAyfWl)wVW@0wI1|3lq^5R$|%(Gxhmof7Xn>hQnpc< zaaO`hvC=G*>PXDV)GY+X$73OjFFI zJpn4^W`(Q}Q$A1A`8TJ_sSXq+HX-@C@UIn&c=e*bXz{F}$$ zefjY9{oyc1N2zbnJ7mZyCr*}f6ic!)b7o0eY;KbwP$N4lQZLqz0YF$ysIX*v8d5O2 zVN}Jbme!}j#~LY%pow11dGlrUmB&}AIj)f7WkE~r7clH!cYd+OXbGyi{wu{%L&eKZ z_;``4Efw8=REkrI@kc;rAS$0$z!wy;6O-ZB(7=)A;d72@o+Kkd+ji?k`(nF#@nZXC zvwqn&0}72}J|0fT!zrcIdUt!*y?MR3*(}-~+0Uo(>HX7(clYn#Kkat=;~X<0^ z)es^>VLAMex_cNieuC)fAhN*E3-K z6eSK>DOW2H)gv(>fdGpt#DSR$0Uer~ZOW~vN{GTmtp-V1`?pORl}$3l>NreV1q{k|m3Yvr1J^E|rl~>sUbqQp_MSESBHG-d%Ayi&wGgp zQ(^(}Nd4ef%W!uaUfj~{4Q*C_Jv3bxf=6_mB~4SB&UqXmCFCsNpz+kTzVQx#5@Sqo zKBcGK{PY+fpVIw(-tFRU7tdonony=lAiNI(v>!p#vnR26UG zEaatMK+}K^;5+gGd;?7fO%E=hcSs)4gL8)O5J4C@NsN$Y98YpS!g(*}100`ae~{B& z&S#iLi3vcE91v>9i#>{L^-9|6S$rfT7aqiBBv$WLnl#1m2qeaz{?Ps7KWLtx@L&Fy zGMxcY6AQ?>Ks6~npjTnH@kDU_lgpnvbqElF%zc(gvxrq9jS<8u#mvcy0BTwl5ilj$ z4$!*%SHFIGcs#oXROPA=EtfSUB6SLz`c~DZfdU8>bz04!Em5c*0F(s>(*L#fA?5UH z)BU}l-nBv2Yb8G3`z6mDQ|6+X$(C)aX92<>ByJTJcf%TlfA{p|v~TC5J7?Vbe_-0IG)lt#_62WoZ>7wDaQgXKoih6 z=o{xfkS8GZLNP|p83mjV&U@bvZ9lZj)v#VS{os7-oCge&=Qxh@c|0FZw{I0r1kNpgm|D{IguR_k$%cQ1bw(^`N)ty~2oKCWZID{cfp zY(3Zl&ythmXrsWvlWTpu?z+3szit+H)Gj@FC%`;U^KKmP>YeI|ApC zj3XDDrUjQ1#M;IOrlFs6&Jj{9Oh-UJH0`?WH%-5F{fp4uxzIQQkj$LZe4LN(r_=lC z^nRM2B#t1-pp$b*gcPj&M-T`tGOBzKqC#6JK9;PAiB%8i?=v{>dgvsQ5>!@4g#vj0mTDcFq^py``rb2 zOFgR^8!J0e!dOj^)>z1t*b94haT+3(%h0p6G|+_-5iq`_w59<<&3yopBjow`kN>0p zRS&$`M3$C0*ma z_s)AFhk{P?`>O9EQo|epVduLrybArsxfWd@(g~zgL@Cib)UYgbn$G9*>71sRxehkU zsMB7xN)yBivk9{B=`@e$Nraa}^X7K>>5J9tn|9l}W$=rx>4&auDR@c@hcUg|oqzk) z!|%TQ@Xh<({(L5vyFi`*SaQyhla8X)VMQV=l1%`>f+&SGWlV#x6)vLHF@lxaUA2Bx zU_(fNjtE4^#P-#GpiP7+MG|5P1@@@Rj=vac{TRT?%hjMk$_e~6FJC|+{WXQ@s&H13 zk6Z||x(kZg#q!zp?IJ+MI{*}02$rF)bFZ&0<6S3=(`h=~?{;tRAHIFsKOZI*(K3+6IY(G2 z53Sz6!o?`n`T>=g3QA05eXvrvE3GBags=fvOZ=h|nii0Og*(b};72S4*0o@T9{>PI zN;91 z!*=P{>t<-fqVvv4<}^;M!#Ji0DGH)^FTtbNkwVU~;54PvaoX+D!(+a`&yTzGyw8XI zd^+ZovM`VbPe@Kwr^C+IN>>Cxfa}nbiYWQwg9~J>W=RPSDaCCTu1g7M1i+eKN~c9J ztq27K>4vk07d;Gsl>D_|q3KIVTPRxqtCK@SDx-*WO*t|xSW=5Znk7@2A^R-ELcv%} zi3z0Cf;gggnFfDAP&ClbrW*c$U*1PJ1{WVLD2jF(r!?v|2@5qEV~oKpVb_ zkGHZEv0+65N;Y#Dv?@hg4%KZ|UJC&c62p&wL}!tf`HVoAbAGky|L#xkx*+Sd``zc` zAO7q2ZpjlzH7`W^ky7rbfcjq)Vz&;9+ioM0fA{p|V|wyNd~n%Zb#6kD3pM{=A*BHb zsDR_*>#GqqNQ_!F!H6j6ZvK&6$dY2r?RH$jaEnBYp#l+s%sI;(K_@r0%f+x+uWpyi zn_;o^p+_RfDW>Uk-k*-U!{PpP-p}!Dg3KdM@;v7hSr?=8&UC8=gxfSGfY#LNKX$Ty+un_=0k zZ~9@|3`^Ivs39(}a7=Md^OVN(d_Ki_9Osd9R+}s6(CGkBrwYaDrcD=ITW=73XqtZL zH`{i(?H0>^weq0>LCJXh^@Ie-fzILPByxfn4KUAPVFHxrSWpT<2VH)FCy9rbC4& z$TLf3nK{ono>Mxfbjb0P;xVO>`3#%^1RN20L1i%3eO?TV%+qS!N?HZACI;Zh* zK0i(4A;qKc8H9m`Vfo4W=KK9{i-`02dB6MedH;pvBY7gPiiss~xj2>zQmS1uWQ)a4 zm-TsN>Pp~ty%{RLF0a1Y0X3CYw#Q}rRL0_xIxGwIvPJM}{ZwmeP}Q~*RwE|Gn_47P zGn{p&T2jk=c)bWLuC!7p1eM*qED1Cu<~+vpcVGYD|Nejdzx}Ph_hOvj?YsQX|J5&l z_RDWR`}|>dJe*H+$~>nS)0`Oy#5+(;L>tj1z$~n#IAEc_LJYf7EPZ<=+5$8eKUcy$H&j!et7%g>2N%!n8;z=bF@mqesAj%(kcVy)F6-68XX~jcTgRS0k;RXie5~%Fp2;P{p@4vE;98N9UM`pO zx_ohMM!)hDvgkG%$(F5DeIwQdIeOVnwe9K(>$}V6(WJO zjax0+H?LoO`kha||NYmSZM#@CCYvk-;Bv;(l+R;|vz$*jN0+iB5g=(CwZVmk96AIlz{`Jiz10e<((MV=OBV-iHvH0qr2$2e_9$To z6NtP|Rt7ckXXRM`~!73%aAafmg3*)HApUbaNQnV3=c7w5`#T=0YW7B1ic z#d&C3Xa?vPI4ogULcgMRz;3{{L+_EiP$}yWMx9?fk1!qOe3av}oSxyhgVSD42bo5R zqvWJ{zN-#DgsdW?$|L7XE>)kY2$sjK5jWUBK}s=?lKCiQke{h+0SKdjAYn@IqaV^g z`Gc-$@lXF4rUM8-PMjH1g3Op%B!ejM(AIFR1QE1`)dCQWSQDthE)+f0f!*hodX zxTs53RL?@~MX`?P3M3)WYTazgR3KS^DDf@(npp&Jz;nT+AruEUsxo*EjvL85YB0)h^b)ZO{b~k;r*Y z^Jz>mr8x-+IzMdt)pi(ehA?#PvTGY`I&uLJC5u+WNZZ1&aLXaEhus0b`OTMK{^?)5 z{WpK|?a%-8@Y`R*^9O)PZG%k$0qOd)^|#kY`by z4iNyt(1m8*^qbIZe6MrV8UP0(nRA?vX?mDX@5ke}d3uIC0(zhZeL(Vnt~fs_oNmp) zlnrIrl7%zp8ImfJB48idb=z;c{-){fe7AAFafpB?q;N|Y%gDVAms-7_9s8+;#{p=p<)S^H*%p(Eb{Idq;(xPV!v z6wfK1ay;iarg-Kw#W-c2n6&{UX+U|2#<9fESgB#{pTY9m3yLiL5>VP#>q9NqQ?k@h z0_X+ETQ;(X3|1kia_ zXoCEVNDJ4l+To@dZhgBV>?P+oALi3TJU`@lpYjQSLUXg;es8(`j`RIA?~l7L4o_dF z{7fEnNI|a8$iNRMS6`-*n^0qoy^Bef%O%#3us-LDO}->z$}KEmAJ$4!{nX1)BfXzU zbqww0R#qsbpN58_)WupQ6V@OT3ZNOD zCvrZY9{)%G=zsZN{2%|%zk7S|4X&4spg&LJ?(y*Ou>1UrcW>W6?vJOY!=?$2oK|J0 zoLEF>$`!xo>NXLJ{_vxltU)8Sp}VDa>755e(44f5a#3qYjkWxFY zE7{6MOP~i-RxfL_vR=FP$O|AywjdP%%5oJ1(sZvyy1GhH_4lM6V`{w@n?T7vI zVaz!pf_LO>8jBRNN$-#$LA_?W>{cvx#KNYl;LLD_XlNj)5{E?9Wf-ajAXR#`%e3A> zG-jiQZC1*mxED*?41spCK&jrze+$|=^!z655rE3`FE#YD4Cdy&k-$%;i1OXR8rB4&1g zu_y^i24+c#GbfhhgKvZHJGbb=X4~IwoAqi~tQPClK;AWt0~RETvphcKFTah?hn%u_ zhfULTZ5y0pgq$T#dH+1`_UY*qsKwwad|%9+j`6Ox6D^>_YW4~k#fy0rkWyM%-wSA=uIYN%_ACL8LMatv z1W@7&!DM)$j*P&<(Mkb@>dlc5#3_9VgbthlA&RD&2}*6eeIx-os-kmt^xGAE`l3N3 zBR&}FFeR&HNRkfXg(Z zQmbM#2WVM>@qnuBYP>%Z#DqWizWY!ApndTg|Ki`mcmNULEXiaHLC3q~Bq>2o!dX(% z_Hp10DMJ=RyIC%xc_2{KunN8+tK>s8lmyjJmH-|BQ7f!75K&6bmJ*BQEO+^m2{eMr@FNtHk6@9Xwu@@0?whg%7u!E)FfRk z1UpNkJugf+88NDeAIzeaUgHuIP?>TK#iyl|Q_6`Y3m~9xL$hcW>t?ZO7prdAw*7+0 zTitJtk|WRO`FxniL!3_2bc%75oP|+TGu3(LNVPdE<82=%7E52a$uh?o(Z)_8qBbW> zA!|ap=hTK+XXWBO;+> zW^f|teDI+UUGJN|85V7~X!^eC2iJ50AROa3<$0Q?h=jiL{bpFMy2Z`VF1xPtE&zG; zn=H%@a~ou_^!=)n4j!N2>tBEV)xZ7u+kf-3cYpa8`!7C&`)@$b;3*6(`oMymtSuKz z{|B^Ap=7=QKq_!B3|)B>9W*NpEY33A=yv1o^(j!=gSqWTOKy$U$r>K3CAQ>2eJW}UE7uun1 zwmz(VyL7&H-Xl4O4iT8=9M5sy&(l7}(>xv0d`jsIc>;-7)e30RAx>rIT%Cq?focUs znNgCm0}BSX*rB9T{##$g#AxxaKryX_PcazJ=CVMvio_)AI2S@ekI<59UFdySgs^DZ zrS}8*9?%Oq1QtllDaB)oyEr|h>6p_joK)tId1MiRhMILd+;;t~Yj21;$SKcrA}%ftT5IPU->dIyA3xQ}{?HlGbjmKM--qztPV!Y;4B z0Ar1VDj{Mf--4h5#fwd>mbWHJ|F(Z-~1o`kN*e%o5K`t?rv9$&~|>a@k0}uHYDbE z?;n2kyDvWb>YI1pKAp!gi?m&6f+NJtnxHHpp|}z&fK?V3H&VhtKx}<~Lt#NSr<1hk z1Ic@*31=(T=bYm>Pt%xE1jvwCB+J#>7RUgWFB6-HE~SrDb2Q=zf_FqX=X^TPbBdna zYB9XrE?=&P?V?$?e$%zXq8<9sH7>I}pXPT@hcDl~|LmJ@zk2_$KaDBPjfW6G=fRYb z3sSN=MeM^?9avf$&GnR!*dBvXADU}Y=Tj&mv&a|eMvcu8ur5EV#iT-R*cvJUR0*xG z|Gc1#LVhW(xGY^LwnTee1uhG*W)$r8TAEU|Ldwo56u^~~ubo=Kfmg(b{mR+|$_us7 zp)@22W)Llj(FZIk%{d7~@_soiZnx`KH|sa+{;q9qJoY*A>3BNq_UH4|HSqd7iy!}Z z``u5McXwUr@h~6XJwAN>?&+IvpWZ+2kK@F`jdz|%6Kx_O*Si$1M&IefU;rCJ2mn-6 zCv-EH_)GCC6$E667VQgAYAHco289vP47e@@#U!K@P(4Bs5JAM!uWtGxj*RtyS~H^U zUsmD)_2>16c4kiJl@buRqI*hOk3@*5wZc^;6_Ch#YFgL#uIq6auVb}K&{o6 zi+qPb`CC{!P|Gl8H84ctJhqMRy3qGwvvz%hO-F5TO&h%T!F%%Fdk_X>5G{sx5S+xw zN#Jyr50CQpUQ!Am5Lr&gc-YNP5BcFSJv`?9F+K0c^O$1HAmj)fv2&;cZk5Z>)W5M9 z_GG!<^$O-h37(ScmXb6mCdYopP{Wk2z6eJASuIHo@YrhSP(|Fc27`9BMKuOb?d4coSJqbAXF!(NqpT&x(TItV!U0` zCwGCsF#~7Ru}Ud&&X5_1#Cr-YwjFc}S!`gjp9mpV`nweaoJa)A)Qk?_(N)S%ZD@zDO84 z0AMMXtG5ZS;sikJFuOj{%lTKJZnsZeg;+YnXNjiV!{n|K~Ima?+mcVtP-E_mY9d3Pp6WV3)oyR~Taeg|V-klEjr_;kc zK0z7*pfVlcT%DI!Dd`I~FPP>MwQ^A>kzB+|TNf6-S-N)N+HKRUeY2p@p>qK|0tioe zKF!m^eBRB|^E@3=IzXBLB7oMU5TFNi+sS-@+FY}D z1_=vIQeeo{fy&_xHe3#UF+63nDpppXP5`K>IT01twiafv0LB4&bY1X^rdx)7>6#5S z3!)YzLyl>l(tJwOfzv+51E-Vl2%r?9$FOetmu-I=x{V7R zH+Mhv-FnL7xPNs(yC)C9dhxY7oP(cE?O^Gs*Nre zcjb=YVxSQ{$vnP%@$dY1fA4>CnDEtiKl#q5pR~Sd+K!Wi239M7v+3L5_vi83`-iu0 z??3%N|7vGB{GUo9I%cskDCJni4Ue|&fU_~GH<{qxgt9v!64lk)@sz>=6b zr;GmvuDoP99S{+^#Jc*RNpZ4hrXHaXAEocTkOp9657oZA(iNhUGcI88QZ1yT0!4_3 znJd#)NQcX<17)kM$+sk_D@A6Dhif5}+Ge9->h1xpIMOpFsyvE)1WM^hD8Z>&ATfw6 zY>g#Yf?U43pt|2oKtolTa?X;Sa0p?w8eZP4-`s6pZx*+0xAYLshxqt(JRDA*;@wU7 z{ts3^{>k>$n?>I{=K1lk`|AGTcW)oQdH?);IL|pcgy5Wa!FC>_wyR!t9)oIKmaScP zlS$;&hi$rJfau7`DL}g`S7=D-Bd{!%-q+gfqfrqU1ya6M7_96Ch$wG}x*kx=qyUPz z#})knC-VtnIc`>1NC4mvwQetT}kSHL!c-dUq zwI{8nvLy4(g%);P=fc3X6EHTS?wX4F$qX<{*(hk|Rp>vg{sD@Qz(RbK^SA>4=2n9pNAjrn;mZ{N73F19y21W<#=3JOw z6lOqe<>!klgre!AOW4YM>{eYWAJsnCUC_H&dp!$*3*|QbHKbUv+NI2=xSGo(mnN5liJ3mgS&XSuN>OvsXUK_PJp-{az4>0UQ>(1eLtB@4<4awueAvM-hOCoXuUb zpA&*GGRt;NpWX!kNGfR{fKJF`+e5#EVS&Sf`UQ3ab^|uO*z_d;X2=YXASH;CqzR_8 zoS)&ihw%jCS@Og=7TE*kTL=M!8FQALASXx+$&~0yK2*>ucg!B5L2~nN$c6T(Nxt!!%1AVVY!4dM70(-Lc5E^aG8f`N&z zhttFJ35fw1I0MucjY~*T;9K>lq-M)hZ=v{DaAnz`IaxrB7Rc0Noq#9Aob&6O{@?w{ zcIf2h&j0$?(;xoPdokiquHI1SRSd*^<-%BmyKzV8ln=kOEhm`3n6)A%L#@soGB{&#antEC@u? z_Lrto9X@Bnvp^6sfDgW1uU0p!3iopM*)BUIHxhDDb8aa$2`w5j%k`wn$kS8WKanmZFnUjSLctCEi)*NRZ94; z-GbT?OAZQCY#zyKHW8ikt^w~u*Ea3Y^sBC0__ibON-l|2swzz}#d)5loMKLyg@qBd zS6)%Ga{}lvAT`u9zU{l^qF=8T%SAWzJ_KgWImMVVOGfs+?-yaQ4vS^8ScRqyp(W=9 zge4{5j3nLQyJbfMObH&pJiqpSr}@0+c@I1T7~_EC!05A!N4&(S zmF3iyX#}l|gA^stssstH@$I5*7fpZL^jm5MPn~xG973L_)5CPQKOgT;=SPTpfDGh| zM2Hpsi0R35&EFS)SMj7idlpp*$q57i9k|B(-nR?iZd`X0!rHqQ0)g`&K$7x!i1Th9 zcj>&3(;?>*L~lJsg)C7KR))(i z{HPyZ0MqIC^?vu8X}(8@=n2SBBZxW_MyP3VvG!9d{42Khg1$;q3)bLJs`an;s#HH; zfiUB~FBAq?_zE>PlIkC-*0zPGHPS1hUQ&|nHBnzM^woh1S9l2@?ZI9$iclZ5EvRzL z>m^ryOCa;~^3C7=-lu=>*T4NTa(?sr_4mK?oln2_{mu5pX1NO95ikJ{L(}&}6vip< zo*%#Z=G)J|`ueN4@19R5ksP`Zf+y!dNa+tTPkmgy@`e zid30XigS)Lr^w783|JDNHSQ=W3)TMx3&Qb|JvQ!308j`;fCvM5=bZqY=Xf6H7`bo# zi_P+G(Jp$wa5OkvwthMEL*F>UDaNPc`J4O4-@SYH?!){0{oy=Ea_kA51Kp2ewZB>^ zDp#mMtoXtnnw=DipfjL_WeEZT7h1Nk1DEt!dGM%<#y$-%PgM7>NUgajkJ}8M8sTDH zE|pT+W_7jpYXMRr+G^NdE}(v%B4Sj&!q^-ro3U;KD~{H}l5%f$=yi+POur`_XjKTXrn(|12z{P>64 zAAWCrcNd_IyYv3-!{cXf@4tNa`0#W(jZp-H^Nn-PkufzT<}4u?m77>%;;QllP}VR) zktVHsu&&5Dtk&+YfXS5Vtt~8whEX8k2nk7y$uJ8~Ipp_zjB3u4o)4!nB zfPm2=%95zqjK1CmzNiw74wW$hYez`r&=HbXEf9T;a~|6k+ZKi%`+jxx^>Bg|2f#`9YIAq=b~?*hVTg@@JB7FQ@xkSAOy3jJQ|q%Ll=80u?_SWILo zu&S+FSB4VGT!^yZ)$m(=fNhV$NMkKSD0{=!d|COmrUXiqUoy)Y9*}hf+Ev|Tg9J zRKBI0SqZ)9;(g1{32Oe?HX&BbR(GZv0cE)>@j<1%&S`xkn89aZMqogq;PKTRy?RMa zhpxeI!GfW2=PjWuMe3I!59Dzj~q4Ur*W+g1m zoFNNvmYl6_f-~Cp3}P~cSUHYM(Fb2_dvSM#zXs519h++<*MP?tb)p_;3CtJb!@3A)=_(gif(>4xAI!IVhSfz>J)Mv!n^8 zDAT0VXCy|^G8VHjWe#LPR;8{-5Eum+tgJJONX{HN>jW!gNg3M4-LBo!;qYO9AQXUd z*91%9%T>K_H9Wc_O1WlHGgWM)hQxAc2xxkl7NZh^GjY?zZ*Ka(^ONn+$?cu{)vxD& z_OITPy2C1qd%@!*1Q7sIe#ueXcEs_R$eTjv9Fp6By;exAqEd^*i>9MAhX9#e`Y zZmWHu&VzGMCd^mWfSOMMG2_6--dUzFnljLNYvzwHG%_X5q*=TdSG#&NseY zG{d@IuDiu53Cm+eCC|8Hiy}u4FDoU?e`%erz1-X z*g+6vHC6^p=#q8=u~s}IL;%FO(73j1`bFFIO*e#o@NGk`L9)67olX|#nB$bDImbDr zsJll?n}`U|qw|2yHBH-f{c_bUm+i7|hJkzlk}RB3VqkFCwxM76VHt*n?|R=46uhM} zSOB~$?$ei-LkLSnf^gN$;G97>u00I#>N2JQ#Wss1r%L^Pp+lHdBLcB1cdLX& z(DNx>n?+=hnRAvLL1y3#;LvrxU50Ms+l_CxXybTVebzipJU)%*`}zDB&rdm?AkP2{ zM4$|s>1&gSl#-&$?1{3wOAuT>30jxhA4F}&7>ARz_vp2!oa)^9)xK!M03 zdh`J>AbRz;#X>0@(Gn-NAhRrM5kMAJJrRr4)XV8Cp1c=l#2!&%xhp?tZ-3e#(#!`)`iBFX#CIWYXq(MHKaRt62wo9);PEQpZ<9 zXN9D~Y)I?kv!F(^axqio@cQ_!0>*0C(GufVYyU#K*n6>@RCO|F6ml&9C?RM8+-Pzh z6;uM0j)tRJ#{g+GK-igQX)vPt=tg=(da08nbDi%&EtfuK?>2q>3Y zFIv=bePUL$0k5Ou^GOBqQOw}jk>l(bfR8~?JOQM3c{AGxytHS z?aj8B=|W_nSr;*>L#^MeA*Fn@rZ1m)AyUd$W&A3mqN@{2MI2~zqd_FRyv-_Ew1Y>5 zQEhW+U4M;%DuIYlyRysL#Y<60p}o|Q>)Nqa@*EMl?b_|l`t^(3@7>;gdb8d(VVDve zo~Or$^Z9us$#-|*$3I^F`dp&?v zQ7$6@)}2qyi$&QK=yjM3Y_BdXdsiqZJHJ>pNU2!PiN9K3JIcn zs0_yiaugcuT5MbBdK!A^S_~}&Z(u1VhzaHyrWvMLQly+gL|aIev(wUX+w3-JNa1UJ z_@(@zey`q3*Ln@RZ6y_2LCmZcOL1Yk0gKI*y5_Mu>uY-CQulYU(#7`6CB0Cgc(ubt z2ECf&5+2ziDTx^8QPrcn7Kfg?#x<>L8X^w@APHjPq`|7D<$(c(5L`n|Z~`1vb`qPsZWYbym{ zA#G8JEWs4n<5TGky+G13Afs#m0Kl2UHKoaV>1aY6dSofZ0d>TK9=HUqShkF52UiDd zm*5ea0Bwu?fc=6t8`|E|-3wZ+up6lDq3JL*;DZ2~szu6>qj1D|k~qqAlH(3e&v-t{ zbb>fSo{jf$gw7#40T9x}F4a3@%UMK(mH0qr$q`akUO*%XtBDg3#xmMn`2s9l2G-4J zI;MJf>wOX}1=7E`45q>2+Vt&4c}l7HU{p;j&ahnL|KdOHfAAyxqkjfZ?;v=zLJtue z-)qxz6w(3!I%lpOtOW~lggDC_VV)%?juDa;mg!C}&@LyFyXGV*O5~hVVw=QdPN=Yy zC<1NcHVb;(KY!RC9ST6!aWx+WJ7w9FwQd#&ihxED3q-K>XfnH2^(Up$4y@Hh!{+Azc87EExrB)OKROzgQ)TKhfuiWCcTQTR)c3tXZPL>9MF`Go+6u{&BlO-Og7XN7%fADz0S9z3x|d|a690)E~rCGen3yg|=yjuvmA)x?ik9+fx&qZ-Z|@FiT2vX+KG0oX40V zPjgOligV79IhsrikwBR_MINxs>_pN9%m_8eA!iX6A}EKcB6T8&7RZ1M}PkA=f8gZ^6fk^ zQ0to}P-sFB$2m{w{2Y((=hIUf4-!Y1U|fDyof3*fyelByLGD7rpoXbKB= zMf+QnuG+2{!J%t?=&2ceyKTCcq2KtX1s@PW@-)x;czQ^ur+K_j34mIjm$J5iD(h0Pcd?wcZMdRGkZp6EX7C+VN3XP=pyIW{xa# zPLb2dd6GPG9#b4S&mgflVTa(b%wV*ZDRkg^->sT{+jh4;v{^XES>{ukpX2;2@x<}O zat1(ZU$oor4$Ifhuk$>`)4TEc?R?&OzrK0>(_!1+P|kWOoXyp_RfUTtYSd`8D-msPF>6O2>-f?-E&-_#f*_EZ2Tz6*Xe2 zhrc)iu|h*Nh9%xCr>;Si)c4d&A}EB<6*#nO01+U7dECDK8{hrz-~IOfYnI%2=Li{j z9@99^(= zkwS6r*SJ7~1D(@Qc&01EQ#Aw;E-Uu(_KMyw><3;JyNW`*^XeJA4WGQ9f5lV?Vk;)8d-819}on_Hb z#fvvL>o-l<0*CW{dVDE`?28P>OrxV%5@-`+od_V(e+Zyw&i z-ycr12zsI>cydUH#j}HertJ`H9hV|{)d$cMD+Go>xh+y}A=ZX!uuh%}O``9nG5`i@ z!ns7#L}UQezRJwoQbnZY8)5@)@ro#_@3KiSmeDIyDC!TP&?QxN2UY*n+0jiC#F5U0 z(P9vNJpi}{n+Dn*hXEHowjH!B1P_h`0aAvTASRqAh!J9hn1K_s0AwiLBnsNWLL`wo z$i5UZnZFPnrpaTMlQDT&sC1yz4bF4M>>Z}pLK^B-sJ{;5G zkRBiM`}gy=AI^_Yr-%E)@bNgb`I}y%&9_QWj^wo#_ zVaLouq*8ujjZwu;fQq*2wOahlT$8WG!6+3?_ZW74xnh7>cAxZxKeqh zA3)C4+AR`hW)#NEf`E>&3DWh{FWh>Kn+@FFxtkle-n!Kqmn-N7(AE`&DoG$ENVCin z#98JujAt45aN678m3W360oYn4%R~@Wct>-%D2@P8e39N#gswMSn*lf@vm}cGQF}>M z6I2A4wJ@c&z!;**b;xe1>Ck7Xtbb+gt?h+%evmOARz)JUT0{vDB|bnva)!kc|L6Zv z|N1rl>pz5tci5Miv{6!;%?OhL|A>X6BS-PB|xu zG3S(1%Bmrz_l;`k09aJ0^@^l#+-gA&&(Dv~N3Hm{*Ejv&`SE7Cke4t0FMm1xm;d|&E#t&lY?h9NV5KssWHf*usbBl-9v;sXl9A!;NE&9Y)SPa|ilvG=6IO+5h>g*Fsz0m_ndM8Ep` zNf2ZP0B{=WSfNXWNLY(><%%qjvZIR^gp0>7psFByEWuteBxXh6qZe1ovS<`@<{UX? zNeMsz9kr{j+b$QIe!1=!8{c%~ynqwQX*$JmKcDyWbdK{B(>TrNm`34Pj1;O?2%sbK zP~|+ZrmLX%Cb(c3wL)N_Nm)El8OyR3b;^%@4%F#xplnWNPMny_?j+}32yM3r-O}~T zFf2khgwRvtg-FPuAPNZQm|}`!j+0Dt&N0n%&N1i2DQ8vtVJ^C31PJInAfcAUDg~yq z;K*x@k^mx*+U11`98MmLZu#PojW(H49 z+xM%@VzcVj3qK6h1W|n>n^+1Vpbu{7ak+3qi=jav5$1%F0EPwKto^EYV}|=L`R#AM zdiebP*T4Asi=X}C{h$A0e)k2$dw??lW8=^VttUqm-~^bZ^zhZX$BXwsys!`~kdxL- z0uf^4Lg$*nck9sI`hM-3!3V++95j%pc>ZwSeSO@$osJKX&Hw=9(KjlMVSFuU;SXzR zlUg~m?gfB?fX%qo`LaUjTGtGr8GO6;-JS0?zG*SE=vorsxSx)1Py4s?@k1IPAx{7d z;J^oTfhYiY^`55V#cVwkhH5T}AiE9BB3W`OLLs`m2{4dvLuj4v$#>)j=LZT47dmnc zIgid4`qCP3B+oY2HjSLeoF|Sk=a|wgoH-|v1S)U?*{uFw{}YNGrn2p-O{Ty80t7&Z zdhgD zU?D56e{R06Y>G;4l-yutHsHdQ2&gnhwcf=vD&~OeK4pMQIE?jC)}LJYUyA_*gnCwtU5QR^n5zJd-wjcFTVQv{r%JPan6~7Yud(<1HlXc=zLgF=m@MC&7>MtT&aXw zdigmgPBU}XxV~@zc0Gl*tIwnO2)lc=_1b)RdA?;0mfM0r=5tv9nH&i~$~?sw=hQm4 z=-chkue+u}^vsR-UGS@+89Em{o#yfD`^PWdfBWvkhll6KV;nQ5;K31jWzWIF{;Q<} zh{Y{mG$KmCFL0Gj)l)b>s4|{TT(nG;B<#G%-fb|PhECgboWbq_` z4(cYw6)7@jAoN4OzFEDwyLofBdAaDfL|q=;ewTL7<9IlEN_Q{APk*xfn}1{d-R}&| z(jVjL!^6YZ-|l|*!ko*{}u$YxUPnFwHK@) zBTfC47!8%$sL#W+UGQ>Lq7s5@(#aY)tcf2Gk?LF+F=_+>8LWI7O!MQ?or8$zN<3$^ zBveV2s>evA+TB;*BO-a5BSVgyX2Kj20(o=}Lqkmq?SP8~3@vmmGz|oYjzAbvf;qx@ zhIx`X0!Pr&WZMo<bAI0Q<3s-Nm>wSE^DZ8D^Z7i*DdvT3ovH^#GEv?)K*z+ z9KE<%eDY$oY&{~(Q+htecaQnoN1h`?S;$aK1RyeV{a0Z$O^g{YvPyWdbGA!sTQ3&b zlp2_nzc1WpNwHk4O0_mB8>P2isZe_i#dMdwp(R3PqbL`MP!@P$De#K;04=GyKq4%@ z(-QMK28IBg)t#Y3QmvG-*$4z#Pj7tl09 zMWll>|U%4c0c zG4r7U%CVK%Eygw&Re(l=7g|k-(0OoxmLke5uWp9F^OMzjDKB67U;fqn|M|lYxSVUN zi$1RE()FXWpg<;Y0}XfGN`OD#eY4BYWD{mhl}syRuM#H;Augu`Y_5RNA-Uj!GZwru zfI71}XU`4qeHg;9 z2wfk#t{ny+8g!nDA_;>|wT?NZJjE1q&VV6=u5TC1ZrOFKt{FONym+UR6O?-DJ8D|$ zdh8mY26Yf!iaB5&8eS~@uxbJE_y`YQJiYyk-+b|BfA!U$|LVi%zukTFC7d3BM?eNg zE)Y2(v{~{6&!X|Ubvj-3f7Ft1T{HM*8Ty;hZ>U>RGkD)2I*!Nr@Gc(SPW!jx;RDY{ z5C+$RYtS|5LZL8NAeY!4pkVpJ>sP_lGX6^#gvd3{cdi*iyY}s^@76B#6gu)AMDlc) zPw%JGhw1nso*yBffeMkuIWSQnsxvPIs**^nG@$RO~*8!;(SQ+F~xIEXUP-f{L#D{6l>~HkgX-4776ON z>b21*;IiG(Vm(V!a5(zhGmuU)%xt^=W*MxLJ1_&!Y!IXwe-*S+jF z-(9TVkXz(woSxqucJGjAefyo&%_rcy!{fW>hhN3{15#F|j3pa7DnPhGtFl4?3c`jG z6xLg2xTu7U0@XLl)sgD!uMcToa{UOflcsa6?^P`aa_yjeavvOQve+7w1* zOKZ&p74)l#KSCgIz>;&0JY~qC@xyYsxxIPu^5)a+`gPN6B{t{NwBH?{cc(bd%LRV_ z2mSB;{^s|8yngk1fB^gB;frtI|MIiqcYqqf(7v&U0 zDAm9>UDP(t?7ZmaWuuc|UEDl(x}19lpg*F9!rhNbPw zJ1?&!G-Xo+OO%s+9u`dyQ(ASTQ(q7WokP$hw9bpsKRps5sJMARn@rK;(8IEYMT=bk z7XVo}!92@2!>Bdt5puR#W*a0SI$F(Ir%Di|3c6R+BA1O}R+=^NC^owW9QD;^g>}_g zfS?WAb1Q7v<>zFhmKUO-g_Vr$x3rTVw^HWmY5+4 zfL2i>B~ocvIUMuukRKlT@sU4#n4X@_&-?j&O4FE9&Ir;vaR`psk)TcrNO_JjCr)G1 z@J>M|vH+5Iv|eYqjHNF zI73P>%`%9SQ}Sf`0dDZSI_d*&pC5S(f! zmI_k@5k{?}${F;3&N{#a0l-vdvPx<}B_@D{#m0wftDl-}E|pknwyU^lG2aHPF4o_w zLO|hI>7Y>>MFb%xSPb;<{iAlX#$Ww~Jl!iTVyP)dW#nk7szg8_v9ZC{GJ+zKnRV%> zEc2Y^$dMtlot_Yvs-7!5jR08uzanKRm{6nQ3~j*0!aeMscZU&;SLzIMO(#r#kIs}HoCXUWVJ ztmjfYfn~u6Bcr$l^>_V>MLvK2`Z*nl2tW#ZCgtiKh?EjQvaFnFCPBp@oO9axV5NAO znI-4UEV+#Nv*n3EP6$8!TmM<#ZG6)MbDpPZKFs539#3-|<9wWR1WsCkhraaqm@U4h zEo@^O?OHds$X_QuN)Z~CM!kZzvBp4Gc;XttSMcD>$6EX!B}YzT<2-%abj!tJyI5|A z#j5KUF7!xVNA{+9JdcNIKIAy1c}j6g^BCu|4mvTiqj+GT+DM`^4vn%Xrz2X(TzZeJ z+pb(`DO9uA4~hZRf0o``Bqa_kY!qWPwB-jROA@8GC^Y0+-}WvHuIoeF`)=@U=R!w1 zxn6)dXHJqbrzk0YM-8MP`0E*5pa1Jz4LXI3EkXD71k}?o6IaPF? zoO7XdP2=0X39aip*EVfvT+?{xob!NA1UU0NrkD~ZLGQpfu5Uvd+AjFUg(3Jhw4EnU zO+algYzJ&RavlgXFeeZJ2rVpIw;BAhN6GN`UcUMD7r*;&{^GZP{ul3l{hOz+zCL~U z0H-IA6C#sGaski@h@^{}15!iYQ)qk`T(hKZ5xRleg$upIz&WPrDV`qY{fBsbALl)A z2J}E4T|>rn2pF=-Cffb~Bka$bZCR4^FwBRD%v{qR&vfstx>a?nx`yuR2?iR(ex!+z ziPMXo^q}9SKOh=tCYs405)edCCK3X=$?isX1Km|!cR2H&*1R$^!d(yU5xGu5X4-Y@ zoPE~bD_0H??w^0|{&`mLi3xD#qyR)r0su6F09X*-E<+UsU^#j{mtTBa5O(y$#4 z_s9KRzrPz#50H9i5;+n@7hyF+E%}V+I9pyNiUd{2qeucxrIeh2n-JO%x)7Hf7u+nd zS#p>$2MU4E24f|WA@#fQwC(%NFz(Z^mo$L%plJ$-iG2bZkqd}o>EE$mGI!!(h{q4c zVU(PR8IGa_;IaO+%?qv(3%IHgwvh0AE8*FGE7rzaXuz8|#Z?IG^C1GyLY#+o8JbyW z=b@P?&4B_5cwxwRNa-NMLDF8*US-rg_<&NJQFj#=&zi+cYUY}A9*_CB&Zm#*_#x+> zu??%2%bOo|-E|t%ar^$T{-Bz!Zoap?e3c~a)^GQl*C`z+C=v)*Hn0RCTBZ(qS)4g5 zsU(48-2&|@gtqUn$TQ(@)pS%CRaQ$eVrMW9&ffkXuqM?<+GDlDVi7h#!ru;iwOGU{ zhMvx|*ub+Nc`#K^O@S1S+v}G<`QitE{NdfVlCujA6x}eJ8fJ^$Izk8mH1Bu&REcYcwxWpvK_QuTD>zcQG+mjid(08 zQV1}`HCRg9ytuVSH+MMr5NgY0`7h%F9Oz@M9fNYg_1vb* zafoVErRAbrE23PPPJBqk<5X1POZzg`mKO9YEgs9D4>{mQR~7Lp5h~%J4VwT!rRflr z6S3d%c`;CGZS7YI@!@cek%<5c<>B9uIWz>MfJzV=43XLvW*sbMu$bX$28*a|00I~V z`aw^lo_ZV-q@-hplpuRvT65Oy!n067RuR@JCZew0Qa)X*CQLzyuRAy8{y?a{+qjYm zDk~I0r8@k;e05+50V6|<+BGzb*mcyk$Ptl%0YE{rrmSOzQJ@!1*@m}ZAi{_OFbWI< zoR0Z;%ui4G<3sxJF+bkt_0ws)Psc;fSx^Nb6KV`Vq)Z@1U=Ad!#tV1>Ltr28#=c#{2L6g|@EwBF5vOUbrHi;k{XVLO%DTv3&zGN#;*c^K1> z)0jjBh^-tAtuSZe&~Or*ICE|QXPGW{lmH-z09mw+G@sGqX0zTM5k)ooco|BMhuZI7}zqqI6 z*lX501PU^GAY1xBR5B_k&Utp*tpN2m>o>b}Aof(?6j&>uTHyfN96UlKWCSK}h~hl;k-DP(TG#pZpey z+t7BQX{nvXwu|jNHXVlsC;%28VQneqgmTi9a!!!PJdUDSB!PITgpC689Rfsb`xuD= zmL!Xao}DovGsPHV%iMAoxoKlNZ`(O{9mfV^KqRfkWfYYxs>;YAG;Q0?Vz&&j3+;?K zQqxfEsBIv&5E@`>E(KK;R+%^XW=7Y`7+T&8@b=sO?Z5lx+rR$XH-G)B4}bgbpI(2v z{rEw*ThJbuxm(VbH$1=4xIk`A=#-feC=bVU+6<>nIvvt!2k8Vbf*~M|6axZzo>ekv zDg_P}j#sV@TtLKT+54|45FJt-t0$tp?|88BGCQi!VHBRS4w8f}X@j8zrHnnkB>zsY3zDr!gI5JW4vMoMhbV*ynTt7?EZ)dlpwOLw6nG5;RMHl+)9Ad^;Q; zBo7>~mM^}VUp&){{qFv-c`u_}T)mpFo+VAY&HKadP0j}*0WuFJ#e|-HA$YAK`u#uv zSJkWb(IT-)OB$xA(4#mlZ^loLU4Pl{T!}W%VV*tU^Mlv-PiF0`=&#tm^WQF(@3*h6 z7|rRuWeunZkiuzq_42FlfB8or-o2KbEQSJ9a7itfO)t@yswxs1>nbvy4*h<&8#CQp zKL6nle)!|Be*EnAWx$q{P-M1fUp&9KxxLH)A0O7g`tIGgZy%qw$K8O#0K^yqhY*mx z7m}TKVKiN|oJ{Zt%p60l0MK$FB|s_e&J-;QTO{6Tp@@#qps@a+^|4g{XD_NJ*w4PK zyZ{k_hy*m{B%&(VHqAvhTh7{+V^E|##t@bbuU4^Zc+6?N+rE9ge|>lNxOv(g_rsU~ zg&6}Q7fe)S$-vkL(1In-p01W?fb@fwB~9h-6%69OXxI~cTycBPCJJ;&y8q1AB!lrlXp zFD9(P^%Niaz6U25+%S|V4vScaQ74FHeoP@guw)jCoGO%-7N<2VGN7~Yu^imllu~0~ zrAf$o(yU#Eodh8UCbFUrD|oHNBV?ms$Urq>AT|gPAvVy=U_OKE8C-TSj~)kzXg|V` zpij_`Ft{2Bj-okhP9k7cg9Y9eM}`2E7xXpZ*>D?muC90cW%-Vrr^W=?*)+c#5`Q|_h6*}-2|GmD7&r{PC@1LilhzLSON5|bdR?0JExk4TWaNBMzf$k#07yd4lbe?zVc| z!gvxLjTVFuDF#@~VL9_bNM->Y{OTv(9xCZ@<{q@qcZ)=Z7j3#U88667|goh5m;+{{;XPl0c03 zvmeFpe;$7GYkhnG7xD%77Fry?sE(4eXi9m=!*S?`oQIrJN@9KJfQ%TJZ3q%k@M$Ad zYYGh4*%I-{-PVm3&F%shkN|RqwxL;vkL%55dqh^%Tu@gDoe2;?R9!7&YTBr#WwPmR zwCid#?CJKK$jymWe2@yfyy^bvr>mC~Yn|(f-7K2%Pw@G5k9Yknz8?jg_6_Ko2A!kWhbCz5Li?l$78XBOdZvaGr z0g*5MSg}zVbsRwzfFRD>Za$x1tmYT9Zcd>^2wCzl9{T<;4u^3#jj12TQ)O=U5v{N5j{b4mhHTr?_SM>z|6{On}vP&tIepoUR{x}iL?B?tX3MK?b=fXfO*i8>4;)lg(m0IA;j~YuW9pB2=!fx`hCZc1(vb5gnhUT( zH(*rte8@Xo3TXyV8wty71$*r*!Y~hqSno@vL`r`Ocz&Y*2D`(?R*E&Jc>5X#B;wd& zXgGEl8;UK*HpG@=!!crLD8?d4ZMu*dL~>4Q*~2_aPO4e5h=__woQoR#G0RMhrr!=; zc}^5m5g5sek1!|)3N3eCY+CN-?QGu8W}$0Mh)it$OIDE#0ht4L6uJ^f{B2GoF=vMK>bSj_2W9#$^H@~|3^>20$kAt?% zGaLqxgJQ2^PN`4*A)oe|2FMAB$aHNG0ZU$0)XN-va;gGkC`lY!R%&gCYCkd!+eopc zFynUF%&x-hl4mRKW{fQiX*k}WwjamC$J60K`vYh;3Z1Aijp4#oXHZZpg4HQ>n!-X= zb6+Z1H3J|~#1J{oC|rc@I?k^`x1`ucZW$Rh=ixA(9*5&oe|$V0pYpJSbO6ZiafnUn z7Ma01_w;PzIZShuflhOt?N(#T+)fvo-9silJ99*r=WxalDIi9$kH&yV>UCAM%DQZ3 zovc9#P)ZdlsOGHJoC2zvU3^56>Y$oLMuoAEQ&5gt#zmu|E$k!!q{={n<2*D=ZZ5D{ zP%}rKA)uyFhePf+dD!HBE$IM|07GamaQ?hqecrSes+v!Sba+1=-t@;U$JzYqv)RQ< z3X9>eKde7!&Q~{|w~LGZv_0(J9}e$xIv@)&)@yTPS14qAuxkl%8K^S?v`{6kIlS z8Wn$fcF*dfM1+!tFKNKC(dvnw{XO0D0*rs__+>rAw0->9ArYAVl(m`zTaUYG5&9c_ zL?s|Xm4QV>CR5SMS#(5%Zq{C0Uc9)udUbR0Y&pMTnx|2=+rz`d_P99&mhXLU@yp-8 z{i8p+`r`Y`*oN)l_~zr&Z{FO0^Je@0?zrC#X%q}#qMulim&;$*fD90+sPw2WCWL5=s_}wXcKQP`05dc#%xAFZVBW!^!7f0I( zl0QGuESg07gr}@y1```p$I_^lj>?)wJ)LB?PwTDT-{+4H`R*}oH{*WW_opODNR?2S zgb|3pC1qtyV@|ow=CtdxzKvPi-dxUK+$=6<0a1^G zZ1!-!k@a@$Q??ccyGcH*a3U$3LQyUL6R`53wn*K#P$6on87+$oK&gWBf^NunC=}97 zp+cHslmMX&?3$jE+H)+u`z4qxF@e3YhyvD$NIXp~DkP+U3ZiKlfX0a3jJgglR&;aA z*Eiu}MavbfF6ic zY$7s&$t>7Do3*g)kg0b1m(^Qrq4Rj3O#G_I#W@cV1Oz&(AY=x#yNzHIo^0k}R?W$0 zshGZol*FP#OcR$iLIfry4*MNZ$Qjh8+uCrJ0u@z2(Hd$%&&IPGYoU6!2Me5aA1f#T zL)NC@FQ4&GzY1S}qmK_tiju7x#?(6Um}DGMpN7NO4>lA6l2>IAk{5Zp@V{*x8@|oN zF~k@+atLgyoEDrFX9;zq6z^nfThMk{+wiP|$EWRfcSKSb^MyhI6p#g8#%OC+r{(KC zAiiSDQZ2zPV6k1+2Z0QP6i^HCaohdTPZu}W`r-@z%l~!!@BZmiyc~~`eVT@K#DChk z0}4ti0CQ|#ES7@uoAtYs4BiK+Kuiopnh8ieK9a0C<(#Zr$TS4dl~8?Ksrp1DKtE(>mf$Nlj)3chBrsTy;7$D4Mv&(LFv0Prwy9I|<2_=pFaI}dkdF;pWl=3j9 zULb?|iZ&|53u+M0c8qVLvl?9lsuPgryI~fV)Wc z04Yn(CcFZn<96Q8F1p!8w^+7|RX1NW-JF9BL6iX0Kr6qLQXYpqoW_1X497eUc^o7q zF{x2WDUX&0EQrj7nazwrk;5=mB0wmTuV=|6k20p4P~z;$S9{c9meiD_!5J2+z|XL1 zDf}6dxT6L z#9P(?h$C`Cp`m8h&gad1(agKhwj7uP1KQMP5xYPJY>*>_NZe3653`QD4!RcGNNs~n zEFuyrL8S>YZ{=o2i?$g8-S=?!-T3iWUw`+j?|%Ki{_4Ab^=I2}|NXGJgW+h(pwzY8 zb{Hcvs|Z+^lcjK_f(s~#6;&0K1e$ZsC|N;(n2hd;EqBYuKR=6eLh$q%AcA;iKyALQ`uCtztVIb@Z0#$XGTW4_ z)e=LP6B{9qIgeR}oCnn;IcZKHNu?JVQa+008hitCNzhxJSt!6FO94nN!HO4?r7OSf3=Ioy?Ui{#zAOFL* zZ{JAHOgm zIpA-d*r#99J%YNt>mBe_H7&-4Q*k6^fFf>Pg{8GnJh!M4-dGfI^_F2~KQrg-I zS5l5tKE!QR`JGbnf!^t22LdvWRI}^`e5fegTdO*G*)$?Bdvh}YQnV@%#DE-pBt1n8 z5n_YwOqVS#I#>qmA~bAlASQt!>nRl}kPInf63E#G*;~|AuK=KoIRF63buog1Pc((% z9YcUfzyX;xkTwCE08PXuAO$N4cWr|q!!W`r!6=Y2SRxw8i^T1N#$Jv?-tT3*)5mqb zze|r#d9zKsZGYV7G-R{vk%SnC6zV_?7wIqkJE~CT8o~5mu>#JDIKOM=!^n;-*}k0k zFi=B8M6;&7zL>qZncvJCBs~tYJ?i5Yp0?w0$R=IkB@bHtiSM0h>y?dK3AQtC5?XG;-$hMjtD z!HQ>@HAZY&TFmj{D%@Pt^<`LI@Z|+wUEy*CZ3i(Z0;HrVqX_G))XTcg2FIRuF z+D8`;Cj^zsf*LoG?Z0eO7JZh!{S#YbHll|K`Z1ly$>5~ zYb-%&e$l%1?4p$@BLv2q8UDd9!f(Ejcki+ZW+us}ArCqAWA6JgrBNV>HADqqs{6YH zW_p$>kxybiBV7ZtDOCp!f!I|PK~zhXUUgW0^D&FIk-HAo>+NoTa(3N7M@hL)1qh&J zPuMkb^=Y;f*qaoK4X*YbjVnbrqSz2H0Z4|Ix7{E9bbfoQFTTfr`KRN5_fNO+VmwOL zEE%$+7gUtV(E_N6@m|N-^TqNo_P=^~H}Ysv1e39R#fpfGIj1~iNuuIYZt$!q2I^MU z;$d`xC?&pzVhIow@#2rc(WJdr_#gm4&v1FWl@cIPvuwN7Vt(1p7Hzj63`7i~Lpq&K zhcq5je@gk3#v$pDQZB(i0RXYtk}~0<;0|+W=T@s!`W2H_wjAPFJ#5K4LAmzwdi|HT zkh)ED(u=fY68wlrssJF6lcb~~;t~l66ghOU>0-NR=gW4sXlIM2n^A0ugCbKQCh|BA z!(libQ|eW+WXUN@8k6)gjw&fviA`{LdRC(A#?nW_SZOIt1+j&-LquzGfis@S^;Fd_ z{G6eyw6VhoC~gQ9ZJ31xnxM7;5P+1JLZHyZ*l^Ru*mB#1*l>u%41|QNfQSrU|Lo0G zwoVj$a*Zp=m?n*>1lo91j2HqUV~iA=*mkk&V%u;Vo3^2*p}@d|0c{Em2B1j98X{7I zF>>2-6Vb-4bphH&11YI!5>=&uZHJe$xav3q9JcWB>-F2e`}*DA{pQWDfAit*e)IVH z_2&LAZ8nfjz{+e}nUz_AefO9(wH6XHIslb3;&vXE%Q#zcoKt8pcBCO4cBlPae|Q*o z4|3YTH~=UxBS#DoDcV}K@!B=;b9hdQ0$%Pa0#?moBO4JB8;Wy^Gj11Qw&K}Ej7w@d zjxB1`@i?CD`olxNzfXs!-0wg}Kuehta403zFj>PQ8C`t6Ee`V69HUqznla!vWjNNk}~8hVnRHFNG~aA9wm<=gBtpgQB`e{u8IwA zH&0Ou70%2TcrFi+2)#-LiI4(w1Pq9gL&LG<&{*pb5EDiKHbE0f!Pl(rG+w*2Hmj{el}h9qtdCcYVLkI#>{-jt^?!R#fil z4e;jypVW9%I{l33G!r|;%TOTS6l0#nNhbQ&c$*H_Pf@Z}GF_=E4?-rmmQTu16cH(xH6mvKHL0p6U3xA*%uA2;`# z!|pir2_$EPL?|Fxau!u9i*s#bRfSx4n!f;AY7`w>xPcL=PsOu2HTtc#J9{H!rU)Z&&lnK;1Bo z>!*JEbUd9-UCdv6KL7deUj6PbE`Iofd2IQ9zkT!m>Ghl4*B|yD9){g!7{-hOri4$P zAW{JJ^HTIFjN*&1mRr zp>wYGUgD`Xgni;E03us^fG54G-Um>yt`I;%wt5iuNx9a%UMLc>1{rTEw*iR(h=Cdi z0lNlf5tk+f5_J|JASgo0FlOyD_8IyN#(?q5@*flL8valm+@EeV;cwd0NZklRmA}=4sq*hy8x+ zdr4!qHxX$h8-#3%k}6qKPR3Rkg{#x9D6ys+AIF|09xl;}gH3yfDLZCWHCcWqJ_JD3 z=O8k5O>@1Py||iPFI#3f4taON^-iC5X@8QGE17~;tf_8lvGAu}0#M+t^;lKi0xc_u z0n`9^F};%(k)d&iUT%BcWY~%WLLaCCpf30^`ADjezCbI$X|b0ABbTEw0A`P#5D}Fb zW5Bkd`J65;`TCl#uEWi3xW2;6OIXYxh7uJi0D@$Y1SxCIF!r$D!R86p5BhYk+b7-c zbT}$xB!s}o43>-rE5?B`ecq*pfY>eo%Lpi#H!yE81TAg|z)iU`R(|KT&K>fbXc7TB zsl6fq*oY%}s)ooMZp z@zxm4QmBvYhN_mZGb058L?Ia|9s0vEc1ywE|3&+6|8Ds8*T^tHrk-h<)rIA@ zhodMJ;()Upo<4AzyC#VW^tKlWktxK$F*Jb#QwYR?2o<%I>Wfh)NI7ZSV%ySov)%8H z=(B)af(5(*q2NRJl&Y4RwhSho*Bf%F7l5LoYmuh{5~-tY#z??eh>t(`Nq6&HKl>s7 z#s6pgzyG)Ua5?lMntgPoVm1b!fXG_65GimQ;_ZBP-8N6h<9D0&35O&j07#aUQ<5RO zJdlcw!2w@(P)J=fOJ3wQNr_Gv*AYb_;T*t%B3}Ljs5Qn~L!^S15z4j(L{!NtqB(&O zC;%~V+jSRRdof=uo2F~pIZ;5BF%5lx9QwmJ98(&yq?CGrQF8XJV;OtjVLZ`L_0!9T z*UePOnhD}-ebrE7IYt2=Xc-mPa+%PPak}Kwju63SvH&u3VIZw!PgHz`tCcs~sfUQ% zFt@Rr#o4m$R_$!jw4IIDr4Wb$D5+Y*ZlA{?r#_8?q#=zdr_mb4)f*kN>gdBq&=;b* zeNdQGW(bMYM^{W)6<^5y%(3jeGxSw|v#kC}u@$U!T+!yCmK~)zm+k07XwjlgA~M>3V4EkYm)hzu0LdhnZoF<=|4T>q43GEv-QT_a z`#=Bp-~P>Czx{WA_wf3=-P6bYb}#z@bOhT&HvKnRr*F%li?PMnK@1ez(6rn{m7Gq; zblmm(r+&YY(+-(gDEag19?eUcFizbfrG-k|vAE{}1V!Qi zcCWdi5SeEf8|F4PGY&0>24lmaB?cf6Qxrmayq4PC;6z-qYA2MP9mdq2vWS{`3-ef++0z!h+)Qg9FC9q@R<5{ z>GY_|G=Dz3d^uaJRAtybjr;qtKXP2ouU@s?qCXvvyERd`yt(BN5BrDR_T4aU0Y>vX zYFNTIiBwny0Hyn+@*vX>ragXc>-;T~P5s2MmI$z_7i!@r(1TK{yjiitRLPEtT09ui z=|k+077x`h6CtaN)>{W+Pb$VZYZjO7;#qg`qPzU8yLq*I@$zB}As@RhSLK zdOEQKpU@v}(VUohp=AM-zDTq#RKvSTHO8=FH_rus`ij<4C5kN50#{(y!>Qa^f@2mcg`ArfbK->eOZ# zD9rpPtJhZHCkZY%J5*g}SkO)hLdqM;asbCXWq&x5kQzFb^MUF6l9qu9IOs$;K`Gdj zVymXIh+G7&AUP(BL}>i1Zlx`kL4T3Q)`YXsVOR-YLpIc4cH05@@Yaq;r@ z^5x6tFRw42w{ejL54-;H;jmusa>_S1-7kK&{G&g<{K=0Nt7Wqnc>l0@{pRW0_uDt` zcaKk}(@6ll018o+?8({#G7w^d=%3&&f3%7~Kt97xy;6mo;s8)8&AH$y_mYmO@gYK; z&oUhvi)Zjp(khVZpIlo4oLqv^w2DM^oQ^kPlZ$;28U=2|DS`7Eb`HUpHv*dUDVXlO zX@xL`fEsq-TllBf+>=`*GbSyV|f00;__5{VavY8rKa$eW#PHuAWZ`^Wz2Y1nM~(_YdD z1Q1XIqjl;V$L39YAey~=Ttz`qR7#bXdW2E`C|j}s8#7SGawt?4c&*eTSk=4NE{X9T z*$9PMneNN;nT$utH(1>qbjm zla{efWm`KfskV$Q5UzDxbpaP9u6z!aW$KI1U$zDAwH;L5tJ)Am8_cZC6eG1QEEaHa z5w33d>M~qihO2A5xyHpDVgrb#(4mrw^%KaW_5 z+FFN=3s6OGZ36@?`l;aR#Q^L?jb8*4bP?w*1S&zJUvy>Rt6@Ad23ip>db7Cm;!vDL zan%TCO&p*CNkEFUhopiL%G$2Phe5&mib5ItQb)j)))-aH*(bKB&y6S=BFQ!I39KuQ zkq9FyW}%$0A7DI=$4%Nj?LK_`c>gCq``>)tUjOu~?qC1;{@b?)B19A@Q*?c0W+zw* zjxueFvM&k|DCM6}5DnBN=ENct9DoGGY?blM#4$910-Lo1Qox+GZMbV-yWQ=NCnP8n zqudv})vW(8WbLaME6?vrYHBG{(+>f%a%iv^lo$vV896|3;zQu&P5XO4X|JE_7eC>@ z{8#y({x^qkG4ztaZiGz`XJzI{fvAX~Ab`SU)7~!HV;b*{hht9tcpB4i==&iLDJO+& z(7(?!)`%YUHWBT8<&|i z7pcO~#-^Kh%h_zz%`ReWkXV33^4On-bR7F*9>+AEM2!l`YTYqzsleMlo%p8-jZ9rE zPR~?q;n7mHP5yGtWy@) zqt@6p%`C<)cC*;cVmFU%OQAt-0LW0eXoh4iAW0r_N;!|3k_~5(tWGnjRZ?UTp=r!R z0h5z4vW0BQe|Xj~?^TZVUfG|Ya7%tCov|zyn^2LMz@|vryGjBMRTPMrK@r(1$|G@u zktxIwyAWG$S`LvygMpBOVqioCQm31-;C(B}5%ZR+sG^7n%pr!*v|--1O&jM;GYicu zgjwWSz@RbOR4t4RMB80anX!pV1dPA{L6k%S-YPo{izq=$n#m#wV_+Iao#Y9^uw_4_uFA}KWrZKw1YH)3NRzH z$rS;Jvsd)1b@dMP*!tH>O+lEF&eMSZa!ayM~cN7-fEV)~7Gv_!XZkVn1 z1~reGj=A5C{buO*c{rxwFpeipN5~^Uoo;KmmaGuNMi4^5u;4^AmRP*T*tK_7JaYtP zV7*^JfjI?0fV*F+V3mnkYUwHfV5HcFcG)g2 z+vW4+v+u2*eRcWb`-_{;y2VY~&6EjP@^Sm8|MmZQe0swS;Ij{<1gQvTi^q6k_vrOC z1MdJuir<1D@}gy8=13Sh#*|s|EYfPVyt=u%{p`i_m(Q;jvrdwtAcxSc;%XV1$VuU0 zd;0cn^Y(tb+3t_0Vaz>Z^4YW^1KgsjAYpq)A}vHiBQ$ps+rca z$E0NgV12~pq1HrW!QzHp7!i?K2~!e8h=g4eW^LQXwndh{AC9L0uxRLN5f-yXRn~{6 z*LQcX?>|27*1N+lNwQ8NDozH_HTMOalym%m1*Oso zhD^}BaIvk9U+OQH6|ePvPO%MwP8L#q4j~kBuDquL^76)tN2k>dV8Y4pAE;6mwg=0; zERm9-4wVvi;rUNpd2X^|^{IL^r5@44SzJY`C?b+nQV2AgcUQO9FK%vbpWQxN&Mpb! z@hA@;ho}2pKb#h`@S`8hfA{y6Kl|DIi&qV`^te5~yW4#8Zu{N)`s*kxvFJJ{vHP(2_J!Jv#-XjA} z>)DgHJ3{R1-u2KBn8;F%lQw-35@fcZ+meuA<^60Y&NkZ43daj+LcozifDlXt8A1g6 zXWPJnVb(&+&=3StLP3BGreJKTY?tl>f`GsXq0H(q_lRQA6O00(R; zqpUFWayZH6pifWw^pqbT(|(sWn|^;x!(f>VAQaOfw7c&edRc4$O|k!_fI{jp233>8 zuQW%Q&8uhsgR&*itIO+`p+)1(>-3^N5V!XvIX)S-Y)nzxcbcdJMCyU{ul+S#pAATY} zCJqj<{xTtzHZ}lKC2!aTKvZN5giV9987`M}bBWj2&Gil6-0<}!UaW9FgBTRhQ?WsZ{==V2hgogk`eR`p=X zkbpp?13f9?_2r<|%K0sxFueE{0<}3NCchpzMv9D$0(CLA4Q#jDnu_u2V4Mi3|iZ)y2`KSOE}V z;AR$^dDpGFcG1pe%nfmXtSOJMYA;(g4?CS4|<(ni6-lpmMb$) zSyxLXK76N_DiiDLnV27hdWQhCND)GHFJ5=5zHKXp~GNlKxK)HZS6#Fm={g^uHlV~b57 zj=(K4DFXz8z{&stfe<4B6EFdRGD5>L4|3h%O~`JQWt4G{G(b8*I;or__c|T{ z1TaEeg~f}wxQ?NPJf`D%?4RfvYB5JM(O+1pVBzQrfrv(h;%&eAf+UA>|8c9ma2@Xtx}Y-X{Wh1q4ic)qy$-s1Tei)Y_oT)vpCuG@AgD&y(Y zpN`TGIc3QSKqVc&{a627cW)4qN(M+%+=YbaMWTLf_{kOeKS~Br#XXe*qRXQaF%mP) z+V(Ow7lgAk;3&k}TwmRM`T6bV&o379mN2Lw62>kpmfX&GOnSFDzPVe!yI-#lyME{; z1A-8WLdp8n+l;2wW~29fXHLYZl^s-^qh~EJzx9AVaeH+^;MwcvH^3gL2m5x%%42j* zC}c*242pnbPKeZncG-4|S!6&RMjZ!8X%^^W78Z-BDo@*|H}@akJv=;Y9`>jGkb9qQ zXNPz8qn_W{5}g*^*>fuhwfOE@m{hruy1AXoamVe?))2ebT%c22pwlugH^Q8^dMu4l zjfw-xnjWg>5v3*sgM8k6K1EQte=jX$K!T?K$d6M{NtZ2 ze*W|2&wnz%zHSaVzq{Xl`)>X1`~8Q9{%}YsAu5>Q1t8QSZ8yYq#uPetMPAQ=R+_@- z?1fr22oZoPDEU$}`a+BK@aZiIMbE!*miE#_jg^P!LXZFx{ZX?&=(rTisv-bTO`aklM`BBN1|$Y%prFKHeK8Gd7h%!pyn$uZ)~4!dfv{&3 zkxgdtt5r<`Hc<8?GK`Q#Gx)l&s(S@6xyruR?LnV5`Ti-dH+i#3PwRfSm$6qBAWoyy1vB>%NEG5jK0-9#gVjSkL5p61PmfE3Nua0tEB% zQ>YHamcgb96-t+snXZB_%1)dH5EUY0T5|Pq{?;#AG?r08vw$&d+(Oe}*U7~Sudevn zO?>`5KEL6cYrI@y*Fhi^fHcCGASKWY!w4zCFzE3J+YPLrWc{dz4IcK=A5=0jK?uYM zAz0%MKsL1JZKDRr&9L|;qbc+=fNsGMx;yk>h$``ch@fr6MGKKV*HFyiC)Q9W>C9?Y z3!gSsE>vIvfZ$_dij`CaaXnyE_2JNJZ79}7XpB{G~9{2a_{rb>v`*A=GZi|z^&xwzxqf2+4p9z ze&+{^KmE6dckd76RG%+v)9N*CMJdFes#-AXH^aSS*=fFltIL$hq4>U(;u9)tRw%I* z0uV-~hM<|Zvo_G-up3iiJ{RezgEpXuI{<)n02c27Q#l@@Hx$@|q4LO0J}nRhz%Fph zF;F07ODA1l&=)W0`ZM_6&*@ixuK&|N%{13hR27n!k>w0&%qeBp)C9{+BCg`>W;Wjq zhxePu1ga>E#DvUDNTQM@iRhSemYj030UQ~#Xi^k~EL7_{CO53b%%))> zJ8X#<4He41v2o5s7!avWzOw80K_4POND$N;cA_E@2VkZUDZ~(33K3%D{~I(|y0&%V0GQ0whSv z!8()C$y>=L0qdMDFhXE%C`OJgH_I?z@nS{oJn$?sL+*#e`nb6p_ILg9N%{k%0T6*3 z&#IrwwG&Lp5DAgN=-ch-|v2hE;3fq@NF-Wo6wyXQML|ydJR>|Ch1?BomiG z&tj2xn<5)z1#h!PFvVP=qR3OeJc}Tc!!4M|a##wr21kq9ya0%=CD1an!+x4UN6i8# z>P@1A7%9xDS%uj(&Q=f?h^;DvC`y(*U^>CL*M6gUpYu@wslDa-Z8u*rhw*e6_78d3 zE8=W<(akSKV;Y9sA96~~d@)~KN|xjCh={ZKvRllL$EWqfn_+lT%|HT#mLV<|Rnq2e zgwNtg^gB{xlVaFu%5k5^ptXhG9Q9<}vHAma5#=gnYVH5%kJ~EXr=&#yjxn~)Y~C)e z=9e!P*RNK$pDnImEv}z6%iDH#i4@Y9PW^B=4yWU3=yM)M%!B4cDpVgN>DKt@2KCh#o8i@=K*=ONArL(bSA zGb68-^Uq&B`@#2~Uti87V;U4dyDrQxxSNL|!~N#)=6?P0@vz<>PQx&!Q8WQKC^M_2 zKFAqR#QKWM1FWh~CE~V?Qv!z)_dFy?$bp^Poz#XQVgQq}E#{r3Lu_5FwSZoNBh6C~B_lg+Rgr^@*j z_C?Pw7E1htQ{$Evuj8-7L(F;RqeSQE9lt63e)3Dp%6OxfCYHFj&G>LS(k2B_SgL)HyF?gI~Nb z1OoXCcN>MP3gWp%k0nC{Hm!|-93Td0BeW6b4a^(pTId>#43X_W?%H$T&8X|KRP|aQ^ zP|%ka^IsjLmWfhWXRvt0X3=qBxNy=mcE^fkbCi~YPeR~Alavksa5)KAH&az_b#bM9 z1$CWjBx;**G4HNd?d80Q0h7paz;sZ(y?AynhiV+ON7OFz9bU+b7?Zt9B z(NL2w*Qqw-Y+p``3T)LvQNy)m=HayIS$l>ZQ3)^*#s=CBFE9D_S$zIH+}`l*HQ!v~ zY7UDQV$hUeNHB~b0y)8uAdPxD!EUFUHEbSr{fO&LKJFonmI$X1Ffat~PzMDc%~wO^ z>LyCqTx^7z^*8@gy}P1;`=T$52!ut015p7OwFxk9F-C9^-C~vTyduG^fUhF|glg#x zMHOSI3X^mp0D=2So7W>5MA2$Da@H&;Y6FByT|^m4tD+#7ebTKKTjvBnh!tcZA_{8q z?&V=~IdoQPrH6NWEhYjP~aGeBXdM*8GHUX2F@jCBwQ{SM`L2+w+H9{#gMH zfR*e(3SFGdns7Yr`?0r{m|(G_6~#$P`GC2_l6y zn6Mub5Gtb}0xIBY4xc^8>o4H@zrer!GyNa`+e`~RrIfRbl0{O644B1wt{lD@n+1!| zUUuCvk9UWs(e5CMWHFz?Y!xon)B_@#G-olrz&=SDbp*;;#W!zp-4zg*wT4<ov1@0`wq3T( zEW|)W7ClPNISs>bOsSVNq?AOGXckGTDNpH5`*^CLsQj;-S|wxZ4#n~Z)m`akLhFsN zpOw#Wg9kPYHs z8S$YQ0UM5OY&vc_j4ihOoqDd4Q`1pUt=- z905)zc>iJe`rG?&e)ZkEUw`xd>u*1P^WD??cZco$aM%rpKJ|mO)e!(N0dow@T}#cJ zyUWmCQnTQ;jj2P#SdG^6WNWnW>70gm2 z6y15SWr-Fsq6gx}hl$nafW6CYblFyDqsmR7r6Fo5MC3>m14keR3PeO4Ol`+-F&P5_ zU}Z-|*$u>Yy+P(WWO8yg3LvPZ6~rgtO|TC@t#J$_sWr|@Qpu2qtOLlPl8U&jDr8g> zrpv~>A~zJ56j#)&uvtLpICPAtdDNlL!$#94_gfizpq9IzSO&W%Aza5Wj5$RU9 z^NVMibUdBXm_%hhU$yi3IOgN=&@^$mxDe9q_QPTOe#}Sf35JR_Dg-b$S#w&xr!fAQJpFD~XC6LB81g2tBT7u?Ng6n@waZ|}G7@7L@7t{=uMf;MYS zvzE-O)d2c(&4mXvyFUTRLLkt5B+$)%_u=XO?Ze&O(}&}*@BIO3U_YHw znS)RyTF>^Wdl4}nOx7As6T@6%$pBNdne4sFm!3w}agXszXT zmsXco;rI$kVxK)3V<@pk=@~vl3t0A8G2G@d3tLl4JEvc8Dw$L$aiDSpD4HLZ^Mv{#Oo%=!!10KEW3%i{36%%~ zp(`XK0J*%lb(z})*|wF?U=wlP;iALU99J`1v^Z;s5mMG(YXNC zESfBT12`4XDGW)ECpnySy@#i*uD7z;r`LW zKP#jh{q`0u>e0^N0iaVJK0aNoY)}D^n3N-NfZ1FxFX`EBcy=3auK3w)xV^%QB{TsD zG%KXhN6bowF~KaVyo|VpNmWfjp=?wvPaG;^U5sRnMa#~$ zP~JKgb6I<0gKWK^BbNcppq{q^aEipFI-rb;C}Dsb5n@!-F^|X8Z-&#|X8U3Nc(=ZP z*sULTPrKu8Ob5V3WEJ*-C=vw*0%WIZoD?De6Vb;ImexiBG=W4yCJ4k$uu?N7#(+rV^I6GGJR@Y1 zgaDY6-mK`WABE>Xgzx=={^I|Y|Jnbgr&Ss>d#2hzI*BDe)gKrr0VB-0xoEpX9`5#= zR0cO0pH$M6E)hjY1Z<@N0IH}-(@F6wqrFa z(>BNY$^@$Iz2}I;R;NpcU_s;OVTbSi^P2*7ZC4aq5kTQ#0ScV+Xby{@L&4DC*(IHx zpur4AVn9YFVkQh2B5@$LKLavjh!g^GL%}2u5RojHMaNP`$JoqQF?U)bT&pX|HR`Nj zR9!IHIL7?44OVo)E>8!<+jwLEL7YiJfBMrarU0W-uv%s>pJiU172C;=oY1j&(f*1)0* zt2QY{iCbUh%I1c;$ z=IOA091oj(+Q@i9-&~r~M*vKDSjK zGM0Cg;L(8(dR`f=vb27V5kMwlghWkfXR%wf%ggTKW`6bj;`Z~)m*2m7{>AmP&lZbS zh$IP4r*u4whkbuKrGCh17>998W3pLSDkI9MGDse^l=Z1WS*ubtO^}>WN6@TkkaQYP z+jQ8;aL}=z%F(nGF%b*iclxS8k)g*tKwDmhWZm;%XbQ724C!1K}?claetER<4F zEwN+PWDyz;1FTmPrhqC1j{NQUwJh1o8qc zo=(P#c@Pmv)Y2RZ!Z9qD^V?@PpFO*Ib#rw!?`A5YAM$Q{+&*ni!)ZCgAAPy_z2Cq1 z-QQc>Ja3N~KYl!X^LqXD``yQf(_x>|NRpKZ2nEDPS-5=wYmv>r>ti!mJt%s%0rUHAitfPyPT zh5%hl^Ooiv&gL9jY9h=dL{I?88OE%K1p86IzlWq>MKgj+7yMr9|>2TC>G=T$~y$7mNSbWv2S^8by!{dW)*0S4ae}bG86o?@ZvIV2|Y)b^~*2#KO01+zT$tP001#+*I-~T2JxW8y_8ng<17tsxtKzFgO)Ic`JpeLla`O?6cWs1AhK!6r^+nWYvt2KmjGev+MBFubR(( z24DPw{`_CaKl`r_v`C{3VL?y?B>~7@m{mF=6d@3G6qjvt%;{mb88iz>xsL@5)IW^) zLjP*4m;e8AL6eio@$yHaBMiknJqu!71@ zuvGC&;8>#adtmvCDgOiI%VwsP{c9#MgQsc@d=rGg+{D;5ZP#{-80WEVV~j`v0WF1_ z(wN4698P2E^Jr{{OY13S*ZiIGE9HV*ymsp50AKXw%Af+VdP}OR8vwWr+D*ag(AgFrOTp|*J(qL8> zqpc4F20$dDz(|3JKnVg9F^868j7`gJ6T3EcUAyQk$~J8eWr9#z+C9Ug6d^=xh(f?7 zVvN`Xh@?#fLVzrc5E&vuKrmTjA`J*_(0PQ52$zv&j0}_ko&;WhH-7W$_wQcc@7BAg z``xeq>hHh()vxv+?)#^Yy88gR2ViI-1?E6V>@k}H!00U@ke$u|uR1OJ#(an~A)r7M zxM_I42;C~i1<&T(E-zIbhn7N1+*qnka@I7Y@sx+7j7P~s9(x&k zNj=B_lEG>#&>g1RDXeRr&boKGL^w8i&!ZEV)^p9A7Hc+r17%a=gr1cUZK4_xN2G?i zp)kYPQD`AHL;;X2Mpem@6Xa1OK^ip;kVnk}z@TXW$PVOV3(W=3ZkyRMGJ{Gw9mnHi zI&M=ML%WzSuR}XeDW%hHIQ5~8%ZnSpU>%SuIA1L|#?#@bd7RJYA@bq0-)%ppVIw*s z+F<*-OLPi^3!$ym_D%+)#D_!$#+k)%Ld~>0JkWD;69I@haO`3?ZsQ~ueDTG_&CB`fvYl~YP=)>^$K80`4{4C`luV!*R8!7r7*p!gIF3V~QW9gh@<}Dz zycLx!mf#R$SPb>3<^+;Odr$>11e9d#bAN>KBts9H47gyq57lrO%$__ROjfnnlp5Y$ zxnckT002ouK~xY%4qf0GqOl$kDIi9|P}x}KCWKkjE)e55=A4z4R*U6lFK)hgd2w~s zMCS37#xaLB%vPbBQC8X<@`ugg?fv%tad$YJ#+$I(yvz!56|b-~OD4udW+Q+Q)Pd2mmoKqB9@@-m?P$WRlY%Fn|sjhd%8O z5p>b;vgH`*G#(ze+qVyQZ|>e5`t6X8mg=b!H)`<;o^RQ*TkVn6q)TN4&M)VDq5B~U zjR3`&LSaN;I#PMFo*&G7W`QaCTOU&Hcp+T;hNF!Xw2jC9E0tI}dj?nxE#TD6;eluQ z^^%~Se0(K70840rXm7-75D~!Y^vg9XSalZJw}vPb0KkY{P#dsfnZgW`RFqibyuG@( zc=qhs7td~PuP!f}IM0Hcr{nf%v)%4P7+<_hCZ!0!;C(>6JpM z*Ds-(uWl@wRF>NeNQfZ-GsFl@15JQghqDeBEq5JvEj2C10Ff0%CFv={Y0$%f#}Q7w zjv2CQ1{A2Dq?$n$*9ZCR8h+<c)4f*)Icl07j1}v z6tqup9MFi5l+|KjuW0{tt16UU%~H%yUjAe7)(NoMiSK_{KGb#+sDa@^kRpm&!9|ET zTj0e7U0u-CRlK~6&u;nEGrYWjSp!6%qDf#JA!W!JG(#G-?_s}#?H-=ix>?J9tA{;| zCy=B_CX0{EfLJtE%3yTwtwLu}otaUy9!l!di@eIrERLw!B+~L#>e$_OmTo|$Qj3qJ z2+mfnOEm$I&HY6HgczV}AlLvPgt9?lvZpngDwXNAMMD9ojwAEq+1hcSC)omPPzCdc z04M@w+7GDJ3b+M8i<<2zCCOt>r+(a>(#Or=4OYQ&z{L>E0(-@4%VwXl`tu6^QvKo!p!CPV#mu1Al!OXCgqTsu+j^3M5{WHbxVZ{H`$_Y~ zFW~c^(Vzcw`4|7>NQ*Q`R!}9#HY2P^&|*z|BM@OnanZGX8Xx!DQ%*KwxN@kfg>EzY z&MF}lZ9JekHEZNyGEGTP(j?=M$3e$24arndEk_G(!$>01}}Wf4=XRK@vCtk}khuabjxONS)@btnVpu&?~I{62uy^b`wL+I&1aT-|_wFxCk# z#ZjX#CcmpC6=0^TAk=zBQ?3|@$s1d!j5Q%sZv|qaKt`7^6Gt2S;*GQRJrWXE+Qo1P znB?C1gLhF@ZyB|MOZB=&KXW?=uZ9Xt6e&iwGLSZgrlH8(a_B;68CwnwQ)Fro*`-uC z05b%JNEi{?puwjb+YQ6EfhIx#Xjt0_5fs(((wRUzfK>!8{6k-T7 zY$7xrxAS(kirs=D=hVx7ecV2dhwX6M$?*Wg0H_cUDFQMummH^=9VZhD<}7#xt0pcX zfLN*mN&%@_BTPVQkmuCRdA8zqMWKZlfdeUO9%R_-aLU6m_Xp{ZIh}OeLmENzH1yHd z3^D{Gxm+Kogg8{0YC7Z@#Tms8V=L4kkeB{SRvj@XN&}>mq=V)JfDjklT!h8-Y;j4{O6tes zV}IBxhS}<-o3B7%9QMcUhC{o!ya1)XA4GD9vFm0CblM+?aK5@AqQic@+kQyHCSgL9 zDhW{CAQaNb|6QblYnWt677b&S>PuAtApio?U2a?Mmfh;ATV2nuZkE@dT|NJ7_3Y*9 z=H+aD)h!xgP=ImB!;ptl9tIf)kx?XP6j4bkqDk_Yb53bYX&iGJ(%9Rdn#Y_+$w_rA zB1D!=@B(dw3{or0GD5aiX35EvYrvqMhz_ST9%UF%5~vh<+7Yp$U}>{Jd%0u)vBqL% z%{dY^fo8;wkL99>$Or+LQ3E0(MS=hv1H~BI*tUpKl*a*4aXD+B-(LOj%gdKHoo4M% zIg4_L-HO{e0`u-5@9(y+?{;^O`~C4S<|LXBMuekT z+JjLX&)LdAtO}9tR?B88CZOcwt;nCfA%IDhOv+kSZNxGWAhB3fB?1631_ojx9dkO3 zlzQlgRYR+$nKj&}-@g0 z?eYzj;R1fWW#vODxTs!KOYXY3Ypi_51TCubcS6B#-WeUECAAFYHzzFgDYaDoWd|qp zA+=DXSOHLVYQrh*XJ`8Be&(qD{2ZtF$N5M2H;kph4Y}MEgeqTGO(7xz6WeG4%~>=f zLS&d-tX|&Ue*Ws!v+L{2Wj90R!*P6k+^@IC)ThfO|LiBTKl+EOAOE14FKB%n-aQ_^ zdw=|J-#@L>;Rtz9oAyIQ;01$ar9nkWSfv+{e4Gn;hfIm(CYq`Aan@&ASP?M7go>CS z0e#ZzQs|=+tr;!Cj4@IS(9Li;r`4QR3tG-;Ip=N$AwopJtSRY!(CrD<2i@)UaLnVF zlL9CKatPE!iVYI0W;vd8d(!BQzDGT>dcepBCD(6;840P4yzIhi z-YmKp2o-fqcpP+h(p{g&Q5CU-#7>1#^>^UGdg)xjX;Bd>lz~?Dr-Z1abrXWI42T3A zAOu*<>1s(=SG>BQo15_2%kcab7Bgsq6B9Xu2&4?EFpfHma6H0p2kR$&dW6lB?Du*) zK+a&%Qb44D%)nG=B#S-7YtXCZaTN@;!X-5(&j5eL$5xh2E;8bBp{2IYZwmQ<8OwK+ zfX)n21qcWTI?X~V!y6EgSpz{6AP^FrrAtcJfZ1i7)=z&Vi7{GqW`(t|A40oH|`=_V(_nW)N^}}|vIqrsZ${@@fI5aU5F|#$) zlV7xy6tpdOpQdUR3E+Flm$;m}Si_wC*#8%Q@{hmhZh!vO;!pqL_~!kVfJ8vW`V6wA zc(deO6g*0Mi(t`&S39Jd_Enik5lF^D=%7Zkos4_)?)87QZI#NsRtPB0H>(k!t zsH>Aii^O6u)}x6WnqZ23P_zk{T<_nxEo+i7Xbh;Tf>H$f)bAMzla*Bo++6bS{7(Dj z@55)mL;v=l<$w9#^fVh!MQ%_D1T&B*Y4J^f=w0k{4vV(!$MksI9rK_?F~#QOl{Hcu z0Xbf`%cK@`2@N6n8CQ4DM_HH*zj9mbqS8BGOK6;K#?^~YlRCeyOia$g;3VJX^F z4>zaZai{B~hQHg%_Uh}->?c&GUNEUO%h@W|!T^`I*Y698tMY8_H~mIUt@ zv5n0<#CeD@#wIjfV0MFTqlP5s+>gU)90y5*&N|8sULC+WV*}DMG?#Vf>0?(mnNnCj=4HQQ3lY6LVhb_zpx`lF7 zRjc@saZewg?=t9$RY%w+%A~g-g zfW#0Zg$PE>qiR5CTWA6>XaI;28q`35s+6rxl!!49%t&X5Hx1sj)FMVe0oViFZ{*$E z?YH0Dee>%#-~Q_BH{ZVb_~zr`?*8<6cj|ZJpqfBIF#r$~2jn((i#S{HY~Hjpj!`uw z8HfHTht074nDL*3yfQV|HzYch$;O5KRQoqUlPRAb7P_h-ShpK?4n~z8p*b5XI*~XXo$yuf& zgA?8WFa`8wBb3Tbs8!Qg3{(LvlhK;C;itaBJc^vXX8OJvr->5P51M3|2jL0;NW=j# zV(hS4U^B-sv#^-~QAHp_7KE&+*L2FmE)NGuqX6;jw!3)N&KHD1hU00s&MAj((amRp z<2a_{ZX05pEtisVPFB?y+HNMA^N<6_ZoUMN-Tr>J{g7lQP^PFB#NtMx#DM@)eKZsu zUllECwINUlvqiJK?yjEAFK_49&z9FOu3vn1`Qp{()$?ZFg%*ea6ox@h$8fNH!Rcg{`ncXfo6! z*+2tWwJTbovL;9*)9CU01`qVZkby`EB9c;;5tM= z08AW;7-}F4M2x`Pv@y)Mna3EjXi6d@&f59+KEL_$i{;ft!-Q$noD>6f3+mD z1o6nwIu3~tttG%i2t-8h3oQdj&BZ7nc;Xh6kOBn)#GGl!a_U1GBgn<9=^7qo*c`SW zpYGm1d^{YU`tfAS{U!>nwI`*L@6*Lpxv+`iC=0-^MW=VvT*J zm=Ux%>~gt|0Se?`Cr!%Ici2^XPspo!fDsW@P?O52M4@Z1o;`c@^7h5cm$#Q!7YyC; z0NbbI!`<#U^vebQ=m+y({&M#7pLQ>wvjqIG?!S9?{QC9b;c?vUB_#v_GFr!S;#6)g zdLx0aIxw9*zh0{7sXi*L92D77w~GWXQZ*h8Eeloqe*`>yVuld0YjC;X<(yYDUMzUE zpo=9fJ7`%|Vaz%Vx*6eN59=N5_IesppCkzaQebW(wSfp#Rfi#;j`?XPPwVuw8P?lj zyB|-zrX)6m0#w^3{2%`Dvw!r5^Z(&5$G`gqS^}huV5O5**amKs4K_Im;SW;EXoyn! z4NzxxxK(g<0lPt>lbb5#9>w~X^ujs00teVUxQi)2&MMp(^j&2I4$tC zu%NMkN$iCoy#zc*QOGe4#GQPJoU^3oEb$&@fbwDE{s2H%zwtMIIH!_#xA zK1$u9qIUz9Xu#?%Y@CCZR@!2ftR5N}lE`7mn|*rPj34d~AMdvB?;r10b>p=wX?2C`IvG~$bl(vV2Fr;kpd%|Dqq<))-OXyF~o)=7~fZdN3;n}E)S%j zf(kzAKmmbJ3584?MHMbr{F9%=AN&En`WgQ9Kg<8`e=^WKpCo5hAy8C7QB)|~Xy^5>+blh(IQF^=F_6g;Dl z!EJ6q@Y7}E*_$WB001S8cZO{h?B>}g7UPPbo#6n*q7!0G6cx!Um@qYYO>ChI z3UHc$UkUdd(HeC@_3>2$K-?e)2#gdtMhoIXjG<|n zo8WC~#DNhAiIl;kgn|YDtYv_J35{%Nm>cHEA%@T)HxvVgKpe1XunC9+fgv^!gQ*@t z3>Xj?l>!hV69m+N&=58X3^{TthxWPQc2f955gvk!h?*&LApzEWx}& zVY6*n`5sm`vLFfss^B^<#1v-K&bgV#i_0*(2yubLq&c7V!*P8&Y~-}d{Voqj&7%hs zWLT8}3fW<665xzqK<8g7ui?)AOaoO*nC9?>fQW#>iiwDk0&xWL2a3di=-R!==K{k; z{swVs6E7#?G(c%1QrqQ8L0n?f^(I|@85B%o77TD{_VN%h-Dv~_S0Up76ww9?g?VT? z3JnvI<~$BE9P)AB4+qf<+%?Uj?dBnb;WYHegA#W0MK@cFV;)aOOMbPpnbB4uwg4nT zDiSEfHUjj!!~LOO%Xrj0DhUAEP&@Y(u1*YKz#uY*$W7NRm$R$e5zRRgkP+nrLNUqT*GZs%Tmv zIYE)mE-}+&IL|p-C<_%40YV5ZQG?i!>F?T#vNAL=bL?>_E6KJB-M!Y9SmETiWI z8pah(wx{6Rf??cFnnx{<%9&vwDL6esbMJ+6nM77qEvSRD>`Oh^L=cxC)h>f;Ojywc zy8Ho|vbdG`5 z{P;&rX!z|z`ugqu?Z@NeChfL*JW5IgBIF&Tjv!1f)K1nj(k|=j%t)c8&WrmBuM1d6 zo8n_gZ7MAX4Gc{Kiy1BEyqbm8B3!KE<&x%eXb3^Ga!X4CJF!!RTT42TV5APCWu%*3RkA%_3*KY8{a z{o(w7{_F8?zZND?SI(;C9Ods4bGhI2z3%f^il0>QDk~o=>s#I16ahXN>gmCiH?5Rs zU#)__R{>00NS(Cs5V24$&NUVjQrT%JrAAux3!LDaQst;cwXEz-6fgPkR+n=U#_p(1pl9EfSnqyD^E_&*&Ft`BL=>n*w$qC8p$_X_@=sLZ+ z;G64keHkv6czsRJZ{c!)bW1?-zS3Aj!$6Y4bDPS+ryqqH8~qBehYjiC-~24+8IS-Xj ziUYYF!l|dm`)iO80&5ITniON zN_o4NyZhn8!|Bb3?T5R~hmY%r?RLLEjwxjoLS&-AOvEt+sB@p7GSP0OVd7puVG_Bw z{R04ojEe$}Ek~0aDFQhFpk(Z6-}nF3pZw!5X1Blhv&FyuUryh>-4cQ|poz8hy2NnO zQZeEiz~|O$xl?+^YN&sUwGyXtA3ALg`&9rC_b()jrdo=DA+~Y0Y`Zpus$`6&1E^%3 zW`+(3nHU+F1I0+hh-EebAcz9ws)B4=%vr|5Q6K>l5Q5FaV^$7Kgt(f+kA56~_{aF_ zr}$U@T>tey8E7uO)e%}PE(`dq9^)<)P!%B{HXP?obCUFU+z)vatKcw@t<%P_Ti^{d zE*6{3+Y_5sC4v<(5`ztf5tU>Q7m2|Htbrh_fJkT;x0ZahXmOeV<8 zWP{Z%tK^(hJ|0dxfhyu;&X)snByK~9R)D}w8$uI8+jL8!NX*X25Th?)k(})jtBB@N zG-nZz&O6D+DoASTNP002E@#;bE~l z#rk4N1|lFpwqGFyve05`6+AQ51=E#>i)CIPg0nw}fa1#ru{HrY31W|nNhWlPP%sx5 zCIT*^JXI^r)V$={3BNHpLa-=3wKqa(b2smhn5CcTU2oPMkhdR=z z@;Cq|q#gq1h#F7>>73wux8WBrU(No}=Mnxu0FD6nNBHjD_VtIS*YDTw-#otm_WkSc z-o5+o!|^?y9wCh(jY+cRJmfqKnh=te@qu_P7FsW(Jx8gz7V|~u_5%rHq_*W|-nI+w z7GXBy&~a=fjmPb#e|o<^Y{&hh3_Gt{NVS#uIe1#C&BQ=C9W1Prb| zgMh#Qglc)2;9Q%MmgJ0g&WptxKGg_v25YYtZ^uB*A_*jkB#<6bpAQciWCT_Y99nLd z)XX?GpQ=bmIV{Dob+F{7d#DOt3 zDmo0AFwDE>vAH<)+y1l}$9+ztfFJ^4Aa28A*32*Gx3?G1UtYd^b^Geo_4DUfFP`1r zUM^O%*a9*rK%ewrz5lDkdLb1w~Bv~X4HdZSQ!jh#VDD@c=avWKc<-70F3*1|3Zj834RANdz>T0hg-S4xo|&y%k5Y%e|F2)jAps zR52&i#wwT-Xx9u7aw{21CA1_2T^K^#y}R#YHG5X7LGM3@LclRft=BO-$Y zz>r5i?U_Od&8%q`?P`|u@Xd$Q-@mzw9ADn9zWn~htLM$Kqm*I$AX$V0e*R+q{qHRf zgM3`~-@V_xyWbuThnz+bAsfx7CVDC*>QI6**4h(9;_QH#bS;>vrfH|v(g}S#_faSY zKrFqylZ)5M_M1dh|(2DTKEC?9p4bK`)xlj3YJaIn+m07d+>a*(~ zzmh}0zgs`NfB5*gTc7&1Kvs|{idj&?nOJG1FQEDkEoW*l&CrPRs}-tXAu=i;#zHsL zBpMX_=OCg6C0dt^B5(=U!08Y_M7c9ll!yiT7PnUJsiJzDp0_vnEiXYPc`;2XX%SFR za^AzAGE6xk&lq_GQoXiLjXZwY<>vXslnR)p8)4^^MF~(qQHcV#r~smehyC_${q0}> zZL^$T-d;U>_436RuU`E4=PQxLVXsdQhkyAm5C6mexPSgE{QSr5AN|qtAO3VX`@^g4 z2p{j$`}fB;AN%)rr}a)!0zk46i3mWf)_@KGNQ(8IRz~9tuLu;{$}=g#q|Dev=vr7U z!sQ}duHwZqT&#Gtz(os$kVVI&r$N`J{LKe_+~M}1hhB$KMgb(UGSAf<=hr|a<0yxN zJUpcj_v8Byhr6fK<72&qQod=aIvWAk*m*l zt(&z93TOp`<%*{<7BJliEW$5!TdT^0QPTEHhL_!v8QJYc=HDAJ2%GcA01jrbIG%d^*dU-)NSK-+W-`?qJ3{3(Mg! z+yT3uffN8Ph~DPxmAWX4MAh+o(L*-9GCPbVCCl$%vGE9~<=3m>2PnfGu$+v!PODq- zu`=M56H!h~21qsA4*q*XX4grE@IrY6!JeI*nv>7m5B^wcKRI3)P zDX@G9DllNXZ21QX#B>|`C*)3a?s$zZh0c~2Y1R$lTJ{}qr zoU*a16OlaHBy0r{6DAUW7TGCbbkD*uG_6a!uo@$rH7CiM%q%cLXy(hnEfT9rPD4t4 z8V4Oml_WW-3P5)4L-O#$_JEnvLg{&6g%h+a>%$+j=b7xuPfpkDv3gNyS;%#nekwAe zQeaT^2Cl?dPNaezV>$Atm*^N27YYM1RGvHc$AcA!q6i=egIAX;{cF)V!HarHC=}{Ae61$~t5~@xLK#@j9 z%=L*PWHY{~RmK%uOWF&VAmj=Hk&CQ@L386e2*I>e2{nUcQA8X3%&bTPjL4Y@h|v1f z5rEhvxq%|uvx{tXa`p|!Xe(HtY@(LjV~m1;U0nLJj~U3@9N$U@JlZ4nU-g z5J;LpAtD7Ng`miY94Row01X$-R8a=~R+Th?2#H407U3e$m#^BNe*cR$e7^-yfW5-q zQ-1qy{rBH~eEr?~53lbZ-aTy}AGS}An}>(p=5g3<(>Uaz&q;C`vY^%*P7K7rj2I|J zZo1ITW4r3Qd1zZ|qbTxtJRG;j-DcdqPW?8ICmj<=HjRtW&B3{Gi!g1sJEC?%jMOzj zkQ8l25Fi=}hJZ+cC?Hu6T4U;=p=mgFHsjW%4K(L8N*?ksCOzgcd0T}f6$`diB^SAK z6T~cKBOWf4Afs9shf21Ds7U$Z+3N*1A$s#XvW@vcYRs0J#4k`(wmcGnW(6yF-gkfFpk5R#xbXy#v!LsQc99ci_e={ zC7D*8N|HQ+idyA`WY8ozfhN$bl5*jvAZIbpRT?%RuCIvSR(BRq)dU$3 ztk8)GZCX&sDWoy=r_*lBA#_dKwM_@auRreo{=27nj4y7NUwm=#`O9XtAW=N7bCMiq z{Kaka)%Tts(zCm#;qAx$+Yj5__Bf2ANJ7a7iXd2;`jNCu$u-Kx{E6=YnDEkhAXWD1 znN8NZI}l3TOQJr|ix5Cjv{2R)!n3(1jzGKgvT#n82ob2<(EIIh>n zNH`0#FK)m5;maSMPRIMrqRH2tDyeh4T^h#(6tlf$2tXlv$Z5BsLY)us4x6 z4|70uNJqLFJFB2>ieI)_~NT2@#1il&C~Ed{hP!8{HO1( zF6l=aah z1OP-)2p|f=B4`yGDyAq-jX@PX%Cd!qvk~KS!vmxSlMiY5rh^!Q_vL;p}K-S@F#-jYpqMyLfw6L7x^-Xy3EIfbCx7T!g zg{wKt8w~(iU`RR)us@oD6evJWdKzH;qz`L-eAMk04_i5$AZJCu7=VbH4v0V%EzZvg zD)r+`z{GlJZOkY2ce%Q8xm$!VO)Yh9-tjJwVce2rw@Y)`Q3Mp*E?6cLC}>eORi-kq z!Rr-0WzSej>Oh<+LPZl@LOmls5M6FonSl{X@t1zdNd8Tp=a4usi`|zJySZxZUrS3xuA%+l`$^DMiqzpNwAxjq12r3Iv0DzFti}5*7h|D$6_5N3|eswgs!vc&D z2m^v<4TvEC1)v5fKnxfHfRd=Pl97Q5YBJQKqKb;-^=L+>2nLKuf&_{>O3s=AAWP2K za&Y6Ab2i|ZZS(mh6aoNHbWqS95DAqca}%33HVro%IcRiA7B$1U_(R2pIB;k~(}mcC z&=3X$kesrloK887ISp!978N9`11|$e2i#IZxPEk5>wcPNE~D_&=7hBDEFX8F zRL~1TAv+KdiNWTfP%!xh;z$&T39UyW1VUyG$N_;A&}NqyPJ&PV1mD(`MYQduWjk3j zNuizeeEZsF)~r>b4MD{k?4|tMtBlR(*02H$7uv)2Y61%h$i)}bvF4Qf(uw_?&Y;}; z%4umKuFB&)U&t1UG9ZDLpO)iSK{LeQZkq`aY-6G+%bS!pc<^29G<(sO_eT^~#Ws70 zHs6Jq+trgF;FDxI5fl&17nCp!Rdt%f!KR2DL{-A$%u30(lZ%Z*(T|d z282leU$XwBX_76+62uO0ud11Sll${G5AkBktjNsDsvfFDn!*;Y*c-O=hNN)EE&mur zxaYPO6bglGX({RM8agX8DzjpGlh5woXrGy>UI1JGxT>D*;m_k6%uJ_yfrA6!z{Y}s ztpdg2D&QU06*`ig4}4YVs_?EbGX$7{6L4y{yOo=p$9LC<_wOIyy}f_`_U`@b_wQc4 ze*N;r{q^-wyE=dR@#V*#J^SS2izgS@Y+?@mN$_~m z$45O*I88K9XtU|8Dv-%qvo@I+wb`0WZ8UACrrK0QrZ&4ahe*?=)7085t(mE`*=cvc zOw?>PYhD7Q5?@v;GfdN0K_hWZyk%epXn{n4j=tbWf}@s;(;9IaUo(ujS}Wk9geD}v*K-@0x3kL zD}WGHFDznIL-q8-FzhzlEpdfWn^c=!UGBd9`lD~ZJ-@o*rgS{%ELyka>}lDY(PX&3 zlb5d^e|UL!bF*)!Q)|Lz45iRR5eplPZs*a9%Oj-eUZTer3p`tPPx@aXgbtv0$a@`L z=%jUX9RJz!1OhL+w@8dh{hSDJiAG`+<3h$pG|fDV9!^E2ik)x9^UYWaPxI;B!_BLA zuixFgxqrNwX;PD+G7*?9_zR1oi0-IWZD|<^{~Q#;3TUKbUx!=NVFVHzL;}TivAAdB zBdDRCIa!v_ax+7x8B1G=ADV$h#57qsj>ys@B>V96Ry{cDL*$V;)_tsPPWl!pW$K%t zpJzYS#5x^0WqLfKtytBB<#U!Q(O?auoq)(-!lGrY=U3ZjpML)F&%XKm%P*dsZ^!9` zo7?%};WVF4yYuqZ7n?u()$X7C+3@w3yxo?Q;Q3qm@u$=4oB8pv%u|%rg#;m+32HSa zDMiPDHyb+NmCK8IbzUyc>*aYlKg0P3WDv2#Wc!0%KjPhk-Q3~kLGF)wnzVsyDP!f` zmbY8lj@S+c)9GY)_wx2#d+};|_4eWI_5Sv5emu<5lo^}qei^mIzOW!ELRh(DMT*&< zQEpITkPU;3xr*84`cc(ttK$d@B06L z@Dd-}fH+oY{KU$pap2up$DwQn*X3YU%!=cLVK^=IkR0cD% z(@Agd<=y-F`A?63`tJ7oA8vnob@%q|{lmjSnt*AjWixIzbyLe&iU;fz58BVgQU{le z0ME3TZ^_W7iIwHNp_xUN0IEBoLx!cKtVCb4MRRZvAFeX7Y$P+n=`{Zz|9AhF zueYE6XMc71um8>Q`A_#wGl{i8K&H|7qNdiY7xrgq0ObaCrUpoV)#Wt<{8QmlQ7Vrk zk3$^>9*WOyaFvqiL{oP;rPNIwwwub#(>yf|+t6&DrA_lRw<$-@_=zZ1bHpO{QI{(y z1dwNHnTJvbu0yROms*NTVHaQk73PY<;KEFXiwpYspO%Vr#BlZR=VRD{}$cA8I9n`d$T6j2lPl$WVekTwVpsky6` zCIU6`Ni+a4n2m;EGuCk|<1o~MLeOdupsCE(_U+)HsSwvvH+9$z4H>pm;u2;LuF%O|BKGPre~niidFLF3AT->*`Q?ytQUhU7J%yct zWr(X$_vHbO9Q7ELIBwYYY!8SJi;`)-O$NWZICPLV{h2M;k@_vqAA5ZVC##pw1Fkyl zJupm^`!SqD{Jcl!v_MkoS47Tw#G}h)>Mx2qJc1asX;VF&ysZYMfZ286Fcjj#LoGw) z$~;mTxQ<*lTt})dGg?8U19K^0_Gw|L1=W2CqcUnC2ADArhJxx7&M3-UD%Y`CnJ~Zx z(h)L{R#;^#z@}oep>Mw)|LogM$iuy_xr=${E;%#$}%mb`Nb|&=^8P_h=McjHi;m?9zr$Q!-$olVJ9RweR+Ble&{AuBIe@6hiiDn z7!os(myShodm#fe4er9GX4<4M>NpJN+ik7GcDp;@on2j>Ke@bka(VIO>gws$)zhns zrxzDjXJ?nY?T*I{N`VoY!KwUt*5mQ;{vDkroMvi`#CR4~Wi|4wluXuU$Sm{ZGgY*; z)@FfdYqPedZC07x^;i>?xi#0oo>aW_PfWx#`iZ=-MXjkSw2_Z;QDO7(;o^d|o<%Y9 zIvsJvEK^MoZVp)dhvYs{ooEwsuhy}@lO;=8TRuy8f(FE{9_L|SxYa`OZHDb#R;yP5 zGm(G^<|!*Bs@d)AQmN9U!MMAGm?a3GTv51lAJz=MfD2HGs%e?!G*A1-LoHZ`AJ7w1I`53i>iwYn=;Px$k{y8LJV^y=moFW(%0dU^Zq z?c?ciY9a(>ZNw6cLb#HyY~tX^{j8>$?ZJNcl%VcJ13O1OUA!QPMmV;27CX$f(+Vyp z=^o4yLIGH`4+Gg1CXks5lK^U3H$&MBe7?12a-7=SZXXVV$*$CA=g&UB`c&HKdVllc z`pui0_m9WBsU2GNE`w-6L*~v_PTQ|8TayDx^z3vGzE(HFd4<59=&_;2HDkG|)q&7i z)*j^rqHssdeju2ff`fnwrFe0VhcEZ#(I1naDR7WoblQ0d@Fe znaNK8gdjCc$weW z|NLiPeEG%I=U<+~uI~4EeS3QTKRo^q|6_ahZ2b0Tn?L*Y@T;GdKl_)vshz#Ku^(Tz z{bLONC}iVkyA5BQ@%ff_JKk()x5Zfn464?|j$lVOyTiNd!}ZPM?frB*HnvuYyjkXAWZp!5m>EJF zds1L=(-aCbQU?^)+>{tG%;oW8A_BS&#s|A_!JEt4>bNxDk;Pfw;z17bW4*?_1yEGw zXm)KcK-s&h48*YwoSDm|lk0<1XEO4WnZ1i8ysSMxiA34`Gvclp_e!vC1=h!kODoaL zozxn#0x47rPzw(Q)$>KnMyyttYhzPqLu{zL8T9O&o;~GfAJ zK@@XCYc`!QAJbq94wKzI*t=`IzroFo?H_Ev*J&~}av{=E%f%Upf=g005#$c>S~z^T z8yD1?wpq`^Q#9ma9^b=?i*VPPo3MzN4t zNMy1%#1gr0F~?^>aw0x3CwKzM!JScjkl%_yG}^ixu6R5i)Jdv=q=Ko%5NUPc3IYgh zMcu#~BecbotiX(6p<#ygA|d0zJ)VXk7&Fx}jMcNO!-nfnYB4s6)7RM)%H|_6YpG=z zXmBwmk7x-~n;+-nsZC8Z@IA7ZHkDTTAQRzX0wVSN`j13jC!vGD4Q+Z9`thC z#atqNYwmVf<}N4Jhs|${56_P?3lmF%+Ye2V(1e-V2U7}O6U}QvhqPU*z)p) zaf&nayl6-E-Gpc6hx+Xz3aJt5W;)LwnGT_NT1$$avN@3wVRn#B$y!rB&90%88fZ?h zJA)H1!0Nrxl}66^u+Pw?&?YRUP(^5A#&pKi!Yn>J241qx!T5L!Y@~Bb^eH)ZclIJH zzW@SDV^cJ?iDrfrLM5sc!mJ~g(YxyqXA$%V4MP+n38D_Tg&e zv6O9yHcXXJi&Zi*a5awM=1NRlXsmR`_Vf~8KOJ|p+3>Rp_^2BNnqq&z-CetWw}1Zj z_SM_R+qe7c>xbLB`}^zr`~RHA{$%5@lqZDFR7 zh^ZFHX`0*p;oLOO2qWJh*5SolGoTx-^xeGg?}&m^OGqKSj%MqN2j0A~wsZ zsfgLED(X_H-ebjuN})QG(TSnm=Ire3{QT_V^8E7Z;_21-ldFr%i_0hHPoA7#eRT2k z(!S;``zk}Ct98W5fcfyg*Y-%z?)mrn~8x^HC*)*F> zYNoBVd2Xgn=GLaBI;$$AwRzSyt4!M3JWravDv}*Wu$b7=wOv&kAZDg6V1x!$lV+x~ z2}~ln;axDMX){$#$y|-kveiY&pNMLcW@-fU>KxB*Ab51vl)6BIMQJMDOXd49YL6(| z;MF>53rjFc?2UPmBXfL6(Hd-KzSje^s{|-dZ8x_ds_>aF1X5A35(&V}6;#LyjD;Fo z1=yUV)}l7e^W%PBn8$H*cD}nPL)qK(cfWu0_rIg7%d<~EJ^$j1?bBxi6&!DMe=RnY zv#atKzux@CpMP=xXs_PRFJA87yxt$~_pQxf&EeK8Tp}EI zEYb$cI_ki|Lxi3PNf?seIbjP&3`^{RXH*y^K1qDhkx4r7UiQ59u}Bx1Tx&Se3~lmO z@;dUQywm<4W%i~M!Kp3SL!>3dTmh1flK?W;{>FlL!eE9l0cE-7xh&na9`D8@a-gD_K< z%Zkr45NROO6ki~$R-IWE7&tNS?7x+uXUyZ^Fcm?97-f;w!p->hThfl199iy6w>8N1 z;ps>9(@)ChpOh~@=4VfFzC|qt#VnW` zRP4~asU!|K3*Fw~{k6S%Z8vv%bE}5~+T^1Ef}2{vDbktzic%`ae}c-@EhLXa_A9k>iMjx4$o z=EooIU%q^J`}+R=;WRfbjG+$0xOsAMF_fX!T1zQZO7)xp6`#zc%La^DVIKRXT&HO? zcJ{!K979?T;d4eK{(6;1up0)i!ccgvhd0|I^&+D5HF3ue}$HN?3ezY`J9 zM6_chZhBSS#`<<-gHUdKFOg&d7cT6@1$Da_hdNX)WQEOSHf_>QI=5LBUf#5+Jd8YW z;X+i%_0kUQID46{Hjj5_nddf(iJF2G{aJ&&;)9&h%XvDv6wN5495nH|UqSF+dCmy|R#a@PAdRrmLpksFF>oP+&T zs-IJ@ECie)_BH2bv%{5mLFYsktGoQsRo-U>8q$h)5VF|k?*Bjhpu~H=5tsLwIalda4|li-9zF+R1B`H!T?h*e#u%7i^W}tnY@wBEsB2;9feMNa8%j&Y-_II5_7Jj zYpE13?<;JDsaCFBm{Eu(rs`tf0FyGTKnjGZv2x`y@H812O0l7OcXJHm0)yFVYP*)= z=?L<+x>_+7RBwA0BA`0?(||(Uz($2o$;h;r4Zr}~03+K-*c6;!;MX6W{pD}Yn5iye9CP5;l0{2p8f?7IS}yDiK>M#1}R$R5sgEceRu{ zZpYnrdwza)alW}YJG;C*zj|_Yd3EvR?EG?balX5{+&;P7US4j`&W7Cv!{9}D*}e;% zo1P{;P1ErKw{LNrF$qZ{5r}dVsA3Y#lTEX=2ASJ@G8I*jOk%UDnKqZh#w=bTC~8sP zp>qRdj#=gn+U9vyY0#!^o@LTDi_D}=yktY>OgE@`eFs8?)T{i|luXR~T%cs?v%#6n z-{nTZ0FFR$zYTLFSX=>JrN<5tacW3axv+qG1tGZ)TcT1=`2>y()uU(NO0}H;7G_AOo#+Z1w4#w1D$Vq zQkzUtIn2%U|xN!`r*-S8re4KD$MIKQuE;zh-LXp5HluVqjH-l1gBJgNQE#I%8o7?+c=L_be8^_>!*ptRK4 zQs7Th6H+gu-0UjAu;bgCpI*KF@o)ZNINMx&@x>QE|HU`oesliC7rRo=rW3ut(|`5< zKK}c^EC1d9Vf^YTjfJL$>j!&#XSaL3KiKty-0j=`DD!Lvu9dgj;qqeh?Z-5ZfY~(J z%|m_lb1XpqB+{Ws}Nk^D|g$52)E-+(RbQ#(EYUmiuuft+7_bRIeRa=73 zHRfI*Tn6YA1Ph2O7jOlN&<4X`XXo|oa=5yzPoMCoAM?jobaiIC3NlEuS#UhSqB{{j zq~dhM-2w03;oY^}yw}@34ttwtqL4eIaiDSRBn-m2CHu1A$`uL*f=pT1#vmHwk}0{T zepyTe&RP#(sfwoDiv4g|Ug1CDd|J**EPW*eIAHM8^H4Qw24W^61Fl0|P^@c_X8JPD zEyPWUTr=6JlQ@SJX<~&@*=ogR;O&mak!t~wsoMTvH#hS7W&7^=;k)k-FMm3|dj9bK z?ft|3v55c}YaPbTryre_QiowIg}He15D{^4P@M(+X7Hq4BxjS5(=3nUC@S<4!)Qx! zPwKN&5W=!90pTJYUj6A`JHgTXX?Jo&5v?&<>w%y}V!p0<*$7Bs&CJidGBFugsesbW z0kDDs5RA#sjnV~C@k*^yXdHMq47=@Sw;i`bWnwjzS?5{gq|#dR{bf|CmTjq}a4Dn! zi9siA({Y+wV7QnUK+2$@l)|JTLlR6svQ$iy?~L)+6d^H3K!?=wa;E%)<>vN>P{6E) zCIW?$&d`a|v&Coxq_8pSq~%BjgLZEkj&Wt!%>NoyivWIWb7lycIk zwI(J;W~?2Y_ZIu89b4?LdtqL}i`D6|6OT#F%t%FNZL_zFi#C&n2xA1~;YSa>@U|{ToBXES_l>jSpWpyoC zbI}_?GQE=sDb$*KOIX1DT$|~FS11XA%P8+aKCE=;eOWKdAU~v^rn@wQk*u0l;$CE; zfyjroER+*GK7d*xc$ z$842Sh;3m17EDyR6efnN74c9}3RSL^%E+bCiMbYnx5Kbc9YJ9xw(h_HRECQnyPWD! zxiDOVl~%*gq9nrLlQ}D_f+}n<42*$LV8yhsRiA=gs1yuLyMj%j?La^OiiU4?j2%ZY zDioMOhuMw|)1>pH$H%rmobGNP?{6RP?v9W5`-i*ZX@7h?9QTLE<& z|I?;6w>CGKXO&sz<~C56oxZMq#VK;8icDv1Xd$t>Q<{3+851lj&sp2TTorjRD{Jew(jT5GP@C1P!E(q=P*N}HRS5Y$?0^DH)- zHmFEzrn8w!YtowP6up$KK?P`D)8UP;fQkrwH%H@TqnJ^suFC8M8fxZ6A0o}fWid>} zH8|9B(B?g-on?jsrmbbd&0r{nDYyxyDK+QNQugrc>cf*vz7AYgbiCI8IDh5&_8a8Fz5D1gr z{^`ftKm73z=ex5{Kl|jHpMC!GZ=U}87iZu6=i9Rj8VX=IPWIpbR{!`Cvp5$(lu) zdb$5n&yNwo$5Qs4MSu~TnK>Pn0k--Srb3ZG`v6)|*v_aOC+=DEpb4|8(N;&wbr+|d zH>-1R)OUIh8_3qzxPa(*TL8AWe1M1xwi{etmQOyZA3x(~Ps_*8`0AW?BMQR|E_6N} zA%vmAD`A`A@qqU?cz12@ujS^>9`5Zl!5Td6RVoc54I?ac9Uw?g(J2W+VCC4Yoh9hZ z1?B#W$441wBp_W1mot3>BI2@KHaea~w+Fs;mpTz&GRP0%%zFiG&tQNH0R|1}e@=qK zCvK2M;Rdrm>6k?hWN-I!+yyjpK`mAo#nns;4+C!oj026;TC>N!{rEy(ylCJ3;qb!` zch7%%c>U_}`ucb{&8k+J$5J+%`t0eGVW?cVR$?x-6p$A;5cP_=Ttk-CK^~TSXzo)+ z5;3J5gn8y4PI*}H8ZBC6O_Sj8w>0%C4>SKlA*$jIQ7v0tBKs9^Aq954)+0d(1-1#d zz+8tq3@BWxS0lTeFr+C!%FGoWj0fx~OuK>3cAK-!xY^cm4V*W!sWz$pZQKVRkfyVZu1_1Yf=(1sB#&()LJXmaj4^VcSeQ%={a0cYtrVq9YtbR zqPNU=tr2{F4YEAm&4Z;~wE$_|F?6(m<#r^_PQ;V{mpN&7Ze|fXE=)rY^E33%Q8Ia$ zyf^W9(8GSfh=RHI*E@*s6_aS?VN#HU3wn~wzn*gF^rg$T^#QY!4Pq{>_gUUX6lv5$5wPNz_zQc9`* zTPn4bG)TP{#+_y1{<}nt%r61~?cu0L^=D1+&;Z+axx()+Ra4?KHL1 z)TU{kCOJ(qO)^h1wbs1uuxVt&)U2uNB&kFTW)ky(xrxaAEtfL5-z`K1W$9WfrE;ld z80s+ee|Z>qJCtp$!^m}@>ar{a1H-LU4tq00_*-|Et=X&)GZht?RH)tKp)+ zKc&qL=1yOl`dA!+wpnJFkeX^!ZK?u@i8h%{9ZWbMt$;$sB7fpWSOF!#VhpW%x0!*lU2ocsXw)d45K;BR7ILgcvv{>=z-OsGu$vRU@CLkXR`%k29@n*&wTT*NaV~i z5}Bk>!cx0IMx;%Yh(JurRGg83i3nv;m`jN{-{GBc#Z^wtSTyDi0L)aFz0QiE#W+yV=uPJUk!Ja{5}fym_&Ea{uL93ULs&`%!ZlM0SH*))zw3T7nRS2M#0V)` zA!=q^iHCvDYPmYoS?PG3PN&DybiaSBz-Fvpe*E<}Uwrd89p1cu_x#PvyZ!s=^k^*P zH9g_@y59|d)Cr%2f8mH?b#&KCNq=;oYHKv`kCviCX3cE5UzBECXS`}Yb`Pr|F|(vz zkSl#bF#S%)DRbg9cTNN((T{u_i9ymF`loQ9mJS{>YC~FUOKQd+e-bgB1fq!dJhEf% zNUFo92+TY|7w5(Ti3CsKp%&V(()8o&=RdrC`>+4q=E>#RSD$_L-~4YsKjY!aFY2M8 zRsQTV5CLjU?eJ(fw{rb`dw)BzL}^vTL8qw zIS^@59v)nMz+rv(VB=Sb-~2gf?*u_?orEw8R#r97zZ*=vJ~1S0dvxnItBc{scI#N) z^G1OiU6KJt#5sb2Vl)h(Ny7N&cl2itTC69vdiC4FPr!?CIc5^1DeceNFOB-kk#@YV z#W-yuL}NECD-lQ~jK!Exr0wG_JI<;VyDhG+%9G3T^lAO%2|s(rPcCgcpiq=IHnC}n zc*6xij+5Oz;O-uGcXs<=_jmU2XvZT|y>fv|;c*+Mq6&{8`ZHRZz|djx5=ey15dy+= zW~A?yz8?CO`Ht~OD9Uv?n0AWU1{s3^AS=k9agIDTZ zDgeBy#UBP|aFvQ7l>IUwV?rs|j3{Kp7%J_yyxCx^AfU~*f7JKa_Ts1cPk%ao_ub=* zACGTd9d2%phhtNb!q^UVJ8mz|&#L37Qn(Z@Tu~xjn2%?y^8+HrUhQmfnzNVVSqxhBJ?xHDqf@5`{84t&7h2}qnU;m* zt=>+HF$1MUF$)tHDpB!HT$u`Twu{%jgPDm8-pgXm0 z6%g>T6I1=Y!Rivg=%W?fR}h(r$YX0dpZwL{TTF-`UEnE{6!51+^ zR3?!oDkjal(A7`F>AIeR>MlRcRkv|bCld>YRtd-TeWGwl*5BTXJ^DWO8?{W1NdC9y zeY!KF?AD}2@4i!*dgG1%2plbc!B;4e&jwjUYnO+N{=~BQ5(`+1hqyv{-Rs7AV$F}` z9q1=bix;KbEFXMMtb$P4ir>VlTkb%A0)T~I>X4bzWF)h;I#+*QzCIT&(Be*H7KQqY z9ghVa5~8r4{Ug`iDHK~h*>CUTcdtz$-a*U=($o34!p?+~f5GewNYsB7S}H!g^xfNJ zhzc`t48Qa#x)hv5T$`l{0zpBu8GzZ;GptNPv){*P@8!@Fpz5+D?EfR5QCmQT!Zx`A zfuBgaOacm{6jY|MxZpNzs)unXMuky{SWy@QP#PG(oa>$aI*p@XJxb-H`^XIBT2rWm z;>RK?j8ddFJIf&_kI-{M}Fyj4ePN zus|)!%`HLWhvpSxOgBX13$G(lMB!OeG{rncUxx@ozlYpr~Dr@g{9zCT8L|g`iPs<)L8A ziVAb5(mL6}l%_qIj*I~hB4TRN$9}<#q6P#=S{ohBQ~2i& z$%8&hN6{ng46Dr_kacgwu?Yc-x_J;HP+a$KQbd)0)No>Egy!z25Eri7^D5^&&o(zX z9_DF!ynP_hb}U~${p#D#zImAT*Vor?Zr;ATel<;d#atMHS$o>v(BrxfoF0NO=vbyR z1XzePzsTM(vUuMQX%oNTp1EgcwkSbK3=*Do{cX2yE3av*ud^Do=Z2&8@55@hh|79y zZ*Cu3PA|_iuw%Z1IB$P;l`eN^6~QJpaC>Yq)5O1aGG%F1j=k^R7AC5$*;O~SFd1b3 zc=(_H=BM9$b8+~Kr+@l>I(s&L|H6Lwe!jgw-rVkQ_s55a_IPYfgkY7e7Ak~oF|dJP z0*R4NPV;8`&|MpeTl+`6->&cS4UCHvdA1&KK~SuwZzCBLw3j_v7~)B`FvMm?X;G6jBDtj^@XpP(bq z?nrZ4=jL3S+O(!|S5{9P0E{@pcT1W>lN7BLn+-0`=;~^C`m{W|;!i%}Pp;@R!j6GXE`#f8G68%0FDlta`+ECotUqh40Mz9Z8xAAGF>F}OF>j!Z0rxitKi`z}^` zukRu7FqCoR^BpdB<*p28g=#^qAVwK^9O-OR&UdsKc`P8oJmE0e!-E_rnbg1-H|6Yn zWF|G7j&_*l!>LV8=BZ7SOf62>g;W7UfsY>aJX5Hhtjh;;fs%{hk*{LTDAGLB>af^5 zGCT$$q7um1zb-IWYp0USv(F`s+;0RZZfuML+Y>nC`S4GV z>Cqu0I?hA}9<+|a)0l=`?LNiKJpL=xO^dq?6Ke;-p-o813=cb*mPW>wGE}wsv_@66NCyjI3XVp#h;t~&|7GbdurRuLy^Pf2NRo+H*V>89Fg)pK zkW|Iu82gP(Lrja`i!3_9EHgOBh%EZCti!Lgv*8ir0W270bv&mOu=3gcfq#g8VA*UM z>m?E3>WnR+k42?rawyB~Gtb#2uyoMH>1bD;#4Ce1*@^{1g)gAww4DKDilLG|p~g+U z^9)W|cU;V+yAdDn!coE5T@54EJ*dAjo(>~1eV$uBS0HE(01APb3c&~h5-?l>*`ls7 z56e!Z0*J~&Mz}XVkP?b!V20~?(8}`o(RuZFC;)sPi|)9R5ptKJn zhj@pGsEYZZE)f+GsJ5meqM7XQ(uk<(keFo-QEg3I1A-kfYbLI%>T(_pH7C!7Y=C+tI*D#811tlrqeEOP`9vw`Pw_3OsgL)FwL*CrfM9Cu(Os833 z7Iur6NsXFX?8$6$i5d`6CaXSU4@AU;DO=w9%TP??^`cIr6c8&hYv?E&*u30|1jK?y zT*w-sC>7P>vOpkhA`cJyhlktExZCZ{&d)A4oAcJ}xBvd|cmL+@>Z9$K-)z7B`S|I_ ze422-m-{!SL{Bct7k_z1bas0$KfQc>@#b*1pPSAMBWo~;O5uQfL4stb1(uMAz4?2I z2@+#Dxxp6s@!sCzY3vYsTEffpG>%AmND=^m0=0!t2`8NXm0&PsI+;xsrIxZqIX|zh z@o}Q*v_DLb4<}aLjP=WB-~8;;pC8-t{oVW5?_XWtzJJ_bE1giI#}et<63lc*=~O{4 zkc@R2r(xcB*WptdnQ-Ibc-555QDx7_`TFxJ8HQO8djUYbSt}@~pwhCMDUp*G%cNA} zg5@j`L2M7Ee_Uu8x3S|BETB&geQ3l#k_a5R^wOP%rRK?rZfFZSgJ3lIuw!3)r1VuNTHHOWJQ5yiUMl2utyHpzzXa z=8n7vQrSKPH&dNC(Z~t8;Ur%XWCki`=B`#vLlRWYhz&{{5R`LF8IppbpdL8kp17Yfh%`d(sn_M_iLI(9gZI5Al5pZv`)(J1I0K&?yh3j)tSit}WUta5I+~KkvLy2fzJu692ls zZ=OcZj?Hu6{(1oL(JFzedxuM{UZ!d;Oln8$nhq!hggRimv8yY5@l@rBJh?1S&dd2F&dxUD#yn%!8V(2B?`^-wG(!t^ zTi%`*V>GejWQRlBAKGb>dA6pZ)d85Q`DzhKOXCC)!cE7Cty0fO(zUR66H!pune*|V zAg+N+j5CJIl)zF25ny9pT7W^RdO!dh2LXZQ$n2q4>cW(QQcwnz5v97=iBl$#I7_Dh zs#R5utWYQ%mDqQ_Zwg?U4s#Qhj5b+7#w>ctg1E3vK0UO9i#%nE(kKsY<0cb(!YfFtg(3z@_uOY{b5%S{rO z6g_j?*F;A3%N`00Vu@!{}!49&)jUqXzK@?A=H}VrR+?M6T4k%D670N=AC}067x<6do+UI$u zK?K8NMz8cmUU?5@+(q`y0k`s`;XnPwoF-;f2)Ym-%kMg_$p;&IAs2mLVcox}BYOk9 z&_hTxl)kN(9RMoP5arnmD4QYXv@2Ag0&m1|ciozaPj^*qB7O+xF94{hh^q4&;$u@( zVnTUS6Z8HORn^(0jzmP-tRhg)X8255)g~|jF;f8*GgtJfpaLsqV#MGIn~4f> zJI(Xd9&hijD{pq&v!|Emmru`3^!LBL{rlhQ)#dizJGrB>CNNq{h`?mC>JWwSjy%@DUe%J6Nm#qV4lT6Smq4^c=eV|Tc7}~ z2(88U6RnL}Kg<@Xu-_SYt?32c+ZnTP+T zj+uOlAY~k$npZ$%@3X=wY$E5)_tP*euj&SpGS-gcaraOucbGrOy}()Oo>v)IGFgdy zI{oe4ssf=H5c{ZauMa+dbri4I$)ZkQJ{jbt>(5`AQxQsRs?T3fq=)lQftPxJzgIaJ z8k53jIEa!fPMcbM(h^FoOuM6<^%sPgbe8A2T8n-+R%HOa7718YQTJ<)2k#^q`+GYC zOdsZPh(CVH?~!H>3{GMYlvTW)TwC^o0WjvsprwGbB?K`el(1dQYRVdd-ya|*%`L2+ z+FI?id@1$Zb~l)L>bbiqmIU=%$oU0D=;T7<3?kcZ?d*&$&iTnz{q&>q(G$A5#QBJ^ z0EX6l#H^X3DXJ4gz+uA81KvN_+jn|>D|h#Hm@!WLL5ATAcT2Y$$(aK>;HA{x0UDAtsxg8JI& zbsPZ-$)Q@jCX_g0CxKVi)7w>wmmtTuK&y;Rv070E+6*`wXj{NQ6Wkx{yEpdJPx6P~ z?SKEf`&Z8ow{NHW`_m-G;IY;^)b081{G(cjQU)%i6o-_cvaHFtlVoI)NZP!TBC)$l z1nm+HEbeC_!t$<_RPM;oD{42qtV1Ii6Rp5%;;p>#nq_p0SCu%Ri-QoQW(OH!poAs> zbRgm4zWoX0R?J79San%1LyJS25Ltj4!;7ko}=Kc%DyXkEr<`LWA;%q#N{zQeho4@|N@)wrY& ze2;H9STmc^(6H3r#k(`H4;1fNAWkU-E+FPom@Ai&hf2)Zj210&3N;{gA=O|$tsUM2 zuPxXR-?`CC7Qt5d;DGL$IS1=N!M_P49Q~f;eV}k$&MySl6cwhcd%9RS*Ys)dCt}19w=zO!6=#@@qMEro9?c!#}?=M2EEg@$vBa4Ir zskfP^7ovO527(A7^gOnCV~4j=w=9!Th^U!0k>>5CKE*^8k%mw;wP;!qgJ%~6P;Ik{ zmvOX6FRQ4zXIGV3wS}aH39LE2fhJ%#OAZfaAK`&u3&iU+L}F^JdFcm?AW<=DuJ9dD zfCw)7&eA8|prfGUCgBz)4iATe|#;uGnHcNQIL-oRfM3Ih0 zmISg_ICM|dqs=tX-L8AIiJoky5X*?le@a+^jJacR!2~2^b2YA9#-X0=Sd5M*o#w~= z;c`HY0g z;qe1=tN{KBjO5FfL#?fE0G3tAOC1O%5(ub~w+q1XHGwo065Qun;C4%ZsW7f=&FFa2 zUE_zn9ZwC`px%ek%xMZqCdMJ{J)EW`xmL5+(sG`pG3w?Qo&v3|gKr+ydkZ zw|>SwKB6@tXC0;GK#J^oBPHWPu+YD^^mf*9PV%nHDVV!C3#4xbw?`SA`~GD0%3CMj zI9HCFFlaw1KLQh(GbMfte9h+~wZ1WtYz=iI93{FA44e~DlkmPq)wUz9uIl9(U0(7> zSM!kat1zq8v1ySvl-z3m_DFhRukMJbhP1rO1w zB+Y|hvdoD5BU?fXi`SoY2aW4*EWdVc%cHp1l^qy5WR3#e#EiZx^EGk1yvlgF3LzpL3$uh4tXo7oo=x90!pxFjQ=+RVFT0Dz+nC4A?S}p&4!- z@y8eX{CWG+56AER`1s<-{rfk^hx@6WVy%t?kGswJScgp=$6Cfx#!|SHp-{=w^aNrm zV5AgAVhFX6$qgv)xWH>a)kH%NEr)>+PlTUyR#A37|FP%%J=t*Y!SU26|+5kIyb<*JG!r_8~}eec1% zo2K4*VsU#dbTKTm*b5sLTx;w&o#Qp}Q?tlB3#=P8WUSRA8T9xh&_s_qVpuFF_YviD z?GTgFwIbb-|B5x;LcXcLJeFoY`h1L>+m!S3lQQrn$)und>V$MM7VW!`boN~ghb?wq zeyTc?9gA2@0L&@@SS<((Fa4!R%n*4+l9xe5#TPMgrBbLAaDMFp5|<37N^q(1Cp)N- zX+HFzFAPvOfqvQ9ac@v#Hil1zQiA*UkRNdXq;U9zdFyb2LuHW{U))S!td&Wa6OXsT6Wc?s=LEX zuYk-qIQlY?zTi_y#0mF~3ZY)?;Ok0e5z(ju5j7JDBGg~1+FEO(!5WywgjNB}nyAcX zDxxqWQ=OZNL^>k=$|AVcT9erasi>&5tWEQg@}lMmuV~y0f)(a!*s9`MuoAM+VW_M% zF;h<;<(u*oBI%;4LfQ1>*=$#sj{J-&0h;9@Vb`sjBDXyXK<11n7aBYhWr0Yv|6=W6 zk{cy6B_-~I9Q55IpLM*94-?YG}tc~<_j-<H?QvY@8W7d(ISp;A9A&|?U0K-vyl^YXL)MBwcFkAb&AMluoNhMk_O60moFlda_7rj^<*^#wX|pa%9Vb zOAan#mmb9A9qOVjdCQY^jb+={7#IuGTvk_qj!5L-Z_Ub054|HvR^4rJC~^~ckx4-FY;rQ zg`F^i&tFhzDL6Z)i*tH%Q9pW0pMK0wFKIhaEym=smH3tfzvXvCG9}2b`J{z!M6oLxwPk8>$UOk_G_xr~`{PFR}?@w>v%=h;)okUa% zqYhNZ@oeY%?q%GRVN=G1a3??Z|s4@*1(1DH_@4HYmVF)~u0 z!p`JF8zqmF28rq*0iWB^{Am>4as3puYPx!rzYS%ztReC@YvYGrTDvSiRddQHaK z7H7T!(D`NqfLild{ZlM|ZLryF2DoXzMC-o{1&|Jp;|oL?QJg7vlbnbF=|s7Mkj30} zDxDG{MUu31N?~;eEWtff_O9lMQj#eVF)epssXWnynCR!RT-*ZKKK!$MPS|Gp@aIXT z0PsHWyy44JpuACWCAJ*QY*`J|<1hkAz#R+tlqUJRYw~J-L+P^(R4SAOP`t;YCzIX{9XU@i6|e6fCfOH%b27nHUNJ z1^*nL525WJ9IAV1n3!Y*NY~#YC@74Qc8*UCW+K{xmp5tR<6TumU0hjIwZSy)inp#v z(-w7_rUZj&YqM8$kXZ@Ewcr{|=9nWZ;)Do1=@EjRY3dP;X~f~{qox#asL|%xymci9 z{kjWqkuWYGqIq&SVllJ@fc1h4^JC3Z4AM$KW44-u;0SqXT(D>!v2L95<6@P2)qd(q zEY7UQ$9@tP*@yluELO5x&U7nS_?i^UTpO}g%x$g-zY08&*2lXVmW`IifsHc3?Bq*s zii`#d2m)qu!Cx{krM_tg1`Gy@CY>qbbDNmJYE88gx$I?8W3s|r%nDI4F0cZ4t6&3I zsS+BK|C~vSftf4Yz|=(B!^3gEza8p!v)!GYZ^q4*YkmGoe*eeY&4#}CWcc~F+t0rm zxX}GxAKs{d&(F(W|9bpqf3dmUUp{{>KfZ47Zl-BEl%i!chLW{T!{t459?X}8b{M(3 zv9|UwmL}Pe3X(nOfn_>{1=vM)Wbwh7314hPtSL+DKy?uZ4`tJ74Jnex-)y-g77wZ`_tQLNrVJ;&i zN{W-SOa)o*n6RuEjz!jG7$~-O8lZSTV8%o+C@rU;uq=S<$AWtj(hg?))jcmkp$|ty zDZG&g(y%lR_q3d z(s8z%J#KGtb7yytcK4u%1KRA*#tL~>F>2r;5` z0~DXISf5-Rd8PU0mBxx)p)F$wwHNH0y?tjt{4oFFkH_!-`1s<7!<$#9+nf1# z6j4y&GL$;<`A0(?dDxU;Q|nl2EyGyq;Jqx_y-a|*d7?x>x%Zsfpdcdp#FhSa5Fuir zHJ0j%j-I^u)Md@g)U@Sq6;aD73N?tKsfr@NNt9>0T1RjbJw0p4*+_l0Yg!J=v1RVT zk--u=2?p@&ERJ{^$SVUyPM?dq$fxlmR@V z?7&+)|22N~YxLzyG7YeP!}!u5rBqq?9(>X%;8}zI7q zYq3UU2^yB@wEJiCiHiL#3I7ujb;Q&VDO$=wy$X zBBkL&T-a2?hs4T^N-s;4M=Ov-Yp}c^X!M%{z&B}D%>XNIqLffDDZ|Khq%v?Bm@Bx% zr*fjt6)02FFuK;X85ClK)Mxe=#n4R`pC;=-Bp-DDruP{o>grn|yl#L|Lhty@_%K)v z?%O!wvQx4(%~#s7Fr(h67m^x&CP`6nMxK#rFQN0n7U7^P>aYjVsD`egP-Pj`b%PNP;fjfXC?tIlo7+QK=7;-zg+m%rEiB#3%(qB7`fx`)UG$uIz|49XMSuod zRKqx(Mh6Pr$#zHL)-DAuJch_H2n2-+o3U|08FqZW<*PHEW;#rIZ1eF*r(*%tI)3)i zx1WFf^SK@F_Sf&;ytsLIdph2O8y9$OZjx)U8pHgou*4E4b+|#@?fA6=%W98RL_IR| zUjB;4oB6)t#rtPeMG1uREY>k#o^7BVZvivIQb8HPS_cvfP;qk{ zJCO?Yn;)0OnF#Sy%IVepy%A5voXyx^W>!E%s#Xe4Gt9F;$$&2?A>o+?=}|1pel3O* zW)F|SVgOvHL}THt!FwlianynDho*cIg)1l?RP^1yY(S6eqKvTH<2=u1WFf}G??ED?E(pEs$8(y z@cCJ}IIou%^wAS~_5_z_*p8?SRk*H{Uru3gp-K?N;Y2q#c721pd%L^Whetad-~}BB zxlJlH5pLua0%4}L1$!@r3lnf&xcBiG6$X?#{O<)Dw0eEqO$s)T`f^y{Ro`~;`5YBG zPOMlghTH*gGmy6{x>q{!;oysnh)T*oa@1n@#xM$fHW2mFXQR+7_2+ENR7=tvY(u3D zTOoi^nTA5Uf^%m&3FHP(e)a8MSS%*3{tTcoh788kXjF^P!W?R!9l1`c?*0Z z+RW6fiHKV3lVw#^wVA1+X^0a$`Kbz(_?4reNX|IEj+GM7MOSDiOzVfSN&eQrsYF8n zF}qFf&Yrt<%q+Z9X?+>_X8?fd*>=OW`;Wi>;s5^s82|JifA#Gb zxcV?;j?<)`N-{S-eKE#1A=eso8L}AryW$B#H&!8ekJ$f<&K5)lzZ;$;9=#X^Y+Na) z*%1$1M&^-uU>>+cOABFOVlQ1RR4O)%S_npktipsK2J^L=YXPvU-&x0|(Q+Xk@ul$PJT#I)AqY6Y)S+hhB_v&?0_%J(}-#J^!VC zAC~|=$0YPK3cC=9rz91JWCFcGF6rbEy}ijelm9+vE-@@|iWhy){B zrw0tyd+Pfha;h+&v|CA{7&zsHC|pADg9=lrA?d*bbIJZ1&a$ICrQnum{f9vsCletG zK}pPKzJ}gNgFq#U#nlX?Aqf#TM|?#tq9k!xkTiNBeTs$3qAmM?Tri*z*o`=K*y(;m3_fASyEz~Nfk4gGS-j~MN-*z%QJ9aG z^%4(N)utj-R#*U(y||IeGQh*e<{c<1O?|!#jG$&pJ>9}a&JF~=7u6&eM7-t2w5{T< z$mBVN6}!mqMiccLf;xZgolrf~O9b+QOO7^v_0_9_P&M}E7qG&?GXtdNa;FR_D$HC|8^g6cn21?t zr3zxk406Hs!tjETs@{D`Y+6z$l{Kf#n9sGQMpl{FXcj7rQA;gcwau-a4oALwsKYR9 zHke?Et2+LZYC?2{P&ww_EQl4bc3)Ba4V#yoYn;T0F_^DgDJMg+c9( zvfImLHzR*mh=WWK(?&q8G?se4DU;D*;?r^7AGtN!p9+^+>qnPgeE#v*r_<^7{@wM> z>vuPAkEdI~iL1FvQZjwEI?v3_e3+QY^U3c!9Pqu=3ttkg=jS*Pj*LkFQnUOz@fJY< zQYAN#>rB3Bcb^|(nKk>eEc%Si0;WI*Yh7=41 z;|Mb}!Q+IRTim!v$AdjQ+Od&JToy4=jVYX7LM_P&Qo{I-^NeC-Qa%w-Vx0T(?l_RR5Ww++S@7-U?N>FOwaes|J6Q@7o$tSKv=Pz8~fL660 zUFDtfL!zXB5QFvwY-EBJ^XKhlLYT>#Y$1ARzGVHi+%CH^lrqqGC7AjeB64jk9-UUNw>CocIi9);3 zNiiQ^7d1=Nhp6~8x@MvhS(g~7A}-e+)6-R&&IU1SDn7j@vOB5M5g#3vvsZg2t`7KQ z3(|H&*YjnT;Y7sv$T%R)FZCsazTGnX2s4G`H~{A~a~UR3@plu9&Fr%$AAkGRFFxTX zfBcV!-~ET1^Ue75Z1=?%+b_PZpZ=Ua`ijm!#;`>x02wjrh_fg3?9cG*H~i24Pvd-p zx4**=f7^ciU3>dlZm;EVpdu_nYSqS#SZg|qiIIXcGn*+dLhf|*3u9g)&A2NrtAZ%@ zw{Bpf*f4A(r&V($!l2^Vytu=|5-tE2u1>5l`IN|kxiXK;W5}3MVGKU=u>gcpuqiY! zv7rG%Bmyy0Cf{SzSwG8HIPMLOkXl#IoAs(zUYQE;>2!diI7A|hNNKXHVr^d(oEhXbkID~Gjun; zev-6094+zn2k|tz!KVy>y90>@;}{DGl$RZ{x`@^4K{D|cYXq?iv0Uh?l@y5T)b+Kl zKhDp0!AS}B{DY%Zwl}q6!BBxP1KK&?12Z@-Ts8cDaOdI>l*bB)`M)Z^rS5loOaNPM zk>%|%EY&{oGU{+M>TwMT-j(o^-kMqdrt=K^!}7X;eoto+CTKGZ$9 z-%*PF)d8(X34oEQKp7DC#Zr5sN%QJQgTirV;FUtv<;R%`u3XjOtrtV6s+dnW^L`Q$ZQ}Y? zl((yRFD?TV6XZpuNiz{>^9s%!gb}ruC8?Q0071<}tj!7)iP8)~6SL-pKTP1Vi2i`c z)XayssrVGPXnwI2G4*X{(K3~VJ%0bx%(S_bs-)aK5nE|HaTQPRF*-J*?)IgQ+s)bTY^b%~-`n5(yZzt(`}x@?!&g7seeunBegy>%kG8)x zWjwp0Pe0%MleT$(Yp>q7*KdwD_eYV5si0B{-R2Ev@8q&s%LGd+&9?yC}V*s|o72zDtYOiz(POX(ikph`Nyh(X&@WLvH#In8)H(&Y;f4q77?x(jmFCQP?Pc)T+QfLKu0uL-`X!(%Hh6{vJ*bINFBo#*Yn&ZL zV{E|H6{e!4CITIq5oJ>nmaFVo#nJ%9{j}^Tk3U4w)JwtU^g5t4Z~+C9{3a%2q-?vV z75>qhSv+vwcuQ~^NFdm+Fv958Py6!(db4qVauj1cA?CSOCAM*(i!-{s;*T!s$5-_1 zigsI!gTq;fSZk056RVD=22$8@!uvbByT|>5Jv`dO-VR61Eta8Q7Z)mND$P10zB*r8 zHjHOB(P02W>N?klI|1wc2OcU3gDnhgKr4Xyzfp^kPhm;pcCxP5J@+-+|7N7S)N~m4 zx4~JANQT2Z3X<~h=9dBxQ7Jy>E|5tn0d`!T!&_P^VOOvTCdF#S$h0fiGK~Zx62jfl zUfj}8&*$%dI{ozH{Nkt6%UARDwLTn0)Cyr3co=bUxf!-JZn%!MY`AVJ4`mpOXSHJY zTIhg5VPJ%aHT)-8W10m5@nWyO5DVFLgZeZ(&1tZrDy@mA`eeEmGwHJX1)@5O`RG|O z%f=RH7C~DcIBGXs^#f7SkX9>=I(eabWJi`nEAe6#tv~QY{@fSa5(=$MTcQd_INi)8 zhl*Za9S0~-p|fH4t53iA_Tu8{B}DD`p!e7I`lt5%ySe;Nw0lAye_1~Lx_tI+dG;lr zKQk4aURe7hOs!OG&-jyH;`6_(|E1L98+-RBeep;8;rH{)=W>5%t&!LW8jQSuNR-5# zmzxE(^dX*Zh_#uKLx$aPLH&A{`vM@#sT`4TSQla!@*r0&t;_}NQ|qcX@Ka&Jz+9My zU_E$b6dw=?8wrD-M^*Y07#+XW6xVWFGsYLs(z{VhW1b!2@dCD4*w@+tG4-n$SLaB*EM@3L`)W};9+ z_4&0Zt}oyrX)FO`L7=LdG-)$CK_12-HMiA((96iSfxN6l$rS4g{b`eeyNWZ2xDxs3 zT;@{Q&#YFeg&q6EjGKht4yb6Jr{f$Ud5ZF@f9pY17xaHlG&DosShr=I!5lPE1_tEF(|GL}MIY5-$1Gm|#AX>QV5liB$V5rqpm z#N1NqNgl6+_v`6rs5EWPXs=uoQ>~qtS9ARi9|I%JL`($`HwEs03D#>FW5=1r z+*ehz289OA;YTrMLEOjMW}6N|DEGgYq^ zA(R-iq^wjq_BgAigb)f=bsqo|7t%sB5EY_IT#1TVA+Cf%sBS0-g{T@2q^-4SI-U0W zT8DAF+m7RA-0dD7+JE@B`@j2l_m`LT>tCFG^Rw~uk1!kV9_{{Ct@6{6KK|wKn_t!I z$FtY3+w)h`&D}}0d0!5Dmy=h+S!Ys>yc5QuMmQo571Hr9#R~p(qg=?Xb2=AZEYz|M z;u9&;{JA7z1jzE}mqdu0aZfElVU2{xGrll-a#l_=9w#}R=s2I|R;E)S9_x6y{q)%{ zzWB8q@Afy>H}BrQfAMg5*XBK{mVr6+fiqfItbO-m>nN-0xga^V-}IFDfN%*n3q;{7D0(1%Lb@d8JK5v*sLVzT~2FN z+DvJ576`qDft^32ge(?IDAToOJ`%&DN(>uZ?)c)IuP*57qFnCiY>VB1aXgi znynBO*jTY0Ad2IJ_Yb(fv+H}iy|;(GohBdC0YpF<7aEls3j0Tpl@J+}z8^c*<@ja4 z7=VJ($n8@~k`a`T$-HJUd<$Tn13;1&slH(xP0aC8L1`ed=074k2$2}@-*S8oVUZd| zGA3D!8oUPQaoyaz9!J;vaQ#v8;j(N)K_yhC$~dpsGHtRj1cZa)<$L??hxUg*oqqbM zy?owYznO0CPRE0afEZ<MJq3(9D#^{e*s?_aEHb8gWBohDL(#U{Nk(aSHBoP z`?_2_1B>3jveWOI&O$`zPwSI!_|xCeKj%6>;NAE3!*BJ6-%W4d&8MRY04e}$ASY83 zHG+{p##A|r-bXf$6s5PADF9^6B&7$9Z5!JtSr6tI9?3v)kyIq|AY$-9AD{7S^U{XK~6%i8@A_bHo%xpYRX&^Pu(Tklf zFobHUX*7oz5qxe!mTecT)XQi6sCiiSwYN| zYb73xh`ACo7te5p22?(XG}Dlrp)q5g%mgZ47+@w2btqzA!%|$kT815pu54alI4jA} zc)iprH>8US3<*aYTp*a(OQGD+v8!8UgGyq3#M%LZJ^(Ru;b9;yGz_H-rEI7SoifOm z69G>6!p!Em%@a&wIEn|N9#>n^p+vAHvu>LE4Y}@|P4iV2n;^9+^R}50qL0u27Up8? zTCV==hFUA}5bKhO$kZmxXc=lmKd9uyQwb$1{~aS6E5xykEmBS1oXW0)T7q6l=xuH; zU23Dr@gyqE%%wnE6CqV?t=GGSY6(iIl?Sd|ii6r>WNc;)cbUrz@oC7n3y;OF>~t$lclpc z-64|O(M38Gp;{fm*J_8Vg^r1rw=}a}k<0K(sob&_ETLuOX$mUdFe}ts`vbe= zRPU&v^RZ0aa zj}5!>Qj4AMbxyt2Fb|~^vmkA4uq}d?0LB1m@aV$AkuC&7L_I66%wQolW)}i8ZV;b} zT_8;Agl~fvdU&rDiYfchF{28~N;e;!OLMRus6^Gw*^o*U+y_x1YOqE$yIQJZXm*Y}0f)ogVkc3a-Pr+daFy z`uu0#{^oGFe}Dbz-R-OUySHt60MAsZ)B>=R%jum1Pk8rKm(N)F+l~a#)A=BZa`U(h zUidn6X`S3+l!hFL!aH?hI(l)#>5`ah%DAVXKGKx?Dn=x4dT~DjYY{9LaqgrXU*T?| z|E|;#0HDCz35kug3`7cMDup5pGZ>kfaq)&jx3S!}-)bi& z{K>Bu>WRZvxlTu#FG_XHpyc8r-BkDs6LitCgwT()`r7%N=;1I1QHYx7RRqlE;JUl!z5yUugG9nwdbhYKnb9!=7o?Otyj^Rxo9(Q+kdvEuT_V8%)WYTC^kVGimUWWS+^q7=rv8Ag`yWYX8W~zSa zM7`T5g&2G>dZA)+gB<}Zn0DzmS^f2jNVZ(bxv0ba1;9%M)965w*2-||{n*j7UBsin57YNQ9Dn%X z`1<8^cQ>C-Mg}g0M;bPKc{L2%I&RBwR_dk!12;H~1FmtsbdwsAm;XQH7Y!BFzgEypmLWQCNd#Z5ieLl_=zBvBM}?1CJ}*oKw7M{>z1D%>ofb$`zBlYhcvoKx^}UC9ZgwZ z&y|wudjz6Uu^#L;{JEPz5ciVZlXai z&c;u_xcK~wvoF5he)dg$@~kr1>-YH6pXT}43ftAw&6nQ{|K)$j|FV?Fx7(k7XFvRI ze)-dMf7_-RiaK}}Moa{v5Hn?ktD$f*%J6=1W27K}lN=eTFfy^pt9cDxujI8J%r(-m zlvx)lzz|>}rZ$Ys8>W#_3M!)(kYOxTLKg(ZFcL;GA{3yo36YR%>X--#!JC}bJO=Pt zL9E6_iyA4kYrr6jmeP!Mw4J7te4#@!MDqpXD=LE(q#{zp$4aPr+Jma}Wrdhb;SiR2 z!7{X$pR(XqHu1SU@OzZ@qoGmg6KWzgz`;v{G(yk40dAk8ms044+f92Mp3ogV$V#*0h zOd%dnC?dIv81PK+VAS0nN|$~x6>UxCsSmcu^{k;vXt}bN)9VaaFx+{aeuc}0YgYHf z{l*G%req8fsOB%+1uGE3ISKg;wm?k<%;&mB)CC6}@a%5!VYHP?WoI;sk9-=a6nLwS zDNIynYbtHl>0pxG4I$#7WL>HtuB6iyQ5NJBEoG4DKp?tK3bMw@@1dG3OE(jy@yLoh z(t+#9G0luaEGe7;n;HA1s?Mh)a4-=yk=Ee+bKVFTJRlFQF&A|5Zd}}Q*22S3hM{;r z3=wk;>}DV{Rgrm?si`)LvdR|u2UWP@rkYywx@W(fQkcD|rdDwE_ErN#XOX#yh|H~> zRA$qrB4`Q|g|}cAs$7S07zQfro{r2!PjfSyD6BPyj9LRt7Y5J;^xmDyWyato-M!uM75a`dP$v}nBsCGsbcy9t3-hEZWl z>_~uG)7AJ|hX;#Yvc(NyF*-}k0~i^tcB?u=5jj9+%isahY+*yXG3ut!J0>AsV|VD_G-ZZ4ALO&du> zo^w%n!>vLgKx9-HN?>MU1qiGbbJIncn9T>Zz$Il0QBn0xU6@F6B(|aw%}db-6;h6J z4=UhFRFn&mCuu5C<){Ry>ZKoCRSWUJG@w+WK~J-`!|_x$o6Tmo8^)n-w};vO;dj&T z{(isR@UzdhAAdG}`UQV_#kZ5)UW*vc2L7{e>#u%UACBAWclPdD?{B9zolKcoIc9gn zMCb5iX5@nF8o?|jXjqdQ>8@O5B{0OQ=+Zrzbzko`I38;+z+9FO#ph&Lh3@3M zJ-KH!O$jbRYUFc5x&!skT4)X+6?>8bAb`}U3vXL;9{~%U^{~v(QozohkcBO^A@TF% zLDGeiAC)-^hw`7Kl!L=a1eu#0S#-8MWH((4vgv?_I;_j0$uxC=k(Q`DKys)d=Fj?@ z`aMb;9^_hYsEC?9Z*>8Ar&`|cetyWeVp-GuCEQIU8^Pr{JlZi)Ed{$vK0hlLXLNav zs|%cOXsj3t*qdB@Se&3T3ZoWm3W#WGI2`fth?{%6-`m|@4@XQVsCZcL>RK~I;f4oo8g&>gblXWB*GQyf0_3=esURdjl=4Sj-(jwL z>NT+z?Jc=oJtUOCAiFXA{>{77Km7LQ-~XG}2HouV<4-TX{O0QCzqtJRXT#N}Ba!a+`oH{- zch=_f^WoVSXP^I*`s;sL*tU1C?fIYN`495?r|I@~o{vUc$xuv1Nm~qjGBX$(rOo2v zHL#50n}<5pS>Gg4 zHU&eCJynHf^&GXUCql$aj-WLjcLAA` z_VFC!3bW(QpRCW$swn}aY2pM88d)!vh=6L?l?aU@YU&HetZzQA^zQtdh2_d+WwegZ z7xRe(^_21Ive_-Mb0*YRU}(LbmqncbxYOxN;2*zPPR5V76qffL&{utj8NmzK;?p}6 zMu4024j*FptYY*m#@ZK$H~tW~cPD6Thk~dCwti12_icO*XfYINQjirNU60G3$#PdI zLKpAXMEqN*6bhwGCimNv4&S|d#3yR1A&wVZXC(PvV|Aq@zWO zIc{>g%5bNakAOO0CPl~)#!WQ~Nsa#8qiDEu%(Oh_SRcU@TQZ8$L6SpBixIY!>p*qn zQeE4ak|}V!>W4NFndey#5P^h_gK9%32n?3;?nyl_Bu+fkh{-%3l(;jU08D-AVLTe^ z7Y8%)QVlK)s$7RsDpSnf_S^(eh+X?wRAy;MbQfq|?PaRIl1;COC^Dv+sg8F zLWs$Qh+`>SYpG)$#-R+ownf#NiOsXM=K7scmlhGj{G0`Tkv5pk`hR4#D{ zqEfh;Su@dT(k3>$*4C^l5HFQ!TqDHKIin)6VmN?x7-GA>-W zY&Zt$5ShZrSs<4>Zzi)y6H}B(V4C9eU}}_;(=}x&-5Ep*-9d;Mj0&l!rEaY+!Einzv0AbC zotWaxMO95(=%S^$>4wXh81n1S3Z2IH#_T3MsI?$7+<*RRo&%dtQbGmPMci$c-@TPq6 z1%LiEA6vb<-oC%q+v`~l6F~|o5AJ+iQ+<`5aT44~Hd-E6-0GB}jD-^sY@nCL-7Q^7 z;e)s{(~Do|L2v&O)JOOMl*Lh|0oescg~0=lLs7%r=ybCEK@P{$=`=hZilLNRhT)^j zuRnSA^{mJH`-B|Q z#bkRC5G+aQpYPj7ap{F z-fHJ&<7FK)Q0#@gQX}HxG-Y8z5bZ`rZtaaICNxrP(|+jzASjyS{6ZH<)qNo{)K0vG z6ezM)$4ci+G>DLN#1;s)FIS>o1-uI4GfHM7G9qI(Z^Eje3a(jiomZZhK$K9&yDhlC zOyMCWV8ejj2D>d??D+hwT%O~6OPc{q(1u8qG@As>s7#wpoch#of55{YcMo>|fQLsr z93gY&QTtQu(>^kSL?W78cWDFT4g}!rMSBavUmzCn!P|Wutf5@MdS~{6gXm0qH^W)C zkGwfP5RzlujX4|a_JAmgbUn25xOnMN1*rBG73)naluawSE@MkL^drK+C}dnP5VnQ3 z#RdX^$}}=A3T+r0h7lBaob1JWeD~w&yPrSNP+` zXOWmIrz#?1()^2PgGt4;EDQ1T5brGxB!FJiAr>@4QkOwgX!pd{|A{rxjXa2#Y6~y! z=qo0|*xV>iFNbwP^s;pD%(2~*HT8v@h(5#UB+2`(U*VD^`tr6J1Hk09pGK-^hLg2f z=EHt|nCk9ScbjsyE`Rds{iD_swCsxTYdeL zz4}pJ{MfGF&8NLIB9RixjRqJLT1s&aiBT!Kn?k?MjbKTe>37xBR?Mzo3Pud9C4fU{ z0NLuoLm>tYOhci8QCAfoLrLNTD2!piW*}y3Mnb5*8q~zWe}M>E%Mwnn>0ma)Bs6O) zW>%mUI=ueSBqC`xc-cRBOSoBfz7QLn*`Y;?+m}XcVqY0l2?bj7v1b_qqF&rjDXfo8 zA%}`&&nUyvP@ROQ<|r3bE@jK;)k`=HX5e(*NM3-Vv1PR{GDfBK*Guj@l@8Kr#Z&^Ho?z_!I3h%F%b`Lc{xWB;lev90 zH+GR=&dW~w61SK8t5BuFt^V!dk0Hxr!uo|= zb1s#zx6W%A3?X0;v3n_Av4T=dsmztQdRmoAiCg4NjHWg{&R!s>Ere0gYyZ6*U&;OU zvH;z&8^GB`Xz)T6b8b2fxpjpV=K)h6-Jp)!nSlZ?LE~ELz&sF_QhneQGiOg=^J;== zo(@4Ym@92JQ%R{SU!R%tiPK;%#Sj@ngA-^)B(mK`WFgl{%wBV`DYXz6E;WN?lX;TU zQFL}SP8ESSj`|JI$T)Tks9p*r;d*1;7Z1=jr4Gd_b4**CbUwPWr-?w#v;ne+^%rIX zF}MAaS;L3B}$~$qBxcngdZGLR|Xv8kwh{RLGL3TXtW(p18+# z$$aL%2XduH(^f=D;53M0QJ<2y1e8vrW2!`i@o8B7xOW8xWs-D8+746nvRhglL7ozl zJ`ay#IeR9GiplL%kBYiu34csk9pu?W5jKCy1_G?<4+@zRyGA(H0W~OS z7Y&NU6c(x?Zc;%cu$e%c%46ZPElio+mmtXzttG{?W z-n_qlasB?qe*fOkXk-^#Hlvu%7^N9lu4N&9(qZ<})UaCy#2hqssP3evL$a(53F?sh zR&Zabt_T=$h`S^L#jqF$4k`Z!q+W&tA@#BM=J(hlvP{qEhB51=L2&{RbOh|Nq&c$S z0i8@N-N# zkJz~W(Ip^XT7y*)T!(gj62b~PGr19jD}u528Ne7+fr`Y*NUIn?sf15=M1^J`=rGW3 zL%SU=cjd`BF3xB>1n3SXUu;&Cg0a%zu!Tt#hXd~K?e2hkCp-3bnjr#}^fK`vkf6%) zo@8VQtgEPPU8YQlz~YHzpb$TZ75w2fA{RzV45a##e@*#bN*V$lne{VR_!fYO*m~m> zlZBi#fykUsOhN?YUlWy;mIrh1bD@wUWe)=35t6R}CM%4AY+#IpEu#{KO101vrXBaz z70ke~;l*3~{>A*=kH?oU<;~0KNN^*D|YyAiG)1x>1d?Q_~bgU!*!bJaEwCUoX*9u+Q!|iI^tBP{b zq~WJD_lPNJW{!olZ@guZxyhu$A1=p?WPD{tDrogT7ml!SAmN2pL0F)LD7+O>Zes00 z4+eX*Iw}n_507=IrB*7;L#2T+79N>vrBe8l%gxiX@vF}+9}e^F!*qRjc=vF+eK>yi z-NQfp;XVGpdaKFB8{$u-)T0l|$K;)1Ne*&NDfLRL33DkS zRvEQKDe1s|br}4I3^N-lN{Q{jmBsn{>1lbq^j7Csh^~Oc@t< z0)wzTCqi%to=`~^2I#RyMnoBP#CtaMi4rlGHC z=CVyu)<6Kn%Qt+Qt5Y&wVF7Zst`z3FskMR&c?++ooE|3?om&pIHHs#U+yMa0nAs7r z&rFb64OR-z$CJ!WMVkph+1G>DTNL)zbHJi&($R%9jmpoM^8ypULEND{bJ+~u(&ut& zCT%{2Ry0&qW~l3Qm^oj>g}L?$3o}GpjkKr$H*52el*|O=$(;ydxD>9V_q%vCM3%II z2@-lt88CW#OKcW}_xQ$Sh~fpo5tNy!wuO@*0BRx#LsO_!GuJJJQg09n5l{m zZ{On0o9S==Mn3tde)Eg**I$?640ki#>}`LmwB@H~^vTC$#`}G}ekbqW$>DBNohmb! zQ-xVAst;<9gQB&)QS?{DIEo0v0^#?OY0Z#2#Kf!4oq= z*wh0RGb$x0pbq=O{p+f-|#5?u;ek&x=P8 z8D8kY>OCrKT>1Y`)}J*=k~G_a*ikiekBD5Vs=KR~Z@>2fn4yp(93h25q42l!i4P{qf1#Dx^u3 zX`ZjYm-(vH>oQ%H!*!ibIvq`i?=Uvzl|7?IaL66MajpiU@4oh;x7KJ39lN%n3^Dx1l zF{y~r_9#>Y1iNVWIDWCkg3N06;|T}F9C8wxLW_rHr66#E|2#q>;f3ZWr%*=c9oMET z-aN>OcSpa3nTH} zOHt9e%s;-Ef4IF_jQ8i|?tK38@bvZR^6 z%2LM^SDLYKGpgCM&{;MeXOTKOsC#}#1yrP}&LUHR|GX-uBC}vpOjW8oB__3?R7eph zw4%?>;=7p=A|xTfUM|7?Lt-W5MvPt_O$lc4Mwmoylns~CgM!K zs(5`nBT^Xyr-oLELscMGC#^$T5m_?^8zmKP4jhT5NG6{U21W)ekd~P}C3puh2SJcR zoMt1nX;22qB#8Sm!5*fF`}dLaKMZLS581V25O;B-0CJRQ`rx^c@i-&ShiBJ>FD-zK zbDic|s)!J_ESF_DTW{8T zZ_O={dBTqR<&_5WIC7-$c69dMS9XOK6^fWbRQm!^s;W*pQADa@jL&qpAVvuEwZ*&@ z%YNgY9W&0kmQtq}yCdRhqoBch>mBSWG<0i?y=8ifKuf9B`?v;5?yI%cdMDteE24zK zqC&@AMGFsCrlLiPm*<#qHC70TDb*m7W2hO^ar%Y3Gj&Wz6k>BZVqrwj=E# z{n%&fAQg4;lsnc!(nm_xb&CSWKeItt8o;<~9fxQ9I?aYguMnpC0?^HSV4xeIF!O|F z&^_4VLvTU0Y^&$2N1~~sgXvIju9rD|QS+kE(f&h8ZyHj)Z8k^byA%G)iM>WI?YSNUw-a?{_ABf_QUtnuYOm5^ZRoAu{;7F z?)mgU6S=;TSAReK;Sclu{o&L5_UU7LeC!QPP_%fT7<$SQF`85Z(exNdU)6UnsEU0k zA?;6=?hgA9B0096^u}jl07E4b!_-Bt+3@uTm@pKSK>6URz;UXFT5fN!w*I)-`NGRZ z+LJl~QlysY`uO_hcR&2@cmMsv!B`snGG9uaMWxDvk>G5#z}-A`%x2YJ)P~ieo{6fl9^ThoLguLsevF)lf;TiMsFSH zlkRT^r817>)&pptNGzat3{2aZ>R=;5!=xuhioNp-C`3BYhl%wn;UZrDg$zEZ;xx(W zgqxF`j^Ygh4il=vkzH@-7f^ASpdPG_~qX29(cK+nfRO^cNoVq zjRzQ$e?~tI4D_;X+TgiH8@l)=_3NfO)8&M?w&U;{_hmq1zY`_-`YD{&SJ`n%ggQ6z zymx8rp~LV5)cgLXW57xx=B!>(C`EIiY_fEo^CI3|sVJ&&Ruu4dlB1xg%tc!*!XiOFfn{ zdFQmvX>qJg15Bb!$s&~k#zY)Wb|8v?y>CX34)SbZkAw~wo$T-u-Rwx#*v(q&5qYve zP}UK7Ac0kk-O_!?%#QdkkvZM}@U-+k^z`6I1{B{L#LWs1PY;3CKp=|ckZo*}p4?7l z0Fk(kxI5zV;#}NDY`92y?AsNA_ty`bA*n9XJrf~VV$6DQ#f-;w)v_kx}(JDG?sZ!=5Rkc(-mg#s?zPUawW>3rdcwX-x&kyJG z-Q)6~{_*i2|HJ3Q{{!ECd-%<7U;XY6uYUKt+aG_rI^CAm?dzxImw)$Yy`Sd!_T~KT zKh)p;4^wIU^2UDo=l=dL?aRBiJb|ODkkM1RBUJ|`nIq*j+n_2?iZ}A`jvi3&=pix{ zse&Rh6;zc{5E&nW>XW-Gd`yK8#nl7q33UQGG&_`9^xP*5wR-ixc+#^ftiuTF(iHB* zP^IU)wlXgp2+N-&wozmhGe|9{QBR-NpwR!U6Xq*9ywq=gkgHejVuh2Mmz7<$vrG66 z&~C;!RE=xO(Jv!4+y*;$UotYgnc&0AJ2zq>U2oYMoqGtF;>mHKN)dYQ zw`1-QbcJYP0D+)u@-gx?!>GyD&|tf5jfafq*X_lrQGLQv8dAT>zX_QnG#~gl_=tq- z^rY^VRdWIQJo=e0_jjf%ZiIK@i~AiY+rmf;=K(`a#A!~Q;ji}^sDKW;O_SWkVfVKV z9gJt%Aitz{9ZDtz2l8ALhFCs(+s;Xm{nKP`97OOd8s$a15se8lWl}9toGB>kg$WV2 z;h1sj=^Nc!RK_98-=sn(O(b{mVei~gwiwvZe z^&XY#)P~kdsFt~u5}Ft$H0!-zo*M7r^_jMMG+h0bMFLc&VwXKtClaYVOjD8Vj+mNI z$ZkeLrD!dpwUnvOla^`}^@W9a^@XkHWu!{>o;ySA!inygdCp7%sVGKr1JYWa_J&fl z9uD(-P^~VpMy~7ma_)UyVjwFGq-L+4jKk5oKt$E(31U$oV+4!nRHx%~EY&qRyLdN@ z?%uey)kWQFl^Bhp(%$<0>8z{X;pJp{i60<0%VyaG&ponxj{ej-0DhcK#^1lRy(jy0i zTLK8_y^0%A#X3rZc9F()l`@G=%1La37s6;Y>#9>JjaEfh>s39qXKkWI*i8$1(Ne02 zN^OQ;-q~M&dH7%cXZ!Yh{Pypze*OFT>bvp?emL{)v!UqqwY+#a{rV5n{hfXK#7|%P zicscSNN?KH0MZ0?LjlPUU|ZAtivtwwwm!o$f!ZA<1%l5_AHtxq*TH%O?Sf2f zq^Aj|3}~iBh=EpZ)v8mOZjZWjTrT!>v2|(dx?GkbQcBfYj?=e)_}!1c|6O~0{QUXz zn-3pe&T#_b+66t|a`B zLBNn5C~o2ab?1yS)?c7Hs6%YrC7#8iw(r9-0oh4%oVU37$gQAowdjJIb^-w)VvjtI zl4fKR1B@Kf7_+fq)M%^dMSv+xOw>Wmh27kK3NnobJLuzY_cocicH8{2+FJWKxe`<$*{SXZtg6xwbDkxdTqM7AU6csorP6e|{ z1?DPOiWe%ALIno7fS>O1=8k{*YyZlK`ye3xFt!t4)Cp1Ew(U3j!5vnHMo9;$R7)7GA(W@+6sj2sArxP;) zIumjy!rPP=A)K(@^H7mg z%m+E#$b2ieKj844)NeqOr#Jlawp9m(XW+Sqj07ObnJ~2{oDmz~ zato@_vH*lYd%v$Jvi}7Fehh1jk($?c9|e$NsfV$V`JUJ2b||`iLxBj;1@_r2!}h5g zje~)m_XuL(CK=yvSj8`%;MzG4+h1bX(EL`im=C)f^Y2WyrP|Zx$&>62TiyeAj}qoN z76G!$z?SEGop^w|&)r3w^2Ro#ltwe2H`3a7&&cr^0x(;#FrQ;v?VVwa3SDkabkr<4 z6!D5~L6KU#1`VYMyb({$&LjYP6sNS-&|qU8GrjM0Dz@DcdbTCP%z|q4G2z(~H)(Ho ztT8|j0B`l3nbKoh<966%8w_j-az#{&)LF|U?nTQis${pmf+l_K%cXhEf}1<*Lizxe zz>Xv4nS&29aK&Wb+r4$;> z8m*&8w*qI5;s}YK4=2@BN(r@LJyS(6PKPr$2oQo%dg%mTR`C$XXzjC9k4$3?02LaA z8CKr;W6xi-MK6Q$V4`ZLME7stfn(qv`kjCpvY8cs`a!I@89;1@0S1gKRtc- zef{zGhwpzs{q$}5+W7tfkDU~pj(Yuj{AA_sp?>*vxc|~0&eqoEZKh^L&QD|lj)70< zRoKH!#@1N~2;doH%piipp#dIt*2Xns7$u7Bk|Q800gS~$nzoACfH4Y?CI+F<#N?o5 zn#%QcSvxKlyIj`u(ptl^sGLiwbvj-hzP3>FAKv$V7F9Q8 zB89`Sj*F@rB z8OJmKIpCDv>L|Nr>?)G_-^`VN=_mo+F`c0zvVV!}lZho8#4rJ;Ux%ueP^~Eb?pN~q zTX}VZ>lt%RcZ%^OMNkT)!XRtM<%0V=e7(23dpthc!=pScXp4B8SEDk8V<^d8XwCcQ zB<5NpAGN~e=dC)7zIbuyMOdOEYi z^EIO2FC9jpx+OR1g0ue-mSNW&3%+ z8_j^7LLiy4YHu^y7@27sD7oOi+png>?WO30248zSa2?cN3t}AYdL?bswhQ7S1ICUs4`7jwaimh z)l%zeD#vMld41jbtH<;5@OXZBSRc;k_ivWJ{N?W7{F~2LN51~%_`BbI^PAtl`svrV zKm7Xkc#zA(`u@}T&wu`0l(#p>SKnRz;r}xI?+z1JetpXif9@ZD=GXUqT}Z;aB*4_U z3D^74I6a#_YKexvKFOsNPDP5cC~Ac&Yk_*$MNv;IM`#rxWJbM(TH54HGLDD>!A9-S z77gwSKofv!XbmiGiDTJZqO#CFf@tZ6dFedifwl?tfcaJq{@+WfFEM{7xN>CV=$EE=1uP}uv4>}L6)u8T^_XHK{A+& z;gyWN3HxRlmns93=QYI}$v`VRj_!wz~+qwXpFHb339k!NJzNRyN6@oECmB)LzV0dmoZeR01{q0G2&?PScJjQ00Jt~;T_qu z>O7+qb+SVWgl5fsY_7{{kJeVRHF|$AR}R2P-45|oFuVQxXIf7&kT|LVXoF=$>faz2 zX;SJm&8nWArm96$g+lAbwOuZ>MpsCr&(fA`xE;V*hKP7PzpIMVFJ@nL&WTw_Ec7frzMRE%S6WO$U+M z>DegDa=y2<8C&!L+j;3oz8@B`l^nySB3h+ZN<>DTrD?P*)mz)R+9AXBaW;D7h(v1% zUtQ}SmQ4m8h{*6}$3(?DTnNNbTDHST*-yZZ=PJ-|!pYWTB6hDBA2P6csxHVzR1EzT z=X%&FS8eGeFjhNmnEYEmcv#3df=N7$>un!}j8w-T+`U9e^Zuwb5qQO_D4p7%K*1u? z9W0tRBcn3XR5co%(g#kdn8%iSv;v_wL+y_PUyZgicf>*B(-~I0R{SQ^Hs&UjOvdzkED@y}SSP<Y_4QftcRZoC}`Om(|1M%?q@Ll zhX5nP_m$xUOAfUS3Xc#m3F6M%1yKYV0kS|(uBf7|DfgR<^u;1GU1aR$4p9!(kjf|j zdPX({49U4a;o619(tY(vQn%@wWjfo2%!(96p+UN#3V!=i9#+;OlUKdu#MG6zG`V}k z*L%CW!`FM>KlZ05tc!F*H?JTJLmm&~InDV%p)v;$(b)b?_hhS>l#Wr5vOOkJzBZ&L zN!1p|75i5rFaRQ2v}xbexHA0zohEkf%lY}|WQVITBAIy~D6hK#H&M)zU{Vm7-_gWW zMG&bR{@bFMg-4NDSQV4tROME1qcT-Y0D%?wdd8cN_RoK@fBN&~Pe1q1U-9K@yMJ1j zRbiD>%r>2Hd{w5CPDeRh)%i-Nlj5pF0>C`x-JT!4P1BoiXqW z(a0xAM{fM+_)6gq+7z4P7s&ms)C(s->#FyuSM8`l^4+rML6t{BXY9 zKP*p=%lkLyH^2P+|NWo-GN1VR+tVL@``zFF!|PxF`ug>+kEC7B{N>&9*T1Zj^2N); zt8eGO|G&-ueZl32$~ z3N4t9C@07SD0GJ2KwFSWOqmaL)yXF8f=)J&QUI7#qINX#+!|pY6)rh+LYQtcOd>t5 zOjfQ~Z>78}$8Tl+L8k9TD`b)WfTyo~|4;qtzMmgxi>m1LLBIX3V3Nl>t1c>=CpbcB z2rGjviY6N~5!uIs`G{)M?VN0{->$lIx}G2;!eF#!QsWPM~bd;gRWn1j^pDHD%SOF^kpCNGJP-dUuvTWf83Lhr`a=xpbZtN{=L ztZ;<#glU)}g-%9I_dbaS1Z?YyK+=_JQPC>%RLiV&5-nPa7_6_>TVIzx^fa3{wE`gU z!J|;XbOh(bvRw;Cvppkd);&?kNQi2grdm&0W~rr!Pi8}JYwv4YR_l$uSup0^tV_vW z368$FwV6^VNMqOd^!S7_O{G*VQ>nG6LZq+D<^0gvrMG5%_0X>F!hdA5(F_7Kiws2( zky7h)QuVPH1>ynd-rB{id8>3!5Cns;6$n~yo(dsaicaOEbr!9vD#Y5CetD$D-22E( z@X7{n$kL5w&{DEE%QJ<9G;8a!dOm72C{=|Gi4l>i5fq{KW_K~u5%HCIfB5v}&6h9lF3Y{4i<;DIb#00O>#2X^(b8@)t1?`=yv>e8icBJbh^VPQ zOstL}{q;fX(CO3Qgcaxlp}X%AoxG`ZbxaS)GfFMQ2C3qwFRn6nEY>{S(jYJL( z>(Ipp_{jq*MFt7tgz9#-+#n8!;)SiAY|?$oOi4MbmgQvBvh?wXuCmX60WzCQBnSAMwK-p zE19m#G^0#DP%G{~MB2m%ny~K^`<(%ao?70$mQk&ny*xV51kHMjJX&krn&s>j>y5O| z4r|?dx8}V^TW>yxC38Kjv#(~&%#6)Dk@-J!~{Rq z6&NF%B|7bSjK-`-)-f#@pQ!QaUSc%x5;OlL&kRQ*f2a-`iEN0LYK$Z_5U~f5m|lky zA0D%Xs7RJ825Drff}&b`Eh1HFt)*(IT5G9}bPp5e>2P&DfAi8VmwrAk=gax={B&NI zzx;9e<3IiKum9g~Z?C6sUmt$_>Gr3eUjFvCx8MJGti>K5`ak{W2kFbP%f?A|Z&Tql}Wjg$2 zjt}SG%6+~moo5*rk+5gV^bwG988$72C`H-b&_O!?jcQNJ1$YT#7|mxlm`#{4*pcmV z21@64s6R6mm*Or+NOS8lZarP@ z@%-Zf;))3?Fn;Lk8E|bEG~J$c|9zVKfD`*)Z(qcq39wt*ydAh=NMbB6<(R&8Bal9B{%8V#P!)T205b7c23}}E4?Ccuy!LW zgc;As)!rK8`8s&%jjBi~K3AnoQZSB&#cTi|v5}JZ(zC_{MGXvv5AVRP5 zOpx_FbVW%9O+pWNvy%=)7O7~}TD4B9vpSkU(A#~vL>!?Tt+_q$OvFeB3C~t(z1ski zF&YE|&A1Zl-9cFq(K^*ytEWgnz1YI6ub0cxSFb^}-lAjtXQ8YA~Crld6Op2Ci zD)k^*Ai~~zU;5gjhBW;FUm)vUte0t$QUnF!S(x2=V_%0S?rjLIbrPsWOA#LQ{25&zubwm4gZ&EMZKHX+51DB08twqM(VIi2s*KALiwO z26WLX(#UE-i&T}WTEQx|LaS+&QdK6fYN_BPs6Y`^DKo4ZCM~2lA8AD(Q9(!TSWB&) zqN-KJP|Wn9&0L+pBMXf4F&FSLNp?yT2m@(_DV_J$`syA0GMnYx#O- zcaP>eL2B_7f0{FNei)-q+UU^`4m(F0XR6I$Op*}U+qFmx4B`r!6}<_-9UzFR%h1DCY*)Gevwsu*Tg+*$ub1C!7n_s=S{i*$Uc|6~J z`TFVp{_XkcW9v^^p|waUphOzTma8=6$*p9z2uuXC2RXq8rm@`8J{*gXvNP-Xn*bej zotgC`!mopWoQeAMpN*e7fs* zk9b<0sw9-Pir$vfL5?Rqo%C>B=95eZoenb1J}GOQjr`O9>)6_Mdl`kNjT+1d4+La7 zX(WBLtYGf6W>^RDVb*%@W~(t8s3n8ZTXUwPclOr1lOi1!9X^;dSv-W`)iOOgrgrw^ z&OF2rGv^}TQ+9=$NWHOf5b2M}{zsu0OQ@j~UvDKFbi5=`P1in>JFo%R%@R8&mdoq% z#UQ+Ztj*qZ!f=GPCP|jz8%6}iDBiZsA~fgd$CrW0eNYTAYNz$?weY(^seX0!@p~wK zUflN%5pZs3JO6C(;~!%o^BU|9``xRtI4JG_#X+Mgv0y1uiWF_CqNPZI)>^AKFH}`6 zD&;s$$1?His+%pXJzbXbQ~pN z>#Ih)f9#+C`Cj^ZnCr`LPcMEmfB(NPMX`Lfhc|e5YxnPL`P}RgYFZ$3+WsO^3hIPf zq|{(NN(Bnaj548IK@PwK0GOyZL=)9;GJKY!7`Ot2bfCc&uLzd{pbH96JsE`J=s9OV zqEL>|8`N7VFHpWI`Vx98s=!5-&;0ry`}vElPq2?egwA9a!-(X3(VIC+a>`ScjkZKVt4N14!MW%xfBC@`x~Mc zJ%%z!tPyA=Pw@6zcpK88aY4*cf z6Y$BlV?b7{gkYq8gm@SwfnlysAKNfiJRn7Au#h(iPbfAfOoOWWz=fi!)w>=HtO+V2&L3AAIfwnbt+N;wzjTMUwiBHya`Wm5SKUS=#;RGKQ|auwH}Uj znzc+2A$wbvcD}Rb{6Y_3Dg`kn)>m83*4DU5m=`R#fH?^$kTT6u zJduQ6(d+0*cBLlhImWgYl_KIrJRPJL^bV&3vHC7QE<6HjwqBx@S)QNNqMpcZ;W2oJ z9l>acB5*vbX&D%)HbdD+ky>D2Z)QER6h&Z6Sqwid)fpwjxWm&JW{1PQOC1hwsukm( zo7NnZ{V+{PrMM~o1hw3$%rt5nDjR;~(CZwYvTsBXwHtwsC}6dWb%lD3msxy}?M#6u zUWr8Vn)=z@_F7~RA3L(AK#FPyM!JVWq(~u(un0S(0u7?-^Iv2VDMc!^m=@J)R#dC9 zQmSduKB-QnR8XbNds=W}15V1k>2zWkxIAM3-TefVOZKilKEbu&+gh8oOUTX6d~)Vp-bdsbALR{HT1=Qc9WXbhy3x@%HAY&VTWA{`_$N@ynNYPY<8^`3yCg zv=kp2<=APQ5r8A91w*jHA>qg~P1FX67obPBw`XPzB4=F0=e)=Uu;58}CSG`?iqHn` z)5LM{97(aL@D!SO{$J}4H*Y06N1UXbg38qD=53sO&@{j5Fz_mLUe|?}xRH+hO!PG&7=`h?c%A>)J}u|L{+E z{{@GRsX&UjtDl`X!}at&{*sDLh8as4b*5;97adRsaPo}`s(J*2B<|1Or76|n#ilZI zLD79mGFakYk@4)Wm+|_|6$UuQ%DNHQvUwzvbKnImE-cj1kyIp@j+kOgY(2vunM&w= zfQzyMN5xSv31-2m=nKV-U{=f;B+n!8<%~B!+b^H(%?Eq?5$`{?_h0SvS9>_irNKl{ zP!3XW%GH$~PEwDUk1`!`IAT8NG<&l~$iP4h*~MV?M$_cZ99{<+dzQLbW8`;quoMOCCxtrkv0M`fYyy-?8t7V zzOe@J=_F&a=yUruIFB$p3K7jA)3OuI;fM+1aQLZEO{)~Hy`L7<%mMLE?GZ-NIBqFS z!K&^sIY@dtCV0?>!yOA=(RI_-O(HsXba;`rV`W%36QM=kD}An*NW2Q5iAjN1`!XO> z81P0>7C2sk6CZx&hr7VE_h1C^EUSrK=a{*aBt{J%Dg??$*^e{PM2d5ag;Jo>L?EhK zr54eOGL@-nEmEqK({wo2BTsCv`m(O)<$S)JFYD$0?(&bHKL4lx^yTXCm+xL2fBg0J zuYdpY$KSko{r&0Y#g&SjAKT~u@&&e@uItO!*DwFY^!@*+Q$f48yT9Pmf93fX@GG%G zMasmf%H*tKflQvP&oTisWP+Zc2hg>_1l&LxqVo%(6m)nsqbGWxvCfk9Um_h^phdXC zI!tH+IpT1G`cm{8lpmnqidNtW_QdlSp8u(@_v8g=(5i=84lnew%Huu${FnCj-NP@x zJiPsI`FwYIxb&qpQRsyEczAKG^E@2{HH<^pfOL3shf>f6Y~H!@84%oH>39>;9&cfX z&EwvVDIdOZP$x1z{CO=zB8mhF zd-enZ>%r&ml1sePP2^DrWA9%vMsY|3MjXOA`f8y#m_cTLL@$5b1y^_c6P)oNo)w{D zKoKShLhQ<^(z6Qu5!c+aJF*v1M&QQHzy|eFdL{G5JqE7TyU#_u*vzb~#wFK@M(4nRv_Bih^2bkRV6Z$OR1(K>+jzLAQ{cB&s^|pOP{vW0u@KpxJ3hrqQVDKPZDw#(D9uI7X9UG)Z96nYa-Uxwa|&3jmBt*5IOT4$GYv%X#)+vQ?? z_3kej`c)_+Ih#?PE2?EWOm&79fb6|p9{ak0J%@a$0@Na+F?Ys0qm+ZkoXOrU7g!rW zCa?v--q|TdwPtUQ-svn%1ht2fuqd!9Me3wFQQ&pLxd;*#IfV`&t%ZuLk>;Djo?Wsu zS3b&y{5|jxT~TNS8N5<7>J~s?X;)GSB3X}}%Gzo9WHv%V^OOLHu>7Vqo)yK64;e~< z8*iFNH8wAPn-Ya|ZBiQz<-Sw$n?oI{DRSBV*6hWqI*~y{2KdOK-JBjK+79O4dd$~A zJ~cJ%Re^dyK63lKTdk;3%&TCUbd{n$rc}LodLcwu1YNb5R7>bvrIu3x{l`v-e`F!MAQb-<{xmVR=*_!{n) zM`$e=OmM)jEpQUU!6c!-@6OhnR<`XJKT3e43W{WAS?pjO|G2n7h%$_0C!s}EQAOvu zT%FJx&lg^nwzSsHm*rfv)@iEK;pX(i?afa={@B*#;r{E#uXpe6A3iQmchFU;N~s=F zH|uH1nNlnp+Q=$hBrhUBSXX2csDxj?9XV>kFa`{BD760**%LtMMnpv;qKCq4LPu*V zN+WWtMed6v>l&CxeWsbayh+hS`H`=`j)REZlOhk6W^g1io1Z3bFJz3TO&pu>HILLF z!w`GAgLpa;CqkbD8jMxgQt1(?tA-9yZ%qj?LFt$pYVti4&}iPw#M!e*4D{x?+e{f> z9T^}sN=U}$#ctBNmb-Gf-F2C5Pq${AZd${5~8~| zN=#CTd3cb)@AxOv86PBh>o2cKMOlH$$YJcD?MDx;V9El>g0{iQ1X@r zwIPTw*57f1p3M<8EIrCf(AYPQgVqe4Z0xJCF$S+RCp(PIdh2Gr`zRJ`y&HTK zZSP*L;v9!rBP<$xghwCPtox)9Av+0L_gXQF^o1U(H{C46>G!4Iq{)%7?^UGUi-=NW z3QQmgBZA{dnLrdgu)lzFM)2#D+xW1Z* zo7URW*L7VlPs`=0y?eX9efQzt|F7?_PWAR>{o$w6Z+`RY_rHJj({HYi*IXMve`+6o zITvZy*Ym3%PcQz3e)~TF4eLif{=%nU&^{1n10hAFN|{(EsRz+Y%pwJPML9ucKp?Z| zD_{X69it9(fdL(8KnGT!0nM2@_6D0_H$;_k!u*ZuYv^~>6GVY0>33XyvCC(+JG2W# zFc-aow1WFb`ShOe-d*0je|-Dy;lrop;gPMGb*MnA+*}=|dYb1_icD3kh>_Y`jqR2qL%%228O()P%tPfilkC2?G8@UZO4E7zFY9T(9epn)MxOsy$l1y)r zaVLp8cFVin9{wg2xldLJC?~sR8J~+p|CzWFX$9NZN>@s$?+44B{fPV$Ikt*};&M_o z%Cp?Vi7*th0dA<+tn+8yVq7-UA_oUBB0sTn!=D-HF9;jYl(wXao-tMfo^2RuureWd zWF}=eVDes;qD4v-^;u35c7=eo-j{8nx{yp2m3WzBrN_C6O|S$ti6CwJ9isiW-$CH1 zI1OfL5u9rny`R0&L-&zTY-^@S8WEjEs)$Y zm*t@^3mNn0{YLZ5r=*klFfqaw;c-JsEz@y69jfCRTJOucJoUEp9_6+hybT-|2`IES zro2Lm&WFR*)ihs0#M-4VPi?)}y7+(?$T-px_edX6LE`El`V55L8zY}M zZZkE&fc&XNLBt_)xbAvGyi>-e*po~5hmmv^n4YY=U-Dd`q+ksLx9Rb8iPHBAwfp>5 zY4B9|tg2)Qt%qf@f!aE~Ajs(`YKh*8w5r%ei_ zKswk&JRztEn+H?Lrc%U|QXxfDizrJmsj8D%QK>WsR|o1!Jr*%qhs+d-sGE=PR-uVC z>r@euR!}O+1SvX|I@dbQbq`G}iWhnGKIz4-Bnlioe_^Sv2y znDy7+ReUc`7yJCx?(XbzF`A)7Y$-g?ZoYuj@Uk(i{XiI@vv-+wj3v&VHQw^ZGH7m+ z>6^nd-a1=U3An>i*B7c3tWzzXg!F4TH*U}BkYUjn4<#IV+E@v&JPV+R) zbG>=}{ma)s{9#=mAMQVY`tt7n{{6B%bS^m>G1z1m5gWyEG9DPnp>}S_nY8AR+|<;_ zSM1JDWMnz3XfTap2Q#mb5qdYIkpW3isVZzDYEmLe!kgte4zcbep68m7Q9x31EbE5< zwv(SWJejCGDUrA04qPIF03X-|aK^rDBv0ak&8G?LD`{(Nz&EBp6aH53*lyDDofn%24T^{onYDvRa3wKrTABUV6TKsSOC@&j z%c(9Ap>7!*L^hukBvBEsOm+h!egmf+6E-3XcE`|JFHvzR)96|nHddA5!R@41qPztt zKq;Joqu?Z%3r>P-)hpqR%3LtTeen_aaKT^S;LS(-`7_>s#fOjl@__pXTUWH^+eV#K zU&{4Kr(>C}WWJK=O6pN+MJZAWw$7QrHf1)3*CjtcdXXa7kkPW#nz0id>_YFY(ZS~J z%MCt?rJ1chN!Dz&wzh7)H)Hn}>D^kV4_@h>`{AUBMKQAX05fBZ((}aCsJ#uP7BXox z2oto7w6%@C+u)fWH)glP!KiC=j6zUAF)>FZN5R2~DFNv67F+JTng0Zgq!0f@5)z&4 zWp9^5J4bP6VrQm*kwC&34wA;~ZyYU4JdM|%HUXuNqx0Nk7(j`n3+G5;tZmo~k5;D5 z4cBsH`$?+LKo(Km=T460XNT-?Bq8p-45N66DL*`Z1$ck2ow7jKhAqOs#fHO}P$E^) zlj@^Bte_}rVVz`}N}be8VmvopuI7`TPPk$3y)EssF3Ym4m;UbO_RC+t{D=Sf{`%_Y z@4lIT{reZc|NC!#{kyAgzdal#w$(oUxxN2qn~%J@nXbR9xBo}^?*9&3&_3Jc4VJgU z2f{=ODS$#IY84zo@9{zK0K5b)h{8Ohr+c^=iMFt=nG~3TNs6KzFyBIMMP?BJ9En>Xh-Zyw*hzkIsu4`*A=NUK%nqF2{i3QAQ^B=I)1 zH1^K5TQ>@Nbrh97vxZ#|ARTlW`{%L&1c~FMIpQ3iMOfeerqXmAgTWnu*{*6k zwA))~POnH9IELq+*f<22Tchg@FuJKmrg6mE5}CxUKEKF#&_SLg=3yGoVTIjBasQb+ zy!yA3MZmFKV~rR|irdEyQ=Z6nyTG0^c?RHj4-*yO-t9kbSzO%Q<<;?+!Hkf=$kFh* zncTD!Gwd^55GQ#9Po`hywt4ueTb1u{cM`gX@!j;{a04U^(^$V;kg;TO(UG<@pzMBu z0uM2n9hEBTjYx~6dKD71-VJu~Y>s@`aXpURaD^Sp{MjRr(ub4|$sLGrJ3~W}4$6ma zJBo;UlhjfRpq8bA>?^J9TB0Iz<6WLJTOn|mZTi%Ikg}JYoZCO|@ZAuTYg9_DwU#n_ z;|ajIjx#Tdt=(<2*C_}rmNXaJGIc+KuD(7|z|uTQAx@{vN3DlaXZjXcH*0OZXKTG( zykIp;Eky*8f;ci~^MndKtFaqRq(~`sI@IY@r-MMcHS6nodFuUQeT{i>=>>-l@dM|Z z=a|Jv71e1zUQdT>k%QOGH=n*FqGn`Uvgny9 z`rFY>LChn>JjUCt=aCPOC6Ch1=mdM1x3D)(h~MmRf9ej&ejJ0CNL9XR+PI%oM?rVH zS_+)9-D0YV&D-6kC~PP-DcVhmkB=IW2HnI%2=vTL$jbk?W3P{q;65-kFi8`XR8`g; zk;23b#l#HKsTI-zMX}zyYJm^1lMXF~+C_~;wHsHpYC4e$%nAxjdQQjiehvBKH%!x)2km}T>o&= z>)MUyFC7F_Uf$N5S6I&W^uY7QF6YJ;#kNthVP;mm!Gw@dg3L}30CWE7$o0r}2C=_J zfl$Ru(}G;%Xt**ASXN(Vu7}XALPQLQ;J~RZk*xu?5h6yCssbobicC{I9;KUImcE?( zx-RSEdU;SOWtwW4%5-@3@^`Pk`F-n84-a4N?%sd>`tkAcbHiB+wR$n8TG;M9qZlY_ zNNIrhaBK6FAX$*G%+HcCQ&tDFkc@uf!vCJ zcJB-gB5|9=)u5OtL`RE7BBnk6u@xuxOey8EH|b`Sk*9xVni#6~8?Poig$}G#^1g6;ugM zWCE`QSBe8rRgQv}1y_orVvd{+c>>=($(uX-`GdXvig%yz_A@?y<-;Q{O+3FrD$2y^ zT8;-yr!pU9x|ZpLdJvt&V;|0H>OfwE7$RvZI5!S9?F=;;)vKs#g?xdO1 z(b=2X+P#>iJ62k)FU`93*819e^J%nR!?H#ViugW;e!-IgpQ~uO}dsJBfkWAxn^-;8ha1EM+5=ASLEqEn+9FLy>2pR>M zToP)BL?+E+9Ha!et}LC*@PR9qv<1DS#i<|53=bl{^BUnGN30vV=aPCX&^ts$D?GR% z?UC-34qmE42DsYRYD}FE$0-qeBzkQFaT4K3hC(-2#J483S2mJLPr$io_rMZ~Mp1F% z09Sb$9+>BUssbyuuoNlHrlr@aRT~5-MO5ot=eg8*DyMm_R|oK__1=14m$s~ZIk)wp z{q@WG^B>>++kgGx_O|@+-Ql;tz5UG}Uj6#_S3mqHT5VbR`RDfG&pce=>N~yuv0VQe zw*tF}J;EL#XULgoBBUH3*NlNo6YvdWbqIq!f#@UYqyQ7N0u$s2)EGX6Ct1Is|H;-n z@?P{JwP6^9JT7;=W7-by)&3S&Fml66>xc~XMk9?{vqQn83 z!xO~d-@`!V{Q>!0JGec}co1d&yF>unntKgx_PUD*O3=92TAz{3G*j=^!EANtNP09V zCXyM&_$b+a677i7|H$L+IZfjou1|)-L#_#WItl?URig? z^HZ(2-d5U^^(B@FJ>_`xB@sFsft~_ysUIA)S%x3zQ(nsPP|B=j_L?bctu2qOU(jQO zz4^2?*qG1i_iI25b0zar82KFnXsPqD&ZjybRSIos>#{t3WnX$*Iy>neiCcGN+^rx+ z5&N9`?(m@0Lp@yA!*$VO?b6n>wa>OJy*I$CcoOSLlAr8|n~^e|%5>6c76|*&mdD-~ zww2y992rbQ(tD8<3zVrGZ*-aga$VN*6Rm}>6uivnQcN_XXgQp;%oJE#?7Y$%V>;vj z^$Z7HiWBtYcq2S0o+wN5I?VDv9ZqRmu z6Prl*cwoFQY49A!KCD1eJHGgQwmhNXc4lK-C|}@AVX}M7^%FaCQ3+>|oYD{qLlvdeac#D^Zm+(X){4smyV;@2;ac%hTPx?YJw5jGxvy)7GFoYOO`&#oM#dib36j)f zvCtx9@pd^NL6vBS9D?roTszYP7?lGU$k=Az(czWC+pmV{4oD56u!I811PA(D>N?k} zQ*8~c*|Kn5+p;c~v+$vod76*YVSaV{)2o+1wRU-Yy8rU^!XXuE>0;py)Fuvs0XJY@L4VNj`Rxa zC`H|GGfy6Hj>+ZUlJ($_>`b_DU9n-KJQR}sfd|sV?)SM@!}asEjPQn8ARA9G z?U+c6{v9@x?r};gn8?(iN!)K{^wNu%1F@C3Nr#ZgIc_o3K^V-%uDm7o7ukZ;re8syj_UXYM zo@`yzx}b;_)>)3%a=60bN~SAJSEU|MXO!84M>~bNpy-VOlzwV88%mvyS064W={52!NUdc_;&?{)z;DrO9!L2WY!+w_H-G?#js>O9pt={#3Go&Z{7 zU;DDO%cWf|>-|T5eb@fWKYp6#FRxzeuf9M2?(bgy_8)G3`u+6HYm{P_uXz8i$<$BZ zm8&1i@dxNE=-?t~V25 z1hU(rhciSqAhkcMnYEQ98S~=8Y$UxzLAq55631>}2o)j+fni4ARUDfmv-xcCf{<3?~Z~k{fts)Epu=~ zfc4(ib2o3j@4$uq?Gwsrc-zN7+Qs@}-Lf1)D9E8gGRznhptmyZ z07UD2ysqX8ELYrEW8U(p(U zn3qeejO~EHtT&RXMds5qolt6D*M5FrUui3IIfsV^KxpV>5uJ}^I)Y;B+1iEHGXHto z4(S98rHGH45wI=n-G`~g^+*q!gv^lMk&1{yDp_K*URM!ORb??MsG^lpzy?lLy1>vtmd+y9d*d`swOnbr zlIesx+dNs-4z#t|`N{gi)?nS4K@cKTsVb^k#0w0?yI`nApOPX)r_({qY^m+iKK<9P zU;cbo4|4s@@#Xj5-2Qm;>YGWf^wMp)umQ)ahi^;oc7C$Qr+#^AY~3?50=h>Y>sV3( zCcZLG5K*`*ARSn9+m0b?qmhJ#_lw98cm`)A-{J$MO7;U+jN}9cT7m9;=_JRBOp0l) z$17!LYwdEeWogUW&R?G{sI}JlIM2u1>+fFNzW!DJho`6ehlkH!?>^i;zF*G|;HtBh zQbe6&AZ<(0*bq+Ag&~NtNoo2s2=CSpd;D`NQhqMF0R>bxBTg|;;Q4Aonle(DNYFh0 zu7gDHMnP-C;~AIOAV-wj6CBePTy5E4rRXU*Iw=28K$JWX6A`xVc|9}+OWgjKhO2DY>-bULEp9zJ9BAWSZnz%x;gCOFA zoWcNLBk9N&M5C*$V|Qd1(ip;%8d)Mj6#c2=03>haZb3=5U*mg+X7s$!oJ8vjz!Jkq zspKp;fTtp}U{+iec~#_Aq;}^8Hu5{ls@)?ZX|Po_JZ%eE%j+ z2fcbBhoekaayaRHl6u56qfVj|B)usS*hs2(=hz#;dqT4}5GT?f^>hKU1Kys#p|^p>%!S;`QN@MZyi9dl1o#MetA7Kj`TU_n|+9B7GESE!&X0c++b2CJ2`Y1O%|^>M9J)mf)Q zo#r}ETIRVN4~JVG#f5t1y7tStJw2{pKeVrJ&;R*9p0BUoe*gOLyWijb?jLS`{SSw4 zex>zVt#kPkFF#{`!TGh+8<8uBWydxzm@ZLNkQtDI@E#c7v{1yd0#Crb)K8GF!aMd4 z^5w04`Cy;l+1HP}f8fKDoflhS5IG$6a8pphB7ihAAC%O{#aidm*_*Z1db72$t!&Nu ziq_%8M<=?8v)NK3Avp}RYgmC7DjHF$CZdd30z^W8AbGSAsTvZKPSGZ3g6%qZ{uNRu zagu}IN-!Vxc|h9@_N6)692*X+!f{>M@aRC}fM~VFjMt@;mnLOPD;sfuAUP0+ZFiE8_h&Q-k}<1(+=+2--n|r)1YohPsgY#d2ZIFO z)^N9X`&T2Qta$z)=~QgcKb(gck&w*E&VO-!LDDgx1g4M#?I|?-ZMDD&;%nk5*_s+! zLRdrLoAnmH4uR3>fPfgsqiq`+mNImv4Eqvcrg0-OP~D~<6PKk{jQe)6Q3qzuT*XX+ zKyHKHXpv9ptu*T15{BttW_Zz1VV&VYIvCHi3sMJ^5mmI)mie z+-D6u1M#7ENPO<#%!aqYEqF6j*7*3)NTIrhs0Xb}W})ZKzN8qYn#Fea zd;Q>%@w`!_Im|@>lCbd8id2h?{PnySxVhauSS-@GCk{4gSQ_0iJI+)4?|s6oNN~_X z?=cxvhyVrFO^f?ykOHFnI2aV^0yF5O5NL-KK~=q)%8U8%;&6DOdQ+zx)dN)5zO4PB zJuJ=E)>gCCtcj-)p+LktiHNFcFIrd%qKlU2#d~KZg{=wZMdwMUX+F%>+OnK4zdU~W z%l+{<9k1pWKVIMd>ebB;$HSFgdOx4L0N1L=S7p7OmWOtEYHexF2+)DM!^@bG?p+F? zLDWW}CO|I*H8FY(%myL`5fYoPK66^&M|)^~0FdNCO6v3CKc;D-XW7a@hP}tmR7Sy6 zmr3SQrveJsiU=S{Ds_u#NqIBgV?iWsUDz!_<8TTY8^Y!#9zpb*b+gEn z_ukus@`(SUv@VLF(lV!ylT1cvukenN@=!-~uF4ajjIDQ_C|>EG>LX!Dm}XGn`GrM* zC=G(__Qm}kAY05QkaVmB@wO@%ZXYy9Nz!V>6McTHMAAq0J%dDiKL*PB(iQPU^`t`J z^bPc?J_*) z65S`Hu{YT2=^LQ;zv@1Fg|-@-XK9$NW^1#SV^+FZZ@n9P>)mONZq~`(d-IO?j69B! z*kK0PtML5N4T$7M9`3r9mmBFDqK3Fp!FcBC8a z5nl=)N{(bg-ET=1F>(+&9JF*q5FOiX6s>SPpqarkWn<$zjQK+&*?pnh3Cs2z%xK)` zZ0e%{augjir#mw?^7!YH4!dcoEfVz)$g*Juf3Id-X7lk#*I1z{b}kF^EAG=k@5>h2 zL%fAUn)IlD5lE6j34BJ(W&5FEcNeZ@7{#3aB1lL&*DT=@0NO8A>V;LiOj@S3R;fPM z@KB~>osYH7hkA9a!WqWixURNb+WBc+*Y?Yw`@27X{rCUByKi63zxv(rcYlBN(;trC z{0fJc&_~!WTn^A%$~9!#9E8v?q==YnYK&k(n!HEg3-A^EApISew>ZD!*AMOfJsTy(%cU<3ty$~V8=5)Mh;G78V-~;o zk*Z)LR2bmah#XQIidi63NyY50Xi1ckv2x!l%L)#9YIzndXR|2mh>T|tF2P`E<;~$A z67cL_Y1lTqySez5_DIx2kHbd!=fun7s`5ATHokVgBtvL}F5Lh@eEMK3Fjy2x8O8Vs zgUv}leq$c}<}7P&{7xk3=*?#rjDnQVMQKrYuz37n!|Izio$(1pq(whl-E>7{Y{#Y! z!dKZAa=dzn%Tu8|Ck2GtgO0rqU^w7L`X1v3GIv8ns$_pHk6(qZ^GdrgdIZI`{GOBx z0Ndb+!96*1=U?{xcD9xJZ@!+1p?~$^i_vj>8DgLsg6vi-)Oa+~%SFRR7`Y3Ey()Gy z9F}7A?gGz(6$cU0ck$v1LLhITEvTE=Had&jr|dgPUCX^H?+l zl%mr?>s-o&oO0UN^V(PItMwJ=j1H7zRWL}skx<}T!;!Q%^n!>++Q>o5LA0=Uvt>Qs zSzFjwV~+~+1ep|p2~&ZjvY|C2sanc>n5Ju;k5bjv*4NAWcxUa&yyt~byu&*NQBAio z=x&~&(=;D%>U2VxXuVzT`}$;UG4w#WY2pz337>jLiq>g9+-NJ#7EVkD?pd z9kSUzWd8$%RCC-v;CVDj#B9WMtf6MYe_V-TnloRpIcI+8#XRAH-Aq-)AjSKBWX+v| zMfFhYRjoIN!;AUwVy@S!g_PEw*0z`d(z~5oUjlD2kzI5<^jy3mD4KrX|9TSO!Z9x(WZ{FTQLT)*v1 zV}zW@Xl}T(p@g>_vGn(i<`aU^v-rbVVjuRnc+W=xV$@maPs?Q#;jq=IB{x(ddxM?3 zFbpYsC>kX8qj-LzK*WHUJRS(dr#$>E1V{iR+og7&$Ze^TDu^ba5*|ukaL~5#EQ*-% zD_L|Tq5nD)ZuaOB<`)cr=R8n!!*V37o(d)v1s34J@XG_<-s8;|zWa)IcldB;AMW`1 zk&hR)22<0*d6vU9ZcjK|$<f%Nm=pSznuX2Vn1g^`>AA-nyfkM|b>RAh*`+ z976!zardH{(9#HvnTIJ9`sR%Lt`F<0n*vJ5VrwWG4i=o}hzUVXcVte7zdrs2L9!q$ znpN@sRU%vmWUmW%dh$exqemOf=8~uwY=!la$B|SAjNLl3LkBI%ksh`8@b~t#B%U1; zI3Ebi8e~x@(R2tMEJ&8?N?g%9QX{>KaIMlAB(h9?=g`E-bnL$IJ{Y3pyi5dExBIc7 zlQu_WgBGdPksPt#JQ-d2@;Qequ;~^8`I-^wH5#B^JZO=*=$(1Z%>8xgL`PR^MRlc= zu2svWm4aHKv(~vzr#hde5LrQ zP*}dicSC?Q#{T@R7n-nqSZF}1rhIKsIDeTITu1KUGh-Cce*k!HcQ;-(GQa#drTC7o z7$)r>i-a9Rq64>%>V^cU`5586jpg0FFZw|Qa>_I9Q9mCS5UN2UDw>|{SfMOq@e5^G z7DJg2Byvg?Xd83;~L3DzNyB!z8Xg(xE;SCU%l^(J8e)B5goyvV2jAUa`z`6z5oKGKh z#d%3r*W>mA3#wO}CykX`6ffAGG4>2Sc0(arRVLNwF6kAky9X%Sb=~kvT3tN-TgOJ=r9@}(3xTQ|)SZQt) z5|Vn!%n=OdD*5l!9=l&{nkQE)2llx>A%}U8sAY2Mgqv=NQ=jU4Jm99vGK+>7UbX;X zLuA}4K$ss70)h^JTR=leAFF_{^{TEwL}w`n)w#$)>ru)K)wMsh$K@=ai%3xssFo@H zxo)6Yk1l4eMWU;!m)aNSFg*oUP`tEO#Pz2F1(dL)QaqU6j{y}JG8)vqt>{lnd-uXi8r z??0X&@1cuMMW-6O5Ahq+1ajl_?M+-rl%yUQKMO3DMshHK++;GGkI_OpNC!_xB1thu zv#9Rvo_ZIEIo{;+I~eW7kR^6$1P5Ro8euz$8|(>}Lwxxa5NKgsKrLJg_o5XeD<_RO zfKhlBjF`wqy@Vb?#0aw9LyCFWINYrPdGdcs!q1qH67f=GI&2R;f-aFOlEfmZQR*iG zYL2sYK&qZd={B5FMtbiOL{iKmks zW;x85W^d3q;#9E%Mlz4-vkYDrCa)`C_NMphl|~TkUZ~>yKKy_rBb@15wudIb03#T?!=)%~udXcB0r#>_mTc&bzd zfVYa_=<$+xI~#AR^Bx{yE(q}A19M7oQHMqYBt%W4&8%iOO@oQfo63B` zyld~d>d)DI7qU{t`@34u8#I#5Q(d-t7H@3hDIkQ51|Q5F^~j7vR%E{gaJPnrZ8`HB z4(}$6bPu8-?mSE}Bf7K`u)XrghYkfZU^jh&jME&USQzd|BK3fDepjh^9F8%jLk^cM zVH}en4*g=br@IJEe{DE2QDKxMS@v*^wT*dMv~EJ_q>4#{tfnhePzt4JEv-x_bD0ih zI@al`%%|yaJs(bUJsrxVTAcqh;cC{qE%y1ZczUzG`S;5=ul3cB_0=Eh?cbOA44VePq+8?vHOaU zh(?WJl!3)>0^vI~?kR7yEmJMGBgBCD`SBT}F2pySf|!{XOErKI$FuGKP5`s5<>v9o zwpj>7gMmzmVbK1T-`O_J`Tg-O@Y%a}FV7T`0k+1|Z+Qg1cLD;cTC3^=QBk)Py}O6( z&BEa|tQoQnj5gdXw`4YMAy**0H-Sa614W8znalAgIwqBNTQ9W5`V!5-Vt;%AGJ@c8 z89tr=AYd?PTsz=V%|kt%%5(q-lr<7i;I<7E9(UI`7shwFaqf*00QYo z7<@>rsIliP6<6xciG&b_IQjg^GHE>!6!R79>8Ql1&494NU2{;RN|~Ta>u3vY1(9u6 z{p4->c)*2R)^G=hI%ApwATv8+HOH4TQ8F%w)Kk9BBfy-hKd?ut@(^M{G6dUsj2m>9 zDWGJ&H9e^BxL8h$`mTu*J*R=BE6hk~iLzYA0O&8PshWdUSECp^7ZS#TbA>~ z{in}g-hRFNa5>)tYnh6cX>gHFPayDWig>7)l=2NtNB}Fz*C$~U=p={JAr-?=K^rL< z*Xkvgqjg&ERM001OEQdU^US&QePk!G*|3sMsfKXly}5tEu2* z;+e9Ci=QP4w*-P9DiS+piDsAs5lT$P4K^W>=%!KpZ5j!wnLsQtvmq1uvRleKFTGv5wceNBz1d8&Xo26FcO<2m(Z6qumZ#RO_uf52mb)IR?sLIt=rE?Qy8+_F z-rGMT)-q5MqD9Mim4^ovg5vrB0##MbQ9YV(kf>hqtU4_|#KGIVBY6&qY3Hukbnshw zh0Ov6o8YD=JA2YP(R<9tr`Ew%(J%u+ckbl)8j7Wj6LZyi-IQf&l-% zoP!Fm%v5t~}jLc>Pq{}QcGB?HYER4rV=I&{g%#89^R66S|EoiRwaVcIc1cVkK8 zBHw_$_%rWFWqsZ@AfiBUQujdp>L2daSJo#XxpbQ3}+gQ&gl# zfvrV!MY&jcY^5&cp!HA=SM_i`&!?$g&4;Ucm?xY{InH&Srb;P7bVIZDm0#|#yz3AD z%ol&q>;D7hf2p_y6w<&IypT`CJ^2~<3Oqs|MV`>l(5|eI1DpUuhXIYabbB83LJNNbCeZT!=vUi$EH(G~RwM4E5&C4iZmNS_W$bv9Jfw~n4fD+@2tn~4G7Fr;xeHon_jtK`u3 z!W%b)VV<6Y&oo8cFnjv*Vt|i{h+4t zPn0wO-%h7l-hZgY^q6eh$K2?If}Y8hyX{LLq5i^OO0FiOzDy965A%%W-DG6phuv2AjmG`@8jhYnK-OXJ50A@PR>$}+OMz7NQrEan zXJ0zK8znuwzyQ<86YA%lSr-!^bQlMW|4Eo-LvxjBD$_xAmQsR~Zx`!J?@c0_;coV3 zHRDp;YI71JBfv{{DKV9z^YwJN(Ru*b+oi3Kwp^@TjBB=KOq3~65-Vc`GYJzUTO%l? z*5k{1ys6VQLF?z`@k3u8dwcTePjr@b-Qo-6`{*jH_ud**%i(mmy3y$hN^4Kc(`RdE zYfH#e6v(<+3)(_JShuB9q|B$|;Z~;uZRwY%zMgwuL3ak@i4d?(AwZ{DL(y`&snZn{ zwyt)5w0=R$UK_hpsiHod3!>$46s_Ho^6-4{$d9lA(*ZccrFD`zfkJESPI>qN8s$zd z9z$pV^^T{7wqjj4M&Slj+X9)c5jSzp(}+9?>Bb`vLF$_A?1d7OeEsmahVD)^xzm2! zoiZ8MlyRhzXpx{>M6CN1eEv z6@WLuS;*vOTz7gon3_%RW?Q9HvbkIeB%~G$}s{bo1TK_4hANKitkY$2MUV zD#%ifSEV23b+yZ7ZQl7MMl1yvK?GON( zB_>H1=J{0d?&CX~o*kq&vQvE6G<*WPD6~kObUu{hRqf5zZp&h8Tia#5I}5bVhiN(< zj@MVOUw-%9Z_k(eyZcXfcOM=ezC0}thOR|)a`G#8erW5kxe_NQL{T=wo_J=!f^p;} z#a`UaCa6ezMh-f`mrkV%dW0&4W-_#04ps3p(ss(DV_PdjASqfOy|@e#$veJ`ImlKd zb5Be}4Q|-SBd8*B_`}B@fOo`Mna42Egv4RZS``zR%4nQ{0rOH@3gTBdId^grToc#C zRpC{Y8^w1;UaQ3fDZYx;+H$Vz300pym{c;JAAmqr$;&@7Njkb1c#twQ5j^?TJ>$+Upy7t!CeR^%nl!b1#b~9KfBC-zlbm84MC(uV$ zx1-q*O*eWsS~sBkq+VLj{EqQTM(vb1D z@po8u=3F-TeWV*iFjIU2&JCNH$1*zNA%Mn)Zpg78!`(_~F-|u!snZ{R3MGR z^}=}7K>N&h2oMbi6NrRT5s}C_%|2LN|aG>py=^^tDqraA|C zzi|Wunqc^RdDM5;-G4?}=K&%mPTPjsA!)oNJ~atj695*gCz_td;kuA=Z z+6$g$&!jlwvTudBt^#>RYK?3ZI)!Cd)#tql$bXyPCIvoYD zwpP4BaAcgI`-hZZ9#8=H)V88^VbU`dIL$VT4zvb2J>0PjN8b=4{?N)lBq@OL9|+H zv$yZ+p$6XGAg8=S(#18@2JX68nAo+c7%p17+eRV zucf~&H81yXPwl?xwbTr;K0|Vdp&mD_KkiL1bmsnm4b~zM)s8RSKJD4X;#M@#(X&t4 z0?&S-fYFk&>N8Y?QdO!adSrB|lZF;e_6FPzpEG*kiI$=$?%9iUvY9O+5St)ICknh( zqzC*)q&6b|V$oPqMU+tkAkq1K&>yCrQzED$g;K>+QKx_De% z2-UPsAs!-vl7qZ&l3_3Uef9o_q<0%@089x8xJ zi7FPCK8;$wKs<2xxFw6i!$ryO=Yr=xcrYu9zOxO7#Vi_!T;`OKb+Gqfh3I65{|94t zCKK?%_B}pg5Fd_vo&E6bBn>8nS+GB89=$EK$L}%HHb(e8E|s+_yDu5;%1~U-R8JW7 zO{+YAKl#jwa@Ax`YCQ9Yx&3~mifod=1VD%ouQ1JR+WT0K9SOL}O-$6*otr6PdG^?S zi#VkhKoP*QI}m-OmO`jd-_{~ZN++pWV35+>qtM>1uU0RuUDoxf9-hklxU{P}9jEDP z((6gDXT3g1xprDp<|AYgzN5_)CBHych!0O&iAKAKJ+VD)JlIcHfq#?vc-RwYK!;R@xJ@)@WSk4S77%%DP3@7)AW(jn zm6L4SwiDB50+q5ufo^AJAh@L5Hn$M~#qy|0ie%gn!O;($jIK~a_z<{EMrNj8)g$;0 zu||-5Sn1J_!f||7wFdd;Ce`1h*voqc5;;(Pc#qH5XyEz0%XpBa2VK`0J?i#cnjP=& z^1>4e%Sa#@ACZ`2eE!}$-c~SA`35(VH+=qGnC`Jbct(Wzr7cdZ&?MFMll;D-{tV3$)9H5%A{!bBwi_Q`LVpE}kmtI)7t z=fvXUE@Tvd45w$i!A(;-AwBNFZ`nei<3%GaFO4}od^6NskZM^fV(~+teXA3tsDLzB zV>g4b)5|fQ;jL{4{n+OQ=gP$mrr3o}4>UnQRI5%`T4&J$c&6vMx0T(4`~=dxX8*mg zD7mjuw>Fm5pVt+UMtMb@PO8;l*4BRcYVB;cu(wQ})|jD7q^h@_#CY|R=dYPasmJU2 za9isY#9BYE51*`^dwVil1CvUQ?~9x9Qz|L~nzi0(kTM;v=Hts!kKI~Z9&LHD?icYk zi#6rn4|g6XGxlx}J)EZFE1eExv&&;&o~)l~-H9qBph28*0U~aKQtR=yPA7tWU2Hw~ zw!kN<#8GCNxVU8yQmalgRN0%YE7%!O#y5(#V|ph2^p4w&J75kN_E z$w;%XffNZzNs({|c{7&2|B=^#z2N4(#ECB`(D_j8P_K=PalNdUbzPR_%L2Kdr^DfJ zm`*p}fA{jcZ-0AU&X12@?jOJ0KYn>S->qwh^fIYv$*PGwZW69jrgM&Gj&m^p0vUQb zds>gfm+37sUgN*Y&}{G?IdL?O$p|x~p5b<12(vTVYk67bKE)!=O5Wl#Aj?joOYBSW z5}4SP=z^6UHG|Jc&Og5*17wgr;#HmMv&uwCg%lNlx`Dw#m1qzd!z|?N6$EqPlnWIi zq~;GbUeF6gAPN?!k|oF#SEz3KMBtZ`kx2mHOnf=Z zpT6SVBi=spmj}Li#K#AGJ@e^;rAs$B zu4cXU)!5D4zq8c`jf6pWlR;8U6r~CqRxfy=4G|8=7Wxx;*n|B{nCR@cK-0e&${b?Xv<-Gn|re9SxyPt33^ z6>#%uJ_RXA4Kr)ayxmIg=7U{(w-`T{%Sc+b&qokbzNt*YQi2D`#HvXCh~Y|Ji%bl# z?gFt-Y)rR>8y@i;8Hy=MLmA2CHp)^<*c49SN)XuFvPVIVW=Q>@cx=gw(Wodfdab<+c=|-k2lnM4o-l;AE zZ{Bo~ej%S=7pxCh&T=_pIpcicdBw7z8_tbiTL09RhrV3wve>$?b!*N4WAD~ipA^m3 zgppy&PGd*s$hm?=Hs7!h`(;RM=TcJl=N1Vz!=i4-dIuS?3B_1M1TY&YbW146ZL!P( z7&2+Hd6Jp^5h!zTF@b**qI29q=#0N9j|$_sPg~e3V=&x4f2qH0bH2xU7i&uuv4rzH?z) zJrB*3J3Lv>9gbY)P2+pW4z`*Zv`&ZX`FNxC1VL}-<>_-@A9_Etx9Im)Y8n?xX6qyr z0b=5e%6xS?-IjWS!g^aCKDPCt_e*%iB2r3l0X`lj3ZhAm6qQnsFQ(Hg)QRh*KYebO zN9$)78>!W0YAfmYlWg4xDRsJjA@d3B{qoe?+4@4uwjv}?aSl0FHYay9#7L+v6}U&MN2EQO})3)mxb2QO+-uh z)We842Vbx?*V1i3mFM)7&4A&1{bomvl(ptPH)K0AlDsWFS>CauS^0_U`>xnlfMgAU}cv%#`%d z3)v!;#+1E{x7b}>fauC0lKMUK4NRy|O}1R3Sxo@7=*5znFE4=fN8RbIf~rp9u?Q+6 z!8T9lfUhih0tFDS%!v#UQ}`@9%d8MoK+_HLON|7OLMpV1wBmVk9hZh50Ch8=GO}zS6;d_Z#Ju_Gv*2PfWuMd z1LgzDB(=%Zx3$yQ zy1Vf%V5-CVMbfQD5TecyisF2Kv@DKOp`!G62O5QlFw#=9I7Ra1PI!12MY0E}WVukZ zJky<8*y)%}MT}#V^CO+j9#x8C#%wq1IS$y&%*!B+-MaPc)k-o_{9xP6S}-o_?dk#? zM@hTW;-O5E#blylrU#gw8F#ADMTn_|B~ef6<}|$nKg7MHQk0=MwnUt;!^43P3`K(B znQhubghFC8H+`n65>>~8A+*HMY22X&OIK#xmTQB7ms~&Y!XV@C9RvWf97E~P#H406 z)rfqySVF?od`2q-6jzU^f`NCzvWwFbO*0~ak^}Bw#@8_q!EVeARF4hg1 zkwx?Iv_6Z;Vt7sOG~W?=t(3S07+b*29^#pU;yYR+CbiiY#4c=k792m?Eo92qa8O2Y zYv*x43#{#amAjZu=kVx|bm^AM5y6bTu^&6?b}w=AkS3%boF-;#^pJfOWW0YE@LVon zdo@I87*m(ij~wX-d42;vhp6X?+$8V$TO|*Dtck>SNlL$0CrBKKEp1`9bYmX}DImGY zd6BF{-Kmh=gfP5;4mNSfmGJxLc8c@k5wk=PWYFKpl>`n69JV{(L#3G|0^D0e-;N=Q zMEG%o*9n$4<3-r}EEvqtP*^+7MeLt)usNw^8RW7(!==MX$@ejEi9ZOowc)mfKq|0I z7;z};Yc-rYpV1XtU()=kil@!cv%1W(jh4S%Q>ga`AT5BUfbCqq6p!5ONFEgt6fKjK zDy0H|v9}A@t*@TA?$?rVhddd@fGB5?+)Shi31uAxpQ0*S4_XgpI*KTLzD!@Nue6oc z1C?rkJ~1Xc;I-S1K~+Rx^l988QVz%Ya66q|LaMcMyWIEXp|wZrO#rA$ExR*sgJaV; zY-X)>YogP1_3eE94NK*EZp&R?9^3j94X8>+&C-76Q4^5pty|ambhv(94p$=5+qvI= z?aRIO6%uVk@?4nY(FVxgJ58s5NNaY!@9UXur9*5@aZDi$L);rx^>ETUk)7+p zzCswT_0VG(5q1Vo%B0f_E!H|)1G8&nq~8cIV3c-Xl9P>DU388p^-O`vsb*^>C2Y`8>10gD1SbpMuJTR zjQiYpqd_S9WkYeNIZA!Uzj;R2hgsK&vBcaQG>Rl`ve7^(UyL5H7;73gA(;tP9N_}q z3j>?K$pICE;2;f>ai#$`Ll5urC6D@t~w7O=C!Wb($1(Tp6Vjj4^1 z^AQf}2Kv%b)~s_8h;lp*N;Hp05+7QDA`+a4Kt&3)h*UsDi&*vqDOI2>ldy`nE9hb( z@La2D(s?ddS6nW3c{(qbr(fQDefzG?)A8o|`kR-h*RST6xAWE9i>zv0g_E|q+S+XK zIFt8mW@W*9a^ zLZ*;oRF`JdQ79sZQVvz^FgNyf?aSKMrLC9$KU;sYBukQHiDHLYRL#tXJ48fgH2@;~ z0T#dpJA67;z(3#-2q3(E)iq?rcn2TMRD=%}Jc!uM_O6cfYi_2dD#F5i`0#YRc6rr( zKb_9!?eg&Rr~mf(_gvwjA}LTK({A|al+UYNBV zMf-uI%$Cv=U7pC_kaC|Ga++7k=*JQ%$xh7orqQ(%MJ0*RHSm_YK_sg_;=%@s#oN98 zN8VVB>~)K{kehd$69iB+pZ=;&USLamqJi*yJt&|?N{K~S9|X|ggLjC`8Ch~YVx*{7 zxcL8VkS0F5#gB3mZi)x+W5dTyKkxXv;$Ip*HtY%jN5{(>{@-u-^$q{|ZvXXyKi=@? zm0zxSzshyM^SfH(*04R`@r><^?JTD=_LFQI+J@G$NnI|zc~UoHIVF7vBvJ^iZrOgy zH^aw4d4RxuN{de49JB!pi~$~CXCG#VjSe0h-7$q(N7k*-Zo|6y5EdK5>5NcLW0*&e zyd9BK9wa!($bL1Nc$-u=h!&~h5VW=_NNqMo@80QR*l-0Wt*NS1UkQ;Mc_euNB9;;i zu!;y&y=jMYD5=s_Vn%J~Wo>aa&qq>U+nnZtMJ+JB84JLdeO5g{=}k9)7&AK%e1{Di z^aR$(-W#5CVKf_kxN>J_T#=3bAJczJ0zxG3G5333S@K{@CNQsVQT{!RUYOzX?YujU z3%>)kF#PF8CoBrjBfc}7K28nftXg?D5e%ugYJ4&0zY#5={FMYWYb~l|scj<%v#t*` zni5Je<3=0Q!*sW{4ff7c=P+J4q#F;Qi{gay6;EBZA;+72`vdwNYUrKEfqvudhFj-x z@OJQe_# zvnyCSJ4B?F*h*>=4A!RC-n(L7q^v|T$tf%%um$Li0#DTF}}MSwEBe5@aOfgH|7&Cpkg2{Q7psc#J= zRAs;H+eLJT3T@c!-R$7Fne`x!R79H#kGKsB04Y^;6I(XAcaYW7$PMppDz>I9~hhd(_2Lu9)=ULHQ{{s8O!_A+iSy}xmo2t->PWmYbg%ad^o61tzZhYxMLkbS&;AGbFf zhX}A9%StPATWlQ=Jzd)A5wvl8x%DeB4SWs=n%lnx;ON<%#kiImr{dl^XRx=y>Tf-fM%wV(19j) zh;Aa;^}`xeJ#-FiA}Yg~;wGxXT|~$ovZ-t)O{Iy78<`OuRaMR>xonM(=*Kv2@2_uf zfBg34x8L{wbw58pKYo6`{QT+s^T+Msfu~bD)A~M6gFaNsdx1nDIwWmr3Uan^&ZtL; zc$kR{R50Z#6;r%tn*Fd_V9cD7Ix8V#DgDa{h;y_wNf#s$kn;{nL=zP>(S6gW^VSE= z#$n_AuztL}9P;*d+V=DLvYk$!9)J4y@cH&>T#uKR*T25Ke0_WW_I`UKh6r_U!6buV zbea!}JX1u2S(93g3^s(vpU2lb*~im30g+s%M79f4i}2p48RoSS_0cu<1;y-PE~6lC z$o0Eq78266P4Hr%Mt^WY^cQtcRm5{QB-D>2#+9-dfNE==jpS4ww(Resp)dVP6lUL- z_&sK8D7^;`!o0Eiq(-xORww}F9Zp0ho0Z#bW1KjCzieaE(8-=v+;8d@Z5h4FERH+7NY zsuER5l#`eD1wph?b!I+OJZOV3s7Z|SGg_y4N76wa7@gKJZZ>YC#{k;l!)Tq{z%c6; z%}2Wp?*qUr)S&tAp1SAl+jHWBm5n1UNql3G6#ffNZo2No#zMIjgDozk; z^?gZRO(Stg)}I*GjmYuPB1EDEkX8{3K&U2d3m=WIx#$mr-Lnr$~C zLvO%6$-&`O12(F*#ZSqgzsJ0=g^{}vPru#dduVOdg9k=iD@)i0W80a0n<2Z0V za10(d-iF<}-445Tc80HW(5~j)Ove>RXYbYrhB@(p?s-`QWF6g*g>+S=R0JmgGBE2<6CnzgJFY7Z#ZaXLch}?Gv;1c9kl>W>OavRUWcBw=- zza%c@p{hV0p0_VZ(s#jxd4XJ7PFz>wRBTN=W+YgrMdbjbKwH0Df>K)|s6ij4wp>43 zF$C)(B@SUJmC$?T=V4Vy6NJ&fH}1Jqwhqlw>=k4-Y_r}rt>zDriZS;4FL7TJ|T8@GBq#kGV!6EzQ^zcFV3vBea z@BMh?I7CF8e8^^gsR9Kch7r`$X*)fMwsBl}yv1DGh%s8^uEoQWV?d;xFWPq6u;Z|C zq&C(JXJse~qzw?Vo!j=19YbC*u9b9<7MpXgB)&f+JW^CZTUu$v!cBCryjpf=og*4<Er(CdB2=_#_();k0JNY(JeYO7pm}Hpf)=_#g9+L zWrSpXf`h+qnB}!)oD2Mr5N~MAxtGe|%~hoYDQU7mCCtRELRy)(kmpSa(z_*|n+SH3 z{n>f%W8C`j-jCPs@~W+E+rICY%k#tM509V!<(F~0y}Z1Cd-?wD``g#|3zQ^k?yr}E5aV8d;!3ZQe;b1o5HF#{YW9@89v| z4S&A#%N5@byc)W<8r#rL*iJZ~r0v{Jays#}Vc)TBvTe}V*w9$Roc@Z#9JmWX{1{OX z4MxGz3Bi-$M!XWmEQ2z9APX_T;jKyIoLQ&cIF4b5_ck2;X4#-%Ck2aWdzXC4=hCo|us;vbF zB1p2s1t{8$F2sja!;VCqissrfWnK~+iv*q)1n3Bemh5wMePqwhq{G{gx~?8Z?~hPa z^%%pM5f3CXYFOxX$Ce~f2DlQ|&f{a%DJ0g&x4f!4!jmbOX<^R%^#s**nvrlib=i;U zm;ulVVpRU=RH)$(l;usq6y{AOY6vr!7t63yzJd8+ODVCb>)qIgVoIGNIm{GR&UOWi z?%XRb9z|U)(}e#C;x8y7md^w&US|eN?@e!b>h9SE5#pN5$Y{(+qo_qPBctMljMg); z5u}+wlwOmi3Z)2cx-~gpx7!tb7rx5zg0~mri(s&K-VXM`+mNG=qw_k(&BoDrjK~y) zNT=QGxY=>T?XaV>cOC{C(Ft!br&JqhFrtFP;m|U%UU^5(Ul7S5x6`i2aZL=pH#%9$*=YBIB8CQws`IaK&6bP`K z&9!hSO}jMJ9iAfIytPE94fezPYQ~CW6f3lIrqB4%$aSm5MY!p10i_QDuh7tn-=vxx^;uJ?fiIp{M61LKyuuU@4xli_i^0Jh6to( z5Y4-&Of~Q700N=itam`R%k$~;-}LlEj&Z#8_pjsn&H7D*J{UM3uX+_pEDUI)b9CtG z^zrZQ@(BXo-f!Pu`t8L=2Ov!WN5q_K(_C88T~4VzA_*TIlavnZw!j)es22(6dTuZ+%ODU2f;Fd3nBuq z;cZRZMf3~|yS-VzdMECXsKf=5`XlvBEqLOu1PZ0`SXmo!phENCok!%ko8q665QZUD zt=t)j6%!z*EY+z`)|yxrDB(+J-Mr4UC;3###}Tm<)QSe&Yj7lbi+wAwf>^)s1ec{t zS^)$!+ZcL-{x-8%qqSzLVDIl2;W2N2V`<*~`=Y8MI@YGyl?5SB+!YyTV#Pd0c+O%f zq8VCRK@)>XGn$AP#jD?kH+m5_1srCEm?-BN;?dTQbCY5n(ku2M_`*sGii`!KDk0;UX17_(o`Ev-OZ)|qG%^PoVW8Mt)t&= z@9)>|-@gC#W&3wMJzP$oK0f^X`QewJ_K%}H3JTkr43=%e4R*WMgYcMp|N2+8o5&r{F|d4Q1!Nd%G;Ol2OZf}qh0rwGKz z(Ma4Rz@%qIKp&arX%`w3kens)&f=4Ta2aEa?^;{*}U>nS!Tc8RG z%nyM;R7^mjDj?pwloV+SQ8KDmYzRnfie2&0@UY>j;nPWe+3@!rpEjISXt)hr-|;`L z`12iqzVP=qetE~2H+((hW$@aylN~^#K0qU(1KUaV6ZQ?QN!z3;J=;n{hk@_{xBzj= zlu~BI$wv!2!C^r!xk;4hobLU(1U?aO4|YW~U;QkSqc&6Eh7 zZt%N`#J<+Hwrx#S#cP#AR$9!ongdr-s}#DiDr`N-BYRQWXUj9csk!wM55pf@4kcG@GSeO_RTv zcv1*zMqsS|AQ@pKoFh<6`(Gw`G1A%CKs@?BL&y>4O|6-$9_DKOSq#|(dh@SX|83<9 zGse;Q*Nyr1aPv@rKfjHISxU>G{E5gzr$8+8d*gZvAOj%`H5X9G%+mPAg0ZSLNiB(0t!)uyXjI*yU>9ni$oAm?z;L+I!`|!3h+0>&bC-W>NHmF(0a&t?e~IbNz+3i(bfT~e`rPa)@|2qe=Hb3i8};!+TB zfm(IJjPCi87{t3sF%}?HxHq+`lMEo@H3>&TbO2c@bLM(G#WA0zfIoq$Dhj^bZ%(DI zgx$V-iny{*UWP0Okuq<EnOfAO41I$GG0!zx3BX$L%{{B2D)#k%Yw)iM!7dcB6MA zWxt$1{5L&+1kA3l{q5_xy?_+2)`=$BQ{N`UZX5?~=yo|heQK8nv)rSPk zDjZ}V(l*^6q@8HPZm%5IIj(b_sN^sJfw_ToZC{&5%f14OPR~#c1b3`i>RZXaLrHMY z)9qqbd3qCvFmn(?8;9-j8=E> zX%an(Uhs;}B$}8|nvp(3X;6K5&5))^>IQ5y4rIJ_>GzS$N2=RY#@XG`TNgk@20X4Q zCfGJbWm!CiHA3kjlCtgmYALF)#Gc&&3NQg%FrEh03k>eyaTmy@5ZB#TX&-8_4U1H0thXi)%C~Bpb1VW;D;3StiPicaXC#Q z61^kiq|0(qWwLL`c_$Jcrq;>|kjW;6Whq9A5+(1a>MJqltJ{TLCy(^B1-G;i8+ zM4406ixBifoB>EL7V$x*L(mO_9D+d(V3-XsC(g3N26>qEVI$_wj>GyG3Bt5*B!E#PSKQQDPGX@A@6a)#E~WSm zr-;IT%%l%97>pub{bB^}^mZU|GcT>nB1$Je(gOMavx4=L1}RoPA*-j7&%dcex+<%V z8c9v14J82cv7t=NQM%E;83Hp)FQ88DG=8BO@}{M3F>ed2Hy4L~$sH|+&W*S@fHp^< zVFQr`$01X!dzjx4S6{RiaObuE__H)#K=@()L`HL=WVL`I?tC!J2?3b~GrNl^%{8jz z&81R|*oo^_qltQcl(d);DMsnmRi&F8(i;vPs-s=Ub{sr9jvJ0Q^fz3uyuSMgmeI-E zh#tg!jGI{um^PA73V^|JupjJ)^@Dw&TRJTUGlxTdm@@88w-_mgb0vd7tEJ9on~E%c z*#czNOA~?qfC3W@=lYh!hLEMPzz-2}q!O9CoSO(BnuKJC&)<_iQ=F*UsQM1KLaLc1 zks$zC0;uu%%mi~7kpfkB?O@XHC);Wlj&$9Z@{IBdE5=B_W`XQnk#*^8_Wp<^jz$WL zSqY~pmNfUT5G{>aT%R|Po3g^DI7}iLHVGO!4^NRZbN@rpxUMyb*xYhSJr54Il4N_3IDgR? z(S`$)II&4X*kr2+AfiOJBs8(UBT-g7H_VKl>_$YJYcRt{?l^?-NX$RXy(nXlU@z^B zTWu)??d**zB2iqS0uxvt)@j{1Ci0%*#k|SPnql~Up)bRX`@MU6CWSQJPiQC6Mg#j{ zcF?XIJ(pEHpc!?T5fLq>EcFJ`(dr!_bUQu%v|m2T{s5B4?fUxdc>iks>flw|j$CTz zZc^AHz$O4`b{sJ3`Qh^E|Ii+Ofhdo+6B zu2o61A=U?i0TCqUAcwZCogT2A#KyS3jpHVuh$eYhHTX&n z4pV8eUu1irz;5sCZ^-f)m)OM#PuUi(;epAW`hz5CkUJ4?DTQAnYd}d=$0X_FQGwJoy;`0(+U%jcg@KmBz2@bUC;!2`z8`|TJ<=i%XH z)o8cYVjqzx(gn2yyaYLgF))e%P{4e8mcJn4szxEnTnG?@Dm{_hoijIPF~E}5(FGoW zm%tpM*t15a-VeZ=P$~4X$!Xi3yvf2C*W0*yAa(;R|fB5`i+>Yzp z_3iuXx7YXA*Y}s>c0kygh&CTi<$xiZxMpdG0IFci9*!_1kgvs=Zi)ko!mwe;5s)eS zj8tB!lM)%-&DIDSzp4AJ;zjb3$Z~1{sTZqNNI-#74hapsU8$!csR*daYBcpZMWc~T z*rLcu1fo}cG^MqVT0_nKCF-AF-Gs!M`s^4 z&@FJ3*%;l2X!mFsVMdtH#&8yr>Gr{KG6k87oFES&l*sm4{%F#Yk`So(TpCZP6`Qpjt&VF>EE+JrCl82%drfv-v0pqj?4l zykx~mk7PXtz5Fm5kpy%d1KA`2)lnm>1_GXD{|*$(;hK!q;40jFQldf|kx=i7Wco%4 zGer=+kQfo?8Y@%Kx*GX;zR>Kcm{oy@`zUi;gnj%?5;9Oy2a05Yy@`Pc7?Dgv9PKpl z5E`ixN4&6Eh=Vf%@aUzMGg3cGD?rvw$5b_cT~>APj)2RTEvUbITm-zSuUOoH1ZI+L zY+Jg}EDKYvVA$YExS9`}6i=AJ^3{m~DQfUsK{Kj|cK|brbTNfBv4ca$*hasOaUHmI z99Q%=99LWqzT9m1L}{`Q9;7*$8S|IMaMFtuNRFZ&-Fl~)c&i>?YEPVrL=VL8ZtoIf zOowhoc8S8^e96L(5s2odv9cNcDdGf$S_)03B*d36f2+O^k}F@_l1&ON_bay%Gm1A+ zV_qUs-a+HshHk|@-M~n3>r8|&^gv-WfZ@DO!07<9jk$Ho)>vfQ(s}#g#oPy=f$!Rw7 z?yCO^0q=mQ;w>tmz#wd}`>^{M9J4s1n67@o@Yyi93P!Jr!$*EN zZQs`t_a18||8JneFJ8BazY?i*C7%ks;2q;jtFwieq6e<1LOTnXS)IoNgZ9NnPf! z*mT+I;3puFSbB+AAXy~05l#_WhEp5FM9e!G2vnp4 zsvZp&CAx@;bmRzI@A2r(O+labsUi)gDoSm%Cep;b7sC$Of+ES>kSLImhUf#Llm;)i z5oO!uw9EdyJ$}aVHjeLaFE7`xU-aMpx9#ES{PE-E^Dhsdf4+SBbiO?B;$f_V$Dj>~ zIT524TGcl?RSi6eFy&P>Bw00gMo?hMe>^H2pg)0@JN0fqb8t%Xk>W)sfRhW+(#Lre zjT4=-R{T{z;C@{vtwrU8?YwOdCw8;j7}vwwoxEHRxgFc}*jqcDPv_Iq)5FJKKK*hW zx3}Bt*O%`vuixIT@5ga;gNVr9G$2=ellzu2lSCjwpRavqhho9<_t~kWt`o#07p&v{ zsV8yxv`=YGCgtH8*8`Zv!xBuFA~YSmA4;u%Dw7--N{KccLa%y{zR@adzPgG{t)Z)o z9pdC3X$k;Ca0nq3kzM7y;bFsvlYBnOzntaoJ3egKRU0r4yu9Myf8`%<{Pm5$zsX-$ ze7*6d^Lof(^me4$APb$qNbk9Z^)zsNxT)f&>t;PZWB^MhdD9Lh&cqRvCb5fTzMRR60 zst=m3AQ%a>0@8ChRWZ6dg(9L&H&xY^?m;%Ucl_rG9jq!9dK>96MwAqeVH{@BpEQy? zXnqxgcU2EwBiIeA<{e==Jl7+wai)~SN-wxK7cp{pW6gy^*zo7{HV|pa612oh(04At zK=h9_spbnz9SOKEB$;R*L4tTs!im7uwV9Q>h&;szQHBH5oU~Z_H5;rjP1XEp*vufr z@?~%4n*eEnOD>FV5($XdG~My7tY=xIafSTL_b)GdeO4NNO*CJfk^BV%GaDryQ|@A< z?m!tVyAv>Sjm#)2>Kj?G(KFh;Cy@ay^2wWmGRA1y&6K zt=q71jM0sq-fnmdjsw=M4~te{esE%Fi;clC*o}j;$i=k|8C_&xS(dT58br3)PI)UI zEuqo!c7Mc8lGnzR{(M{R4b$ufuNO#?X@s&xt}7=C7b@E!#9q3*#`~R+0}*8>HlM)v ztWr1fZolBzUcEOh!CzH^KQn%)l3QvZ5JStb;CpW{sPFU&(M z?Fm^GL!MVPeS!Ser5A}qZC$vjdYjGtc4y2rf}eb?N8yqUgY;culb21ENh9TLM|sGE zEhQi`>YGsB<1}UqjZm1MwIs1W{v~FV$Xck)PBibmFkM}M93Vuzq!h@VURTWzaBQN5 z&W#?03oYw!wwxpb2o`cG7NV5{An{#x&}Tp8uE-tXQ3wxV_L+3mqM-ACyoa+uRN5}O zX=?;%;CSb_aU9Y2NL%3Pu#VHem1+{?7mg<&yVr!DPLH44`GcMwXnovX$NSgg_LaQ@ zBHBc^*uN}MOb0uj#Z?E#Vf{cmogRPLpMGiQv-RG;{ds-)-HvY{rR}nx0F@Eds)*wm zESLbWZsUNo{rRW!^WUH+JKm3PzaQ^kVI9)2Zx9h9Jitpfw)X@&0Q4CL(AWN6H(%6nV-)h;Ao6U8J>f z>(<{n2BH^c$Vq5*?+`(#?(OubZMWXX^@aU)kxYt1{iRYc#Rbgm%Z|`%e&3XwfqKL;Ml1KTU{CMp+TO%-xqBht-!q{?TX`pPvSk-*di^NI|Cc^fnscN1M zL=Y$fMI<=wE8bP!P)l^}X+2Mr9A=*S5n3RW?8u^P} z0%Ja=#AoO>n$PXnAUn=`J3qCDgZ;|u%kBO3`u)#u|M748`RVfU^TSU+J^b|f^y%mA z{Ag#6F~)HlM`s^kCwu1oTZjYPa)b|`J^2x~EAUz*4GJ>Eny`p988$^L4V$DdM^b`@ zk5sr>=E8a01i?5sae$2Y_^wJcY8C%Zl}l@v)5go*Z5%cZ>(`^-j^2;=(VMjMemd># ze13R-eE#L9zaQiF^7{Jq<=gk`+w1FlABTa^9NI1rPaKWf0fV>6Q&H8l{h>-yT}awr z$7J4oCo6kYAlkgih^nejlU0al3uzIjJbl)Ichm7Rm_W^!DiL8Wug(RF`xZg4NkB3w zrZb(0IhjLuu?+@rm!TK{4F+z4Q^UiK&u963!RNDl*yUMq5o{249)I{{$@U=pYiNwYaWMH8Yi zt2;I*hzQTN6ejdU)?o@ciZ&R zjTk3lS?zDN8!j8#R7e~2L^+4FP9k`75qB0Xw;7SRL3lDi%}o*Uv@sD;?}}D@N6;Kv zBUvDmOB0ct#p8sxND}P6`tCmhBGPnk%?MAz82uQ-`f(iDw@#9NCvu$17|sdN`;%JC z(Dhy?Zs5FcsTrg3iF=1I+OU*!C#PdUrY$3d9E|UDCd3V{x7r5?X4yn$rDYBc(=zAi zTttY$AGuEMOn+da`Z!j_fJC&!-+RYipaQyi4p9Kg|Nnd$g)B&;cpt09zPr9i7{VZE zlG*T@8jTxDknqF3BvJsz@?sE4^FDpy#6p)%s>m;9)#`-41!ctRVG8&z=otc)Z1F0{ zTJ7nLOq0A!mI6B<2pcvja5vVJHegY|nY_!Php=LiTccs4ni3An5> ziVaz%T=BqrpKww%vHcODKzjH{xkL7y!lZ5L(Gs-_Do5W}prk^UMOc0kl-{$HxeN(G zT`M3{XXXa1&p*9}csZd-a-{PEDf*ME3D6r=aLe(Shb#M&)DzcMc}7Se``JAV5oL`h zsq3eoKtes$Ii6B#XNiOfB|QEIn<;3>goR=v#paW7dDta{lULKFDeqmD#}tv&p-7?2 zzcQ4>{IR)jVvztBcbE;AEP!7jQxAS3B2t-xP9TSb)k!N^bG$0KUP$+&0)D7tRGEI6 z?^~Y=xp36pf@=s$b{IKe+2Au6x%tUlnN>38{w2r5++UX)k#?4C7mtFPSwA=qj>9h{ z;vpmiy%?oYi)*_4I74Wo(@eCr(}(@?Nlwq&F#7d)`Qv#1%la!Q(p0uR?#)K6OPQ@) z76Hj|n049Cm!JOL9)4eZ1;wcX&YukxBc|=%l7aA&~bY|UjMY)3q;Xc%5#O< zGpkhqGjiyD*&aSgJ6S*a_rKWR1=(OPP1>@&9}j2c27;MyED%Av9Qk%hG`?P33SmVN|H`xEJR?7XhA$b|um(5{J25DbMK$ zjTSoI?ihurV>8McNG^-ing;Nb^zi(}1XQkA zaz?zARR|&*Y-LCS*wKt0o{#ikl0Y=fgL68O;BH>#C)GUA-?}KbpkTycY9e&^B;0f* z)CW3>sK;fa%PCZ4p+YFp@zlLfmJeDiNfT2~KaW{CR6!Feq=`0_MmC73Zi3CE!Bn&X z2c%JjBGOK5CpkTBPd6M_JKk?^Z`XhP>(~GKAN%=y`S8=jFaL7+{L|_AV>@4TKaZnF z&GP7GLqLUVA!bqr7-PH{O*%rJGe6!pSQ95Hz$Tp4*&(e;P*t;Sj%r3#0A%BJKaz?1 zse&AT>(`P)1;?_Q{_E!c7RJYyep165UsA)4UClO`lhO`;H0_!NxR zBx#QHP!$4k$~Zsc2QC7l$;fB72Gl`=%VaT+-f1#5S#Yc#W_6+S4f4b5U*0^)NC;Sy z8z5%Vq0omDo;Un-l3yP1(+M9=I151O9dEDr^9^6G{PT@}z2UDn{&mBP*US^9lmyN19&5yiZ`D~t_NI?rXDXX2q|3@_&!335S>~Sk+d9wrHHKRqck${`$A`J}oqaR}!$M8uP0HV{VXLESB4j389FX;$I%0G!5TpNN;nmFAN zM%d|>W$&|XY5K{mz)~F@*D53YV{eC^e-3`e%OxQ3ydWc!Hrzh%s2G&HAUcJmF(T5# zDliPN$1iI3oF`Fm>^J9QlgRFWy1I3;*+iJ>pO!U_jnR8f7U=@B0$HZ77Bu z*&8M@93B=Il-^ES*l^h`^>V%^xR1P5LHQ1>hs!A!ig)#(rG=pB1E8-{-HxZ-x>ZQwfkVf`3mkb~o}Vd#US z`=Phtr7I0hDJIt0heXncVGKu3;dZ(!?ddkYu*GHO=F60!h2fBh*A*XCIFH!!xpP%K zFcW{4Xg?CmQ;t;%WP;zaxADypG{{~xwf2%^0GCi+AwLg;2P*crCCU>l|1Hg(PPKc> zDdk?EhXl}=S(d0r5tMgiiElFeioJd9J*<0?&=3ZBnVrdDY|3UvgBRX zVPioF#*L-78JZ}H0V|v(vH;bHn!F^;ZaN@T6XrgAQ6!JN`KGS@oRVQz7>JkGVv zJh=RiUvbG0VQPLtFhW^}!yq%QEZpQt2?t85HFp8_ukah1{z_yZP5}{!ZV{!-oIH*} zjOe*NuOrCE2tQ6TW*j*qk*AZ}7CV}hhe9{;skcyqHU`IycEIykB*Voan5_lp*3XzARf*5V9r-!ybff%>9 z(O;ZVlGf5zr$tU-yCWi{WIqnE>uHaog>IZbtP>vCX8!fgBuSdmL2=%lfzZ5 zMfwU0tr|>Bp@bPUOK%w+JuEU=EYt;&sM3n&kQ5O%160M> z5PpV|3Q?!QVBQ@z`Hpr^d?30Rh&Hh%+8`>{L^q)zay>lnL#*C_Mr)!?&|m^>mdp!f zm-9)_&-&ESujBT1y}rKx{_o%Z_|N_PaDM)D`t;N3^H2Np=k4;?&X+OzI1ct7a$s|ox!FXWCano$&LWRWEc!i_?iw@Rvg?dQwsaX&wQy8P|q$J;pG zZrAUx-(TO~Utiyk{yv5oI0RIYrf8mbt=hcEGId`DfYtMXL}hE*6fJt+swkR9FoBGa zwuMtTlO^-iI8RwAWB&A^Ga^$4L`W9}suYd6#9l8&F=`AW3Z0@t9|5JFMWkDk9pHxl z<45^F{S78~KFMW6gB+djZ~WsM{`$^8UisIne7oZNjV~Q<#Gnvj8?niDLEF*Jy6xOf z*ml(oZ37fkaT*}<7zyi%&=9q!2~1Mkxi(>F$~d9nB|~ByHt0DWmb%HP1P9Rto#?6k+N% zufH=x&W9DA4)KvbLO48dEac}A!c9!`7*;k~p) z$e-{I((w#i5*;uY+(S0FcLE58sA@5Q0yB;=j(*&FKl*Uq$VkTfd>xHR=*^&l0Fj~* zUW&!4nS~XB!J@>)<&;SZ#;=iuV9Acebt1==smC*yKMRXU#Kid}twALUS_zTr^;l7f zs;-vI_K-4H0ih+8q|2lm>ojD!@?6a)Fd=lFZoix{3j7@X3eqb@S}124vMXa-#L(=Wa?t)PWuIbyEd6Zj8$jlcb2@IeK%v%!2NFiFc(Y4>u4~$_)^z5)?L`SEm zd({xQVrj(kMNjmIEM+9g3A3nfqjL^R61*j9vDiL(ST?;g_qJkYV}(8i;5I$BT&D^X zKL#G+3HaEHk%b7GMq3CFMgTGt3+P1nHpDTH~V*u09v#2~~(KzLRw1Z{iV9)8l(GZgIo_Wjp>{W|&^ zNVF#VDX!xoov9Sk&#MzW`WPln+xf%(^to*ph>Z7_>&tKb_8ow18@5wyb+Bsw(aHUZ zfa9=kx}Pu4f8QQ|7S(ZkzkT_wzx@TFv<=wcD?AzhO2!e0h#7l_w(arr{`66F8@Ko4 z^~<=u0+7wKpUX}&p9e9jb_@#I`FVT%D6NgRZ{zKoS(hfLJ}J4g@K3o_Y+E}$N!!?u zaeW25k39qijRHq6wg%nLPVS&9xZYo zTIRFY;hd0gS(%FUq_-V^G=nypBPRnI2-xskFY_k)4Z+5fSz}*W9tKxdl@21HnMX8Y zFs5@??yEusF|He?gsF&yt1ZM3s6l{HPKWpYurMg%@mh)qn%AhrEErMI=HYlEW^@uw zMFF4x(nT7h>xVm(&6`8GbE%?`Mr#6vHljfq6h0-F=S@zJ`_m`(ZnwAh>)ZPu|B&DR zV?SR`&p%&2{c`^FX?y;#ouBm7%;$*r!}{S1rBLw}kujqq1g54JO@WoEaf$H8=J}aE zM2c48lO$lOb z$GG0!ZpW$VY2Qw#?R456p3a}2o^MBgyWZZeuP<-c*Xw(~`q%)WVi<>bhgw9PuBfWe zJ0PgYrk;@@i64l?0i>B#0@TD=^J!)!OR756!eA~1WV8n3LwK93*d0N{Ev# zPT`oS7v~INRI(TjvV$Eo;{od&-mSyTOILh?2s^XUgvAuqVJL#sl>_NyhbzyFC?$Y& z*cE{g1Iml`-HhD3mC1E;@7ZFa-VWLIVenb1(p0=Pc_Y!jKMyncO1j|=zT<6l(a8G z6rqAd7mWS_1)wD+anwmL6K1zm=?E6n4M?hnAPCIGQcZJDAWQJ(k046)v;SZk%>**}fr7{(EQKiCG?z5_T_?y{ zRBvoDGGb9TRguHGCxg_CygZIzS@}Gi&gYa~pC)*H=(^d7gX+O!^#TwOKde^WG$5I( zoWWeQoqeEp zj)5Ga3tk&5P`oCuiVF6QV;)x5nOQR0;*(@0e>Zd{Y8X*0JUhk11q^KoE4_ zH`Ikx5lpdf%rHu|&O)kQ%twF+sFO5}8!192-tJgNqFHGKnL*Io8)r{%07awtV<3~| zFs)!bP|BxSoegnHDs-Dl+e9~Lg8*ji?;N-4Qw0wm<`;)uajC9!u~?FPbPr+}MOwRj z((}i5eipNFdp)jyjN3O)%0Sad%7|D;Jr4n9w<`oNAA@GPolXxww}+3?HlZD_UvIB} zu)ji^?z^9|N=M?nw-PjB46~u_eERq=?eTL{wcGpK_g}~L8w9d#$n7kKEEK2!o{DC) z(IKi2A5V{;WIOTpc6AS8lXk=)vv?oZNAU>|Sa*xvx}qR6QgLLWxj1d=Kf zmvspVk06Mou1Rk2(Tr6ia^&Tt4f)GW6gtncO?e;k8cr#^blL&QKYQZFNkkdfiDWVOXCzaoOQ4?!0RQz1b02T4PliDFd$xW92F%Mr~T z6GKVq2T`X9;L47=iJ-OfzCB;|K6vZ)KJ5M0kKS+ndi3_Hr_;9WZEO3d$MeUh4@V!j zG2UO^X}X^p1lqRk^004!n8I@{M4Qi=Rj@Mf+-LSDkK~X zGwZ1!LXDP|K$Nb)dw_`$V#+RL6WLok@wCf!*2@X!vtBNEc);UXA5Lf@6tRKZ4XJ?v%3$Ryz#()w*FlXI142}t;sQFe7jNkJ+*m&_xu^ao@g5CuX>OaUhS1kqc!toX z+M2c|&1Z|JnbI_AntHTQA!G1cnUjS{4zY{*wL$p|9~(Pp?A^x5RwaG(5mRPk5Q~q! zF^_>8#uzYPS&Df(AoiF{>-|7Ip#al6a3JrI(=suPx;V|{kup+KZQD#oU>cc7R*2Y9 zygosE+eDB>&$i6H2wu=9fj0mUzKXxFErO^o6y3QnUrz!Ke6>9X87&v)~bHMWDY%(>q zgdgm~#yuAuj;JA)mBW6YGkTQuiJ2LJ1>^Y#EYsOhZM1#O{VdZEZU>Hzo7vTRA2wo8%P@{Eou2I!ePt-XAPJ$20dG~~ zsnl7$FJ9`F?uN)Tj5+zDd`}iqwd{XEfZoC=WVjG3fbE`OShu@SDf7DdRYtmTnvOd2_GH6MM1o%$Pjs&N1<1TifaVaQ3!3|()+dMXP%59O0^_J=)HfQNY zDT^#@!jy+NfA?#GFiLT;>7t^Zp$ze4}x2++e+) zzs`AB!RbP|T`cKM!$x?QLy*!g`{O4)KkK%ejsE&qfBnt+s|ciR*iP|jCqcbyeBJFN zrxY;u9vA%Z^ZxJ|s_gH#@4p?de_?dlnw-vnn0Z)AB>hR2_4UGb?=aG~)AQf9r(Z;y zU0+_m{W`ASp@Mx&HD$p+w7x;4?P_8DE2l zz(}LC)AsN|P7fGky!_d(FA$(@Kqhl)mcQoY3(%yU+vNjvAJ?~WdjW@NV=f|qn8z2q ziRgaT{Sgqmz1Z;zjvUw*&vd7#F{L=U?O-%=hp|L^{P>2LvHFGvdPvc&13BeAHB&^G zk@2BW4yD&vcaq94Dl;l4Wght@8={^dzOyt^p4Sv>2oN(t#Ct(fntUU8~B^Ji8g3kApRzAa8!Ltq{kFODxMZRrzz3srtjNrjuN&K40`- zSN&THU*;jtW+Mzxr(9K9sFKJ!5rJ4zxe?~HDruP&_3=xZ$JAQhdM*lc{M4BZw?5_& zR6uy|kA#3ZkZac3oA`8ALKJEee_{_87S-j{hu2~tl0kgBZOay?+4mHU(mTH|g*F`qKaU_5II(yzHm_>C^f1FPG0hpPoOq zhi5&X!NKF5$IW`5@lO{|I+)W zA>yrdp-Kq-CRCuprfuixq>m4BdXUSLK3?Q<#(C3I!zO?jjpJtTZ@j+a?W%8Yd_Uyf za2y;$IZ10GPqLlRPLMOjXU(2PcF~qDHvots=tCnpVoN6toI4kla7&mU3>B*WdVa=p zI{Y@pd|4rcjF2=Lrd`Bt z&a3}|`$!`nWQS#zzuapljl3TZn}JcnGo`-L$8+KeygtRH-;3U|KuSv_QfZ0rc-z|6 zbZe?EMwN%+_ad1Q5o5fQVh;B>c${qPJj~cf&&&-UD{H-vF+9IxSoSET(dYwcEttFj zz(7C0NQto#JyEKc1Wgc~vq=u~S-u!il%7RQWhEkjVm3U+=tn}+*h1h1K%tw(cPbJ@ z0L-4=VKK`83R9D7$w`5vab9GxH2?j41l${VzdX$q-~6IfW%F_ps;R(8*}3ItAVVHu z9K)|zZ!NT8q*a&hP1jH6^X4^|e_CiHu^L*oKy5?XCl{TI+ zA9nGA=E6+1?-^<-u8ZrnhIkVTd5lGLK54Nv8tz@@$TNNYH2NTf%3zV85EEq0RZ}x#66+Hg0acTd%k8Tb z6ABvY?-sWWSR#H|^58jbVfl?|@bXrpBrWx4^1UL^CbF^Z;@l929f#Nv^H~)$*C0Si z$RHDE#QHDWEC8&NgtoRn>gl6yPpxfsTyJmx=+`gkU7Ec_V)cB zynchI>>G;jF9qfba+U@xEdw=}`8nz9nGq)a}GI-?Zg0?+; z+AbdnjQ4N-^($bqZ3skI$B$8fA0|=t@}cbyFdJ`Qt-pFof9!uewnni7MYfZi9#l6w z#<+bKyFz>#E^1rn?j?H&CZ8bLsfS2%1KF{x40W<>A%(%BmeRm}hDxeJ8l1uzT&mms6!`!$Z)`v9 zOeoVCCyIv;F<2lD%laX8A!?zMap7{~xI5ReCLx+BWB_1Gm#WDfl_p<)t*Vy03MK}( z5?%w0-Y1i}#u5#oI7NswZ^OZACr6_Xu|&G2Q`oH3mVc!ge3lGTo%^56BejS@xq#86 z>{z%!G0W^7KK z(C6%m`mkGgmdDuOO+eI}jI@zV3Pp6gV7tiWS?$LDK5p;Fx8L8s{-gcdetPNKoIwG*J84iQJdn?cbeMI?_#6sjO? zD=6wAj5xmP@YmrPqbWk5n`!X9Dj>Jd&z}-L9V3ix_mrKJ>iN8X*m2N~fw$h@j(#1t z+ab4`h_>w}D$gG-r;F%@?Ifp%ZG(er6SICqtwfAX@MfDc0^{@4D4D+D=R+eQs97{JJ;q=U%03+%=L>*m8~>93FkYz)RE5kK-0 z$pg+}l%bFE76*7NGcY?$q$FiB&Q#O>S5GGJ&a#omRcsa5ob*(c*=JTXh%KoXT{yL= z51xnktP9W12+So-H;C+^({h4Jv@SxaI-e^;#BU^rb@T2eeRS(id5ms0hI1Z~-7)Co z2)##XxX|RV7%)2!y-Ce+W{$NTC^t?3AAMw?!g-WjL#8Q=3Kfq5`fPCumhq}ujG$&_ zF=EbjL^Y9Cx8UEBR+3OQ)sy4LJa3Ux5h31KHBJbT(klWd&+|&N6*pL@8JM{_ zOC%`#4#!10JrcQhH`0HLQv7(5?5GyB#2r5JizH@9SD`Sx+xB=q0MTDJvfeU?BQv%P z5Hv!gpMeJ_J-=_(`8P58{C121GqX+{2b$pMp6+BFBiot|!yrcw1!3`=j3-L~5!%E- zK`l{eItpCXhua(p8=0aM&$fh@3jFvgYG%jBj&FBt-2D#|Gfny6BGj0mP3B!LnY?$G ziVa%+RKF$$U&*@yyIkI%_BP{^iA4#{tcLl0X(g8@jJwSC=n?|9e5QPaMu6S0$B)U&0heW24E!B2YEbiB@N--opxkygFj|;jFoE<_tvk$A^PB?f7N%*3zZ#Zq-D($?qAc5 zfNopcpY-%3+ey&u`hNZPrybvkA+6!GBMwERIsQ|A7&F`>u))zGvY$S*hhMhG4+4za z%lo%q?e-l2oInA9yC)rQzqrbnfzXKMO$2|6h zVojf2DU9L3Ojcn1cyk?%tH2kslYM6b-Q+ z^t6}x2clLfU4^-ET#PH1GgAT{{-XgRP@0G~pfP6aZoq-jpguAtCdfb= z*i^*euC1C}Y8j}ii`@8rrt{*j)Z$70vy8W!D=nmTr+pSFcEzFjN;F(sF6<~qIp3e{ zLZITMQSKA(qUT*7POX#I(cf;z`|b92v;K~upU18ZgSZ_Wom(Sm-fO{=jnq3XdLod` zhl=o%!YPli*MV~iw1gtaNgd%-N0JbHu#a$SvZ0;jbi(Bk4^Q&+EKg7JaFWxGZ4(>p zowqBGZ~Xp-+m*K~ZU=6I-DFUxq78f$oUomNlgJrzlI;Rfm||!wp)rP`M86Bp9;pZv z<9LG2cf}G>!p9;n8C%BiNIYN%(woM3Lw2LrhQR~|HNc<)@WPf37|>}kgau|~=NJP= z%&YXA^x1&IJEsY1Rz+!uX1 zg2VEC)|{o|Wl5qEgP;^M2Sj3Cg7-hErItMz5Qb-d+t6kv@$-P{1dxAsqv0)(8NK?iPWSRkHHVj8c@6Mgt@P?)wgTteg(e1{M zT|Tw@qsHvY<8q$rV!;ds5sSK8vbuW2L#t(e%oIi-xr*hbMBUr;4QpL~1fGT7N=_)% z8CWnGuGpSQseM2*Mk+~K?q82?V@+;gt%zcmetM0ZRWisNlar!GG;kZc`vclxm!$y>NV!m zf5*bW1OqD5g=3Cjs20g_5K>|I8|hNIDg$EbABHkA|g%)0MPBM`%^nTif&*(UjMS|H;$VKWK+lqWOhSX z%$qO%lJBKLuSS8i{rtH-{Is2)1k&HXUSEE<+c!wVzJmy=y_U(hI8>BJ`h+%U1G;Yy zznq@`uI)08x9gYxu=g*bkkcMJUZ*5vx)7Uh zs!Pe_tvF_G;*4aMw(pludj0^8-NiQ~2!3`r(MXM=S_l$H`RD@&V~whvR!G(&Y5B>xkVu2pd$h#H5AYx#K*!XiqS z5jC%5$vA0drO7nEi-^Rp`R#Sxc%Me*%~a0u0jc(JE*YuR3{i(gR)8t=m)sq{sfwX- z41+$MNG+?r1r;Wc3WZ6o|2#LfWkJwI!5RsRQhBJDLAQ`irIG5sO@td8;Mh2WqW*u2 zz$F-jclTIGvwG(&9^?#+d5MRjW)e%w9T>*6dnPJT5);i(-9g5PJ`JiyB}~l4qlvis z6`$2Y87fMuN~1K94tNXAk@GoJ#LLRWEE7OPA>9k6KrfkbdE305s%fNtfI>8-4pGBd zPG>!Rl!t@k%G=xV`g;5J_2u9H$NA~Q{^{rQr=Kn#Kexvxy*!J;tlQDiZ`NaKXahXm zLlPV@YTOc@Do~kb>QWocULmNI#gyjksFI&#wt9gF*n>sz-49W&jV+Mj-=&d0=E53Q zOp|yeWRRxB39@baxShwPz4hL$e?M-bXfk5tc%JcL0Cf|ns^e?(DHwsk$spn!k9#hx zAqk>1RnZ@Sp2y>BM>NGI+XmUOKgjt>&QEfB#KW^ZJj&Ar=Us%vf#cxq6<@D>d$G5x zz29(j?*R$}*ub;ON%RDqASc1exi6uz76XKl;|5JOAca#5Sfpfe)KYoIUka|BHg$JUAJ+(rRKV!(=1R;o)XhoEKMTe2sSIV#?x ziP;^2scGER90HIzEl+>}j4_T8V^&(z)|yLAYn#Zhf#Y@@J?IP@WAxs8?}NjRWIKl0 zF#lf~voS|>BMpWk5TVz{WQ9vcxtWWN1as3>r9+_d$RsT41~KCxp(bWME{_~q7#l)n z=E`)rMo$Cbs88lT1RyW|1$T-Y1zR#7Azi_O1Blo{Y`Z4b%4W7$9x-GjQn(}uggPby zI!P!6AH9%0nwWpX)4@u$wB^yN(xh^UeqoBNnqpt1#VCtHPqFy*ZZvMrdF> zfEg)6GA}-Gf=vVl(*kL}q^QmDrl%kmpd{+Us&}{LH)1_rT$l*W?ey`+y`eEc(V@RL=R6&EsY*m%u9aue7VQ zO;0D9B%%T)EtosY6iuIFFS(~Eo?|%W1%|pRHy_ByWLfbQ#gcm73TMht12?hnx2tdHv#@NiLDn*JaO@Wmec9xq(6 zq<%{IT|JvPYs)`ZrJSfXFH$Do7bHPdeN^T(*PSD#FaCoHyR#{da0xocmFxyogZL;M zo6_s{wK{azNHrW&97DaPOk#bC_|hc~lhY=U*-Lp6^HZrcmXFsT*V{@h0YT=!mO+lY zSxAI3AKAHTMuL9)B4XY{maX3J=6pO4U3jO_Vp(V=+Gc`t8Jj@r?o?<7bA3qSvSN{# zw{TT5U7=9kA$4`zceXYSalS(Fk_|}o!%K29@*iS%yh=utuxL{m1xiK{F$GLjX^{aS zW+I**(jW%y0v~NTj4G;1XG2;@2)g(92%whZa_PTF6CQvH71mTXVKhm#?l~qZo^zyU z*)Hw$VSDTxS6<(5udmndUta(7U-!$?>Eq9*&p(|$KDVc5JzeB-!5DVjc-)RYy!k|U zia-))Uda$sb83^r1w;@|lvHtf0Op}jregh9enp@zCpCh;D@`FlPFaaJV0Pg{sRzc* zS|pbfyU4a{R~Y)HauN!l?$a6&_TC1=@VH|O^_@mJh2bRX+4I?#Szygeusv zU&NV?{Si-3^6-R*M|pU_X@>}gadeCufB(zgU-9c_Uf@}1kpfi7;8R-;%GE1mE=&&W4;SPw$ISetr#3%zw z(cz6eAOrB}9S1SYI_R_DyG12^uc0HvbiiZ!Y-J<_@j;Llu8I?_5im+jH-TJ8O?VF> zO98c97JaG87>G*KU_DyQrVZl5iAn`pUPYQ`@dd}#g7S#3lhA${NJMzUp~aGPFdP1A zk8o8HYC>bTF?u(j2QS#RZQEquHWJtC?fQNlX4a2kqYv*pdKhhtKKd{WZxk|U96pVO z76m8%OF%663r>qnf+Jaxw4RL0jJ&_O*4`y4X|H5Kn(UUDBjS{b(Gx2}lO`3k;BkQA zri;da#VG3wLoow}1q)?r@6;)wKbTodiFXPfNL&sG+%%0`s+@V5)efL=Z)zrSu%Fm? z(Os-Axvb2?)K_VE3M5FGr?Q_J&z0%quBgC?T*Vzj;eYjN49k$|`r&`V?X_+)BQD`= z-=)!1H8B$I+S16?pUHb%q}@e%f{Mhqhygt#uNZdAtuoiJ?M@YipbSx^RJHG^!^Xxd7lcbx+m~M zJyc9eSO8i)i$v0O3SmB9Y(WZfV8rS-P@q;Bxiq27MUZKS1=Z8ZWQGTq*OS7FO`q!D zuQzuzq$@{0SV(tS;o6v4v0qvj^O)A}tTQjnAbikc{^9z2T*_~KmQ~}&cg-U(9h*2s zKe8!t5i!uwg;1%rslCP2VDV+i0IWnzDx}P6 z;QFd$Q%1iIrBvCnwh^+$lBu#zt&m*Tcpz3fR#rJ#8ZV}@j9k4GU?Qp;K&cBQOPIT4 z8DI$u=(Rm$RzCi)RFRz)f(}5YE?;*%m#&sSFT>2SPj)9GO~a6<3w7PQN6T0fBC_kP=*zO+ov+Y(i#b%m98%+N^4F2> zWnqzAzc`d4AXUW6>Qj`*p>K*Wrd-QxEn z5$la*&*!360&tq;$Vm$ukx=1oy=&DhIS$SokhCoxt0g6*BTY+s{>;(`iLe$346_D! z7Q8|gYz9*q<~j1Z#ST$HHKB;nT}%o@Y}QYSm$xwcr&5|rCaEfFvI(17GuaU3Gb;36 zAseVel-rJNZ~Iew?l^9CydSssxqQ<0p2C{|(a9lFc5>2}uB z1*Z!x&+_z99v^Xf#D2n_=lXc(x9@m=rNoD$1HQSUGBZs%BF%0`8L8+Zdky4`6~cVJ6&E^XGsidY);rRDp|xJW?~{kyp&!C zqIzY3!^5v;12ozgG5su>c(OcxPd@bjq(l1doLD-K8{>#sBY(DLh!n+Yg#)Lx@zl?_K>4v3JL0G$)SuFFg#Mvs0 zgd`3Y?>?4WmIxzOt(BSzQ?KR4)zd{KVX{#pxa!ccK-Uk)I3arNcOJD2w`LNaO)MZW zMp1%UNQ{NRNr67x4ag{vKNwirr?M3?M51vh>ftFOn(4em=1N3WYOZ<3wZocF%vDZ& zsloyfa~OxAJ9{xw(1iw19iuUdii4e@xpS)(!^HesEGbeqVLHb-_VW&Bg^CF)7=|#P zKNM*pBPQzI@+u{Sivpg=Z~2z`3jd52kIRCJ31a5g&YSgZ^rY1l_$&Yn^FiZJQ?T&foXp6ytg=Wb<6?;R2tQK=p^qy zPi!8P^e6EZ>^zl`hcz4afT|bld7nIRj#)duOmcP+aO4#*wkwCC)3j&WN{2t$Mzx_@&&J zAJ{79DVv|mzeJJsY1_^*`uo@8`W54VDm1JALnkc9c=o88 z6u1B!ouugL^!RzZd~Dlg9JjZ>{;gmC1Ss2%c7|Ea9InKiM8x?eA~3RdsJ6?8{qb*Y ze_}tbfBoxt|5E_jcS3?_UPM9W^NRQ!h2BB7{ps}l%YOMVj^pjme;v2)qAG0>E$J-ZG(QR{76VHNVl40V2mP6X9J$%yr!EUd|>tDhl zt%V%cmiavxrJdybDBEuRX4hBPk>u$QqB<+bPztrgmc{VObQ0^2DE{*Y1M_2Y!reE{ zGtWf|ST}%Lf(46|J(5%n>DEa`ngrS)O+*CkPThvHJ!s+tUt|kA$H8>=l8{|EokiNM z?vj}HZYnM;_9tJLBt)meDa_7k$^4XoQ9Ze9N5U>G;^8aJ)T?4^7juUlW~SPzhS9^!9<9#78%)|GjY~Za){K5%EOR8C zOCL&au?g-l=idYxCFgO+HL<5jq?5IZ&8xGP$ut)M9k5cA;=(g1o@jEDd& z*QZI)_QD7#7xCerQi=a2B_~a!3%8mAqiE3t!dhzvG-@-ds0?YMC)v*O@YIH3-0ZlG zZ7OEG0|Hk}>w!qUtla8Qa+1m~Xsb*gayg6F!;;7Ug`(3YDgtd#)g}-@ z@>x-6kRxvhp;l6#Ah?k9b0y_5xdb3tjOd~fokKmFg*1B2iuoLs(HV_OY262}1P^3p zHhS;Ju%Vj4cCTp}-IGVWsRs>YFIHu;@VFzH00|l*;9m5$EDt$OWQJ>8Vdj!2B_ZjL zmhBI%Ur5gn?FLYNBSArQXvy~#29_Bx{t>VbQhdOdA9@ct_-{$SPii7Bur{6xopbL? zRwkE!!pbPJjAA~Y>bz80bKWDI634K?^_S`xwwlniohufWlH>TA_;*zs_EA)8GdTkpd4A<3qyZ$msTR*;o`wJO@;|&2k(0Yg&Q0@!%i@l~r;N zu45I#TcIXuMp|Se8(lQx+_@6#Ig!XnPdc*dYqaKvk`Nh)*(EMQ#T(^+6Cc)^7OZ5# zmPOknen@~755ZJIxef#b+mGmVeog+vIicdTri_Q|vE2K+zeAEP@+V7+D2$Xo2Z^$X z9YJIZ2mgHRNm}UK#Ojy!BN{|%!vcQ+s|v=}xvMv4PF!7TUHn|@_;V&Xtlt}FEpIFx zhN*)>aN}XpMR$1VTcr5}M8Wh_NMYfpV9uvqWFiH2QJ!-Efm{hwziO^d;aS)D7Gk)7q5M)H9*@jtW&SBujodE*K@6nQ@iUxHP;Be{ zsfDFlDmAd&hRegb0WmV zh9aPG41w(X^Y-+!UY;@d{^j4s>mLxxwxvXt{-c|&)o_UTGb&X{`?t`w=e&3 zy#5Il*|s`BcR!B0nDvv^0ol$Uwx`c+Klk_Vx9@*obXh2mGMJh4GEaz{p7i0PN;_Wu zwA(k)25EVG=P~vb*7Jkzk2H+8Zy4_dQc^@ylrGka1tO;XjO$lzAtpKrqC}zy%R#TU=uqTwJ<5W2sx6$Q_+^i+sTML* zWfA)vS%Tctuv{nzv@T#6wJ=hc&niydt$c^whNY#z-6mV}i3pw=OOkA`0{fRbdjy0slDB zAW+3H={89%>D>R-6erCa17mdAi{-8H2o_CJVk(k&^cYa@6#-?mE^*gdgkI?qF-yfT zYD*=!0Y(mGgEXISLUz>+GF&bg`FJi;Eds8~#9WaLqKG8@=KmDgM8u>iHlZrEVO`oL z)}v~IoI zahMHF>;d|~8)PABv_460V=-WLR2F6gk{H1)wkLv$6q+i96Iwv0tove5%prOHWZ8b- zcS(B+il8+YA4d^Fdg@0`S8Q-q+KtK{mk-G_lQR0bwwd_UKqdFg&0Th}1^H zpzfR*u!gpQ7AYigAnRxs!YPf&$Qe;mvq?uY$bcG@(!A+yHyUMlJ5qE{Tj&rbn?#goXPGh%@n5IT=?`mfBb&r0&^o|?Mb$690t}v5p3hKj!I{>s{ z#p2A$BJW`y&F2zaTTN)-{GV8klCXrb;*GgA2Dy;QX<7O30EBWkUP019E4ATuGh&FD z;_$v3`JtIn9sR$MD75sw)IS0}`Fp|$r_zud=q(V2p`rK;8qVfrVG0*~Vj(1gQgcE1 zOvAIVD;Znx?MZ*A9BdcTKp#oSkjOq2TyF!JJPMIe3Rz5BD3Z=l6rN!qnxHu|tQ3k? za@`}hu|7*+Ci2C7r&WhIvn7(Qjq90epy?+U#%yuo_o&_{B!GMR^}|hCE-)t_UO!n# z>}0EOmw7JP^xym~np+(4<`G%{bvzK+cGGoBIFa*m ze4MT1#+COHX6gPZ1*!Dn;)Qj|{Mrd6*AIpwtj#V1itygZcy!2FhTu<>y-{A(P{4P^7on~o+d3= z-A?-bSk6NNB|nF`F>vP9rt=@iS~yQlVfi0^7Ul?ftR%I_%A_RLV|9Va+mZr>x!>MI zoTA}U(qLip;$w>>?=)8qd5x9##ljPGB6J-+`24D1cm z?7{BWkQAy_Yl|2-hDh5Ue?EWsW!o;-Z@;~L`v*8=JJHL97L62Ser}tnVhozK(`9@7 zWxG7HU*G@uk8yj`rr0)4<_{@zWmCOoK-=YGyL_iV~9uYYkZ2%lt@OQLHI7FN1 zCQ#94)I{Pg_r z(=X>wKb<~2Zl_B-onW0uw=sMjqFDkm`KO{4sS6WCRoZI+i1HMsn~U&jLvo9X`T;?B zdPu@8-woPKIF7?IHPQkHfo&6SkfAM&qQQIc!DNtxSoHAWc9DlO&KEpB%ZCs8@PzZD zTu!*0r74HuIP7-C*FSN+^8FpxgU4ZGu#dP8^Hsm7Zh~F83pSX>{{;XaQ6U-!QmTj| zLUC2K;M&}iFFYrn-uv6LhuxDs0`O$1I63MFyH^WEBzMSQBXmFp#3%>7VFx?Sz>YBt z!@_SJ$oz#WPk}=MFBG6LhNW62s3YDMPL~5z_cT%k`4+4Uxht4>SPh+CR#^lssS1fn zS^$FV@Q!Vs!BI0=BpP{81gh20loAuULYS(8Kh7xOO>AAQ#GMAwlUxEpV;{r7k&|YJ z4eMsXfOvP(ZasRDlGeunyG8NK0jDvH=1m!yvu%=Lrwvy?p1K|k0&_Z;Re>dGR@`;2 zISZbdXQYRgj8qX3u^du2|6lD^S`r$l(f&Ri!NqdNC!z-s%V>JOMpKZ8!PlG(Ru`iq zi6=NUbD7j^ss}@|AW47}kt)-)%Dnv~Nmi4*xC)`H1mQLbIGB$ni{4M^oPG<_&~sko z2LzH4g5|a8QT}LOQ%R+lM&BOqFv{H2dVoa2M1(^kF)GtLCd^oHLy1J)dpWtabY8A0 zx$(8Osi9Hp9PiKTmKRwp^IZW8U>us5Wg!hT!zvIAFdCaW#ljLU@nRML-V_MkDB?|S zjG%dbmCG4Mt_fo?Vso7JnI*CaLnF*JNHoP-5-c8zB~dF0BbL2eA@Fqn{ZCs8TV5@N zR5IaW?pYqXDOOR0#yhx~dnrqVj77O1d#j{_7K(!sBjQ5@(k|z*p8$7-gfo#p-CEYa zm&fK0_-XW5^AlGr*`7QgxB#<%L!yYRBZBkjHD!^6wL%a>ynjhVA8{NP=SQ?iLLTOHlftw zi~ZAACq<6C1)IribAwo-wcJxZ9%!W$Wm=%9Tdqsr>k*b}K~|%wHdLXH3mk`eGBsAy z!5<@)c~`4s-u=#Ht=6S5LWI&p)mwOo-_E$%=t=S+)wAVS)hezUI=-~XfN7Kni0F3K z?McrMDvdV!^=rR=gAI|$fXMF4ivXk{l3I4*{MRrL+8$01Kex-XZmqw)ynOv%?e>Ny zIPHMK%rpC@VxN>x$cSX?P!O#>><>S+$4?^L_4}{KxBmjV>^sq-CS_Ufxj3o5B(9_T z`P1q7=XQDQ?=N3||NpSRN!zHK_XeAr5_U9X+E0^NSG4W%r~Sh(BJK9|zmB)RL{zp$ zrm)>7HHV{Rgi!WNyL`ZQ>2Kf0>lc9V@)qWIx&7e)w4E-xKN`&5zO!E;B2DV-%2b6& z9asyt2w$Fd*ThU)ShY9)oJ@YOZe$)PF5jjZOe)=1Tvb4}!XU0HHJoj2fJbuGL2-2bT@4yACQOZX7^LQ7$E#y#&HrXh{Q;kR$$_n3s!Ep$nh9)|y)J3F4 z50TN#4r#3or8J3dshe=44QYqSCepx7u?ctBfNmy63RDzI*`Q5MJ9cZQgX6IN{`T#U zw=ci7?YuvKynOuS;q#}<^V5F5Y$qkntg~Bgd8&oTR_nRGHM?q_vUzy2 zl1*w0a{FeJcbAmv>tP9A%GkxXD^<3Q-8FxcgySCL6xp$DvYq7efTw49e8STQd3w@^ z2kaX*B|7`y+ZErwj@K*R-|=?E?XccMWFio#vRzbnfr_1EG(m$kuPueat5=wr4JnxS zXA{YEAVo9M{s^(?k`|thJ3b9m`d`Ij6V!1JBgCtKnlFSGus{#UAUb>|WFLl3gnJJ6 zv=QS^;W-+vOE`zi`geF2L7$*o8g0=t_Xb=*Fw)M`T%>JKm4uF=%l+i!aTd=dP!VAZ z4;cQ7^Vq6BpDs9&2uub2K|$q%gve*?D*kx%1pA~K%#4)bY=%##86&5x z_)DYr!I(wct#`|fA2wp_S8yEFnt&!rXL#vMEHN2UZnc1n8+08M$sWSGep4gQD@*+r zcA+$J;3Ns<97bvavT5T2j5=MG0ZwNRopFUn`Wiqlg)tWREIm@D-QSMw_vX zEGfu%+a0>8cy(@In33F6GMhz)A(%3Qit+JIGl0zGL{x+)PnmN#VfT@1Byu;^1qGXu zePv?1)rTu9Ry&%4lCiH$ht5Zv-{#L@^YaTW7vGOWQFSx2p13Ks`4Nxt*gyTfo~3TG zPD>mQ7V28uznMR=I3an}Iy7ne^9lh&Q^d*WzU$ACN)L#xyeOTJpGgIC_mip3Q~5AW zP3amGC-UQuONQJp7J%@u;~f?v0seqWrpG@O4dMW~F1drQRWru;23(JDiY;2ZYXcfImR@la5y}s!G&^mP4lMwT{V;ZVL5km15MxC3v5seybrs?$&pi?VFh`x6 zZWrAj^>hKm`up|z8;>`@v$^kT6z1=>8y;?uq6vs7aMjkHw##R|eAxALy#Mw3=YR5e zM{79kd7Uv=Y0(=H2`@7=|Dc&}m;K?F{qj@Ww(HC9@8AA`e#O>&dTup-p2lWgCr%4& zklgmi^Yj01zkD#ezWn~b^_RcU6#L0jgyGO``kc`qDmTY4!BDT|SaLzW#>Wn`|vlbjgr_q?zc}E+3_B!n_+BHAe44yFdL2!PBc zV?YQCz9TKBgd7ni={Li#vtxCSVov{zN z@uTuZ=&2v2oGICYIxk*YdcNn*wA2Q2J{P8;%y-nztO_)CR^TLe+9+G>XXO87s53ov zPfthiM+#>IL_|dCDX{KlG?8JR&aIMdO9I1K$c+Om@3T9E-s7Wr=03G)WR)vN8_|Mu zBUE))86q82)lGC0J186NK-m4_TeE#0Kx`o(l*5=*n|RUGB!eB0^1~F>qn{&NiC2ldny(#vgf>j zE%L}LPH_>pBK!p;26b~@L*Ox6H?=0-vjg6hXg~(V$Q#59RmZRxjX)b5W>leJ%oXk=CaF7U<@8_e?I#P&L322X=!9az1n+RxB)USX zQsTb}pfX;B1mN?^oB$$}oY|mv`3&A6fB+3X9@c0vH`bztWsKpRhmVzwSIn%B5!oJQ zog6lLu^Xhts25~;j2XpX(UdfqjL?U!VM1Oqy#o;&TZn_8ltO2Pp0s?rP=7XAi?R%o z8jPVx%K^&;n(kB{@+tya1T?v6fT^@Cj)}j)O@oPdJ%(swGA~}RHTql2rmrFB@8mF3=j$ly`1&RXgaIRo^4NE?mUu3Rs*heY+bGHL=h|)dz0ZrtJ ziX=23f^pIG)R*fe)Nu|=Bz7Wo9PKDUHUN6t%?*Q!47aWDO1FUzo=-k6{W(g4lc- zuGs}}f+;~;Af|$MNWyA;XiV701go6f&l+N!uyj87B8)RGF)v>FBrkj~JpogtmSQ!6 zE|6nZlGjB;osdzF#@vg$OwVFe7A3~ok$F47eC$#jbvXRQ#U#6Y_PWvaQo)1G*H_jv z4`kiB#MVs4bUt=1?A={5Z**>9nB7cB@izo7UKTiv!`SpJT zVM3ae+QlF+w=2ap4!sj!@w)^#%wnkY(MFWJ6oC@jWoF$?C6r5BYV?jhnD0sr-xro zPk-M|m+Q-y@4x>W`WxDY{RFVu-rh@5-5B8`IL$;)r>CFKPk-08y?puin#tH6XG*e$xGEyuXh3 zub^RTSZ)+o+il*ctrIj91dozd_4gRndCku=e7olNlGP4c~MWYP&UN; zy|!{O>cqW`NFx%!JDKgWwMav^O7mSL%(Id1=;AasVaSBSWB}5X$Xu)V@q0F8Bs%Zl zK0zn)JATMLS^gJyACbgyKfIo&6$Lrsp6*PO*8tCqV}Q{`KPA0w+o8y|PBsc=ow9oLGezb>P+jM<^5lsvbY|kQ8L|@c`UZKRwCIcYOTF4-azL zae0)Fk8*j$X~*6`q7S^k+n;~&?S*ewyub4p;*A&R0ZY-Xi8f#pC~N~2Xo2vn)s_b+ z{ew~mAB+2flpHQzrli+U(o-pwh}RwIhd#EDswT1>RLJgKG6tGky#q49K@9IEK@OsW z2J4aU#=+s~Zdo2*Q&;^I)04*?rj@ga%=#<#!={;7P$VT)Q^6GxQBV{@Ep8&l$|5XD z&=yJ&Y3|E92@)wD5TUB7S1v{ki_c*}CIEz-L?9*hhOr3(r%{nyhxtevcv^;=F zWk&_n(DmfPU&0|_d?G05@}H`}6QEJV=%G!QBcE9vU&BQsaB;RI6_R9THfFu1f3+v7 zc+Z*&29$wk`j_*ulL7K0Qbw!RurLzB9f~8sdEKyQmYzNr1L%%Jng|GDgxsBpr$|-k zJ3#OU4B)!hbb+ToCw?z95Ry?HAl!8>=;9|klJD+(>Qv8+ba4YAnd^}sED?;?FkLX1`)u9nU;%yj?0Pokw}`KOo1slUbUcwkBf|&;j(*XZuxx;(-r!~TwRu? z=H2_#xhOOwAtjt&QMz{GUI3i2*|7c#O|d-ba_qLQkOb%{hR`f^kGsHt#riA&+s>a5FKP&ri3%=|5_B+)^9 zJ?XHW)A~2ipn}%&;+)Dq{>h1hSe~l*Wj)bJ_s8w>X*)fu zwCmen$IEZ9n`e1=;NyQHbx3fum{=h>y2!RY{IWg#ay~!z>&w@F{J-pYMQhl15Lwug z=QNg{b6Nb^`ytr3$G@MR{(jyc-(P=!{p(+0H*6cEManX;C?8JN!~ZpmKs$Zdo_=Zj z7tE$GcOs`kl0)|M_V`g`>o0$f>#L~ZK9xowG?GJg)6=7F5A4_D z{hQP?#+8=?UW+g5@hGu#_#m|=0r2NBmJ&vk2&Gg>9+rI1+J(W-ZZz7T0ER$$zfDP{ zMeGO>Y)=vDE*=7dCC#yV7=D^+^`<1(&a4tV%}+i4)Nd|Lg|h3s$g~$Fd?kA*ig`sG zjuPq%LOK?@dEO24vl$o&Hq;IC-ILV@d6xkBpFZL8iMzl2u*uDV-^ z#vz^x500ThNeP=Z0eh4DRZ8>M<7YjdFWuv1FYvUMR}<@xchIpHRDO!Ik&R#i9MNnre-5%I=NoN7dHoMD^fO!gNFd5;wl;M)~iddH*DUtFR>gWEmzL1N$Gm;dW zF`raxjc6SD8xh*bgEpPjdMkt*X=Qh#CO6tw%8GuY!&aJg2F1&tgetx-*FUx(Wxq_D| zY*~=TWe5K0hci#l|86O2L{3s=&Y$p~icqYpMY&(RACy|&;uM5+muCq0$6H*R{vinU zILmTZ5jG4=D;QJ_3Jm{nAOEaJfb=Oi-{Wha`rUma2OTQ9hvFFBtY^7L70*onK2caM z$Xw`Yy;A-b0|k+G((OU^2holFdfa}q;~g+h5iTPg>$eEiG`%RPKtySbglK!(9zSpA zClI%{Fa7lo*pcl^k)4c|Eg@ukXqRV^*5AL` z@hY0&<6b?v3>Hs=$rZ#AU0w^aJT%p6KT0E)f=B9~5V{CtxQ{2IqdfKIXJsG;GtP85#ve@^6h$S1YB_Th^ zVT25CjYY!A}N^i!b{aI7Si7)i~YjfyLqZhvq^60TmvNT%~#?*t8ywv z;SM3s#a<*Tx1Rx+oo$7}rC^~fm%#T#!}&icWpzT+tf&2SOuZhLds|$OpC4ghJ=S}t zRfyxCd7)u-N->B4-h`>huyF4g4>CiD%$}!8`sRe9C&KS#w6^!FiFsO+_nG#Ra8Xs| zXeQc3Av&O7Q)|#h8K95sYzpO2=+;ze4cY{oN)t3-7f`S{!K4wk8Sc%A$biy2N0Jce z`J|_&!$u#k?_XZu{?dPJr_1Bx#}AL6KAfH(w#QT3H);dD$ARV2AdGaeL&p!Qwz=@Q_gft#(*V|uTZ~xE#yZ&GPZ%-dKeEEi#uY7rz?{D_~ z-EKFEK}EM+T7zyX3bbexVX^#idJHg%5jG|o<8nv}5-`aNe+(_aTjqz0Q*`Nwn+#K#SY8%0 zH~rl@$z{PH5>dbK2Bh*hVtG`+ChCQW(VHXq4(2ZqK?Be{$BB{0;XUpT6mu7Ylt!XS{80q;P93#4MjA2KQYL-zM9i#g{vcJbjHYENJ0693k4T#YO zn4MthgI7Xjfg|$MXvhK&2oe2)-T(5da7Emi8v^|Q3wyIINs=5p@B*S{=I#-ZTUAzX z|Nk%ZI1j^f$T=jt*(AHFx^j)p-OW@4^8j1`Y7y+QlNoM{s;CGAxL5=Nq2p?~^bD%a zV9)d)La)aEDL+_dlpxxeg^GE1o9^WWb)B3I?GsM9Zi(Z~*_E{3gbYZ)BtFK{r3j$v zex(M507j6g^?_20sS3kK4+Z3FoHEU131b zJ^&*pk|CqhVcX?c7&1y+j2Ipb`nvuLQBH8LZt=HhBA-E*Ff=$#RY(05VH*eNCT&$F zUoefZFx6AFr#Kf zhk&h4#<)5{(XPY&Tu4tt$=v&VK&^;YAnr)lgt4He4=-zV<=BdkjfzDEMr>)pnpiul zd?)?;7;;|j=I5?M$(AIOsOVX@2R&UxbiaOHuODn)p&~kfVWcA=x^s__#7PfQav1>K z9>>GC&IXAmtVwgGStS_j7>9ClAZ9kIHam9T}NZHL+2J|mR==KfrBGSayo*0(*sQwZH6!!UQ-x52}Sxh z?nrq?#{q1~5*#e@>ChBWzgdFh+ zs>}U{al&cSi(##Go6pye&wu^!+rOVKmxsr9Zy mDS2^mN%yCp{Z%&b2@lCynyN zm++NIK`9kL!AJ#f{UaTZnR z7?($ELv)A@u0)8Li05>8Sq$fk2oi8bzzoZX5y>hJ(78{k;uj%w606-t-?cF`Abw^G zc}(1MTfp)oW(~X9v~` z4CyGDnK1&fA`QbLk0Wb#%sFkXweL}&bf0sd{NMo(KeA;_4{BtckGXb(IlozikZiGf zR2+g6K@4V%AudDsqaIfO%*9EmpY^#)*L!(*Re1rSK4aL zBd_>lQlPC#lB;PCTIF%1m>9w8K z+nTO&YIqsb!hr~`uZ(3!{f<~fS+?J7%}!npwppG<7El692Ong@U&~^(X1nO=B6<>F zfBCfbPgpk*k>Rea5qQEz-TF(oT$vA&|dG~kc z(>KrW|ML3re~Xx$&gsuIHGS6<$PYHgd3*Zt^5*;f`uW4({)_D|ayo&Kp1uM#bf%R$ zyg*Oq^V@HATwdS*Jg=W6DtfGpl0@k=HQ6rX{1#)|pWmF)l@}PWmvXlo`$_5KDhkI0Z-D&bJ-gt=pkTi zxujh3U1(JRn1+X6TDx?k4YyGXGC}}Xfmfqc;Bc{9?H`Vh444$sx8cUy-ewNPKtm_~ z;aZZUW{zH$q1N)#vQI6rNi9r(jax0JI06d|5X)5KJQZwP<)0i?xmEUXj#Ens8kg+- z(%@=_iG(rLP>}h&qv9@J;B01`!T{<=>Y8RtnAHai0D+oin0naPO_cFOl-+*z207~ zKY#x4)6b`ehsSr{JbnM}^4(>7IFD_EM7lrLGGK`K+nNfbF==H?C;pN&;<@vl``{L6 zkjpbMR#1K|QQzo-@pyXt?(vU*_poppqTf7;ZXzd`z$B!{O?j>rvaTRO&WMNR?6-K_ z%JN60ZxyP{cU-uW8}hiwhygxiBRU=uMna_4_%SfHi|uRO%=TqBTUT5A!dz>gYnja@ zwy;=EGFa6Q4B?gm){5}{6wtf4WNs`aN%>g}t^sL7#2YEAA{bva?(zV*Dnz5+zJ^Lr z&*_Lj#z2(k&7j8cpQ1GB$g@}KFj|pF4=DqwWQUV9R}Xf0Xv4S>C99J%_Tjr%Ji@Wh zIrll&K5ujGGwXTG-8a50Um}`u2+LY?@;I<~9$-cI+D6cLGx4ndyC zVrDC5F!;>*TjR(aDbtX~f(fcu?rUJpu_DZZ*3bHDwxAl--28{qT9#r}QHPX6Yg`cFOg339Ervl!W!y!7;VFzxk1ibvZ&tJwTHy(XLF@1gnaL@$wpcPEtyXFwVlx zCNV-TGXNQnJw!CaS|C=WR_WvAsHOUoI#mj-y0bx&N+M*&QYcv{i?%v0oyhIk4ioHF z0v;+lniMl8nZ@tuDR=dt`+1bsrR;U^sXLt3ra5|)adJXTpqet71cB{5ZVa7-QWH~u z)5<6J*N@BEb?q+U!(6m{{d)++i~oDJ=gz972bM}Ad0Wy&_wMOPngB=#Pec^sVVoXC z)z&^=-mmL3WQlko4~fui19-Gx4&RZX0!||kqvJu(Z}jv=MXsNJo3FoPO>6@=ffm45 zU*U03GX;ySC2~GJe0O>JLC3@E=bv9b{BNus=K%~WU3G9l(b@=FtObK^PurUx&yU~P z_4&7d|9`MQ>-l67I*(cL$2e92g20w68JBM^@BTE#!^`jgcK!6TY(vhc*xN$Q$Vn5L z4g;}I7~|pT^7P%Z{qwJXg)KRqVKd9Biy<Xz=TR_ zD+vU*?3lsixF1+@B0_$O3w!K>KUx@ZM@PLGUStu{Dacg*Ga2e2c?ps+xZ;0mj#wkA zfgpxn-`;U4A5-cquSzj5Wt0QRc}A*Grw0EA14kvuMpP8$6fp~%c~K6gEtUqZ6G!uR zI!=z-E+;hn&-f>&9T|3LuoeAu7cz5cj5C>_9E@RDakz=OwQsN*L8=jJr(sLmEZyux z65W!tKBb;6o;3)P3oO@TxMXWo37#k7eOM9)K1us;0mGKYv2JV4rCzN=unaC-Bb1=3 zr~Q>s2(-?NqxiIH` zyT1SFrw>2>dU-lOef#e5+c)R8594tgLvR|oIk+50m|0Z8 z^bl9{3wbmN!e5}GwWhd@F}CHdFfF1m{HUtbVplGBAhU33B{DYzP^kH7vJinvu#t#( zq$62YMMTZ>O}!SW%Y$o%zSYIUB^hCX7jqJ#7M1S`vn~d^3^ps9W6m{KR4tu6hh>?q z$x@|r&9#jCS>~K`-}klqQ8%o2Iw#))wkq(k%(u42hLtGmOMZ#?SrlKUQ)U)fHv(Wq zwuVn+hQ|$JD?=MI;KPF^rP3T#jrbBf1QNv4&I+rTVImKkgvQMJ=w&&(b-_4n_ZN6! z!8CPT#gF?^V%R%cUd}NPfkTu8sG~RNAGBA~=^DQd^EeHmwQE%SvV~xAYCNOEU`wD1 zDt6L|CQ06&vGDSctDoucFf2d>5s517sa$zQ^dh=bYOjsg(%@kXoT{R!E$#bTn+*`* zA%4TChf7J*_~pKrU27<1O%KW>XQs#Z>HeTUJ;@PT+0f9G5iMsvgMrJlN$#{xBHq#G z)?m{sm@>RbuA!84qB$X?%!K%rhQz^YqcAqZz!5t4R%f)Xdf9FUm&SUB1&54}=#v{4 zDW)}nY|5pLlVZfe_+)^WbBnM}*j^GrMVGdcY;uc)WtI^WuI=Q7b{JL2ES>h`rS^N{ zMp}nRsw0ttTlA9+VZ@})PW96rxf002enTQ4D4W4==?5HJZb?a!w{~vMMkG0kk%!9d z^3J8_LwmGLOU`^*y3rUvSYpumZUf+9;wCqWEN-;?NWD~P$_%L4q(J`7vbv}sL#k-{6Z{j8ExUO)c zfpCz?RHBV21c~}CxT%JpITZ#!T~4-(Zf9V#wePR*ZN37)rm(>&uE1Pr=sSuReS8>Z zW-6EQ@OGTuLge=He!hNy?NEhgHU#bmm7L*3D@1h-vW?TX4{!f4&X2d}_aA=wZ`iNc z2DXz&rcw=MdTLHhWGPwnPO)uI-=E+9(IB6G{U7W3R}97ZY-W{{8I06J7sV=Orh3}m z{BVBv$Nlxgr=R}?xXJnCj+oP_!UE`a2O&cA^z`ld;qB|E-*3B&R{BhkrLxv1ZTTQavHqBizG;9pcPVbRD;n8nEt{4L%G9UwEzy>1isOfsMC_#z^ zkLKHgSAdH`Ev3w&gK`j!9zgHn;KD`jW+1#^EkLL^D8k%mTS9+mUjjP;BNa;4HKIckH(6}sk5N~2h5woGbl?;YucC)$fyD~C3R3t)>!r+Jv6E)mic-6MKc7gg%89zA2 z#<00zis%O0cimKmM?OpxHcXanDyK~!MVDb;uh;$M-+$Wv_RIO<^6>V}Y=hepDR0xn>+(Vh~-1IAuYkt+K*HA&7|$6o3ezOX6(P zX>+%AoBIv(YI9%fI@g-EM~=)|QX4j~A_n8ws7eM>AV0LD5D zVgt$*PPmRxuD+*RCmtB%4i_G<5&;g0`VczA;t&?W@GK(#HL7?-T1PXRXhx!0QgL*$ znMXQUOk=t;G(Er(ArC)dWv)0}wgo=cn$v=?GtQ))x3%UzZ`0=D_7*?aW#xXB{0I+X zE&5<9&D5CSNJ)s;oY9W8wSvct6SEe@o^FO^RGlUfE&aqnO8{yLM~Yt|+%w|rn-r9s z_@)Gba7a^ych5k=u(rO~T(^fJY|)jnx(t?w6*7;OjOu%K!Ym9lMlM}2_?bG_jey_? zhYMf&5uUn|#PIRdkh_Z+##3rvNE*gIWP!Q0n#r$a64;?R%Vv_aWqTfUw=g~+3bWQV zNQqr?J!nMua2JH7OheVJ@>nU$R8c5Hg|DvEhzJPN<=9p9Cg2`foPrxX>!MH76sQP^ zvuml;2-_E(k;j}4z|wGVu>Evt^bCBT*pCU}=}oSV@ZH;n#@uL zVm9JehV_ZBzS22ra#8msD}aU(&CR^DI>7Ib@sTv7(x8f`dY56iA0OcW*2wY|$Gr=W zOD>)0{E-%D>qvV{@p{_$>_GMLI5}Xa3QlX)%hF>=h)!Y|&xoCN+} zzs6CurwxWY>64@E=ZH7{TLx1-y?OZNPh*^(fBRqi^RKdPz{zbNVIn}c6?n5F)5PiV z{PdlOKL7r=b$gL*qp{>hqyP)cOl6$k$mwzI*W2qytSO?VHO6wK4@_2sXtR_s*$)X0 zOW2|csfujSA$o$JLm`XZs3gTZT&DAqjxB?F3~_X}EC|Xj@)j_*6$~Q1d);-90i>HAF^b>_4v$^T z2}>#_K~r`t@=zm|#bAIxCTE+flm1Vkw6T!{1>@R-+e|riR%7mzH?I33#}dX#+8M}U z+F?dZ?cND$+IR)Y>4nym`J;T*Edws;B$E?)V{ zDV{EiNo11wG^ElKR9sQW878iT-3#|9xA>XOGUrWX+6>%>DE!<{9gyL%5*05IBC117 zcleP>UMF?;!!(u*5nW4Vj5#;K5>YQu4O>e#U$XaYKXJ6~GF6eW>2}f!WG%bR*W3Q` zx8Fbf^5Oh&div(+^6u&K=AsW{oJ7Yk_gws_(&Z(SxH?S}Rl)%FVh>bk9{7k=TIHFR zL4Wmhc8yX(%V0a^ZU(R5WKeg_Kr!w?uD<zKAIptrnl*3Zi(xCXk)oWX!1B3Y?6#^TbKCRSe%a;2dmrucj__ z#_{_!=+MonB08<3;MDvf%_$*lRGbgxW-@RU)mbt$P@w?7O&De{a$F$7;gQ+ZaxKJs z?Z<~y731X)=Ln&+6qrW31H;K7+yjP3r0iC|HYSz7P_DDDygV6CGR(t>H8IJ07p*OM zfv~v3Zwbo8OwUA@dd&HoR@idPQq7 zSYk&qK~q&yU{k+1y_zt36M0U04ZVZcVY(7A$`i75OX(+NN5eDF$~TRp)F!1rxgazF zdslyW(IG_3MeQ`o7a$Uce>W!{zDw{r36&&;J?wXN;}Nh?Wmp9&noj0ET5E=gYf4Y!Bbx zt{*@D`Y%|!Y$xYi#B(z)ry$Ho($p|6@3yx;*!AW0!%r}iF`}?paE|*`Ca2RlKZ>08 z*H3o+49U=YZ3ng1IY{kojo^3jW$Y3&7~oYoB&v}59u}4Z;L)=b&^ZP0JQQ0uu=G7o zKBKTmfp&;p3%C@}(dl(il3-v)%?QYjH)Rht+-`^r1^u;Vq;%Uu>j!OPW?n{FTbaL zVE7?&x+D%;F~%22-g;~xdjVaAu6%k|nkgd7OuhUtWtrg)ssW**%)gx)oW>m(axs8dVF*TFkj=HF0a1MuGh&M2BdjSlmrdI}v*T*>Hs`fUNqC~$GP613 ztOE124hwKtbrQkl23WmvXy`(#%oy zS}|d0MEpWA1fq9&AE5`dPNh}960C(EG16Q*=Y)Cz0hTC5lxtmnT0@)|o25EtoI~rU zuf!J-_~4N(40%j1{b6e%;<5%a^X)AWdD^|g=~`abBMy&n>%vn%svc>0sEhWzLq>2U zU92!}STM}MY)+yNn!FS0k?6#B?#$aT2aO0Lw6l)^_x34Pxf%aXeM7fM=$^m>6z+U` z*(@pZymTqTHi%fGH06@Egk{+|QiYRT0dSh-mby6il%=_tG-W@_Zb=R60w)mN#9&^K z*g&~BObSVXA29w-IovxNigkFdw={6&lF)Va+>tM4-?!1P!5|DO_@9{R@ z%(H_37AIQT36&NZE8WH>%cXK@##BmK&R!C*lu?_Wh+jm?5bFk3cr{QiSAF7sI=Txi zp_2P6s##OSu^+Zfw89NyFpcdaY*J#Y{ECwuSvQ!{;gYlr%>5`3OkXx#f*@T=V44gE zcdKH8yxT|MA-n|FqSC=!rpd51^FMhmtt;XN=O{l(Hbl0lAxX6!UpFg0Y(Oj4vdq0f zStz|vd9^hUKVpP^4fOtHZ7hfHBH@a}5d>xmJ)*MWI`m6s`+#DVBeNPWNzougX!nGf zW;x?HfX!hlAkD+*?G-R2!7BLRBgpCL=4?{AhZ!gm<%z@`IhL&hAX8J=3S7PGHu+WZ zG5L%jRT-ptPupVB!)+6o+Fh#a3y=SLcYvd{je5hFPw?2*6)VKX`0yWzEO=r1*` zCzRaShA!~k%qlS9LV8>&g|NwKJxF|@CFlyJROMzGTOxdxj5D^2ZfD!qdil8a&oQS% zN{1A2GN^B*^kOy|1;`4lVbrtiI`ki1L>$_&>?LAJ^?4>)f0S1!nyl(e_7Lwf3 zVi_%o5r?agQH%pD(0}egWVy^sg+YOpFyNS8$vLelrWKmKs@4*;6Mv8qDIuq1Tc~zQ zEfPmUKpWmvUNOqP@H^LXXGN+ZlzC6?h=5Tv%eBAkQ10NC!wqbz3|J#dk*F1E4ja5g z0u2cdvYP|R*h48SYN4j3mCDGQG}K~LM#7_2LZ;OfHIQV9rs@q+Wxi8)29;1k}XJbFhilU`9r|(Qz+|rW8I3x`aGuWI=^kCq_k8zm`%jN>G>bT4W zF^_y0Y?+5OV+*!0MI#K*2xTyeQMLqSIs+Enh>fsDGf5p53zV2pQHYrk)ohu8of z8h6m#EmCSNk4Hp&u$d=0aoNHCH)W8#dwedAFdWWagyjQf5ehW>Ewq`-Ij?b|wH6l( zVj~uT-=$|sEXyL)VJs9<$2N+k4Uz&^(QZuf(hc!wJ$g*3R#@PJ14{vr&@ud*5=(Q_ zupn#ov3V{T$U;fooVA)h(uvdsIYa77H*u_u;*OXW>N&#&frQ*b6|5SAY8H$EViqS` zgCxt{Fma|ZF)W#5!L%NYh(O;2FEowHJvnR9cNUFZ5UAlZXA~J5reSod&3Z` z$W}rNC)b1j#b*`oLF}9A1%@oKBO+ND1~ST2KJ*9xX_b*LsjrbMenG7D|!ihfyNp6#d;aai$G^a?a@x!WN1g(eB80}AoK44feZ0K= z@pSp_`uh9p$A81R)%ljiC`Ui6_%M`~%2_WzT)z2X-JU=F^k0{~`f0TR+ObpC$deiP z1WaS!%jx0r<_8mb{`Idmuc8|cf+Nw?*!27$qWjBd+h3!I9DP)`Gh&@3!%8Wulw6Br z9b&oIv*n?iM>-Nk0p!^$uxV?>9u~{pE3qlA;I*j+dbcS=ePyc!qU;L{iU_qN!WGU3 z{Zen;@96~h-{yodG{@yFH1Mznwv9l+eiew2#zKK<;d`eJYTOkpCO*Z?vq^PDJczz{ zuUM!v@gTXB@X(MmOvsi^A#~!&1GKTD52dRZ{Lf^SR1r;_6EvyRi9lgi9jCH-A!P;z z+ci6g>d^!(R@8~fJ1kpFEtm?}EN6q^%j!e6hDI(p8;7R%UvmyfCUTsFUsLKl?HCt&UUx%8#?bBLz+ls^`#wK>e*XBfo%D1$o!?v@-k#+_#}FuFQ;X^L)zWbBvra5x z6||P@gke==6lw9O&)^BjBnzXziEZr8$Kk|#0ay*_Q9wo3o>2oV3vAw}U2p4k+VyqL zeckrC@3!_xSBUNDQIf{M-771GH^7!l$LLO%Dt<0VRp`XREWp0J^j48M{(!P4i#|50hPU+R9MJ+y4Vz!JC05kYvysan|pAKn3B`mD`)hzb1k?6Vbhpso^F;+_rVerZh zi#mdUgOm)E(#UI4HKe`E2PgJ|xj<2OH+iRp-B)BA1^@yU)m^f)i9V=?rp zYsn-XHwT4g$djHm+0@+`=Zkf_rcc>@Q9~rhp|%YB6*x;CT{|V z{7jnkks6k3P7wM#p39I1mZYuVlajgLZlOkMvY$G{^|CZfNIX%J%^b9IBGa48$VoXy zsUc}&(JV1DX4zP*q&0gW(!}b(@$kY$Vvu1fgay{bvXQBIwG|=>hM|)$^?S{LkCU^W zG(Xn(XhWgKPKz+doUdj>gi%F9u#uc!0ItXdzcLd zDFJ43+1+*0uz_>YZe`w4(Vug0gy)bA)FekN?a;aQd?2Al8pCi^fn+bxXsuF}$=z^L zg`mWx8M9W66x-%ZRsZ7lknd!SzWCfd1S_$k;_$u3E0ES=?=s-2Q1Qwn9dvYs*j_h&}vCC=a&paMf%RHeCmqZ(Runre7&UJAu_MA^^4bpOd_clT{*V(TNy{8mDio_2N-ExXE+sQ8 zniSNev$bTG0};?rjg$(9PN<3b1hdMS;f?4cDQvc&Y7-;|FgnT;S1Y(m@)Yt`rWi?0 zl^e8cG;z9F@&Hi^)VA$5qkR?~&a zrA_Vie=9d6wOUdo$x55YJrB(VLVs?!I}S2pu*HKQ;wlqShQkm>rrKJ0n1aDz)64GI zQdsOp5L>2VrmDJ@Z%)wZKCF!i6;-hT9m^JEsVuW;I#d>9KnMJ^mo<^+-OBJP#Y2YK zfCw;_Y_fKYv1Ak7R3>D=M0HJw>Pd&Y0MFVr5VxaF{_>Uzl9L+Hsp} zpSbSxcD38&tOYYNl%NTPF2K&y zEJ4cs=y67@NGv&fxXa7BK)gVyABy8_BEd(I_W{r8=-XZ*0Yq6OB_CaHvOkEF{mC+v zNs|RL4|5oDKgzP$7G!J9WuCsV%+`(B9`V|zhd}0x(xwsh*lkVjkclE4QC>x`a`&!8 ztR*5HHdjPF_Emuw&-IwF*wdFVZV~%5OIt4B&H^EuT~6hedp0|gSiL0zhbRczpd*Y!VF{lWRP?KA` zLF)fh^l4HC3GARWX;`4^roNp9KaXUTti34rZDQrbwgl6)OYxIm0z3rbi6ivwYsgW={= zTe3?+SD?dk(j&~_-{t~faMEHUDS#!*5ll_W5OLhn&jX{HD0Z65aZ*4!B(X@O#J0+M z4icC1Oc87ZDU~UCf6MiVA(Qu!(323&$*!&l@mB~HQf!m#<0Q2tI^#~E1UeBJkz+_` zrIa>FM%0O9d@1GEhvNii7%y+B44np4(Kl%k9~8PF&5N{{4v1MyO8Vq0=$T^A5MVw$ z8KntXaa!rM>^-3?EFu5Vw+3i}HwerwgvVx90x&{r{TRPa-DUFta*xu%sb$>0L|_2CNCwaeC4> ze>$DNy?*-J?bBZcH#TMLp~%6II5y(h&lsntKb)U_T>I;%-~Ri$eTKU6wNm*7g?PSf z(o4(*VVu7C!#F)%Km0sjKgz}mueto`di-?K?E#SW`eOSv4jUx1y2@q+)>;4nJPJuf zK~%4mj{ieSMec(z|8bd=qeGi-_Fee#{Rocj`Q5XjlI4>yaZ zH-gex(pbH0jC2OM+H+@5^o5ZFVg%hKm578zA~C}#QFW)~_%Urw;sBfKTB!^TwwCV^ zych69HWq11UM3J|>vDUpFjG30xoBc!2$Du!ZqLpYv$f*Vtd+xl#`m?x731RIQe_Kg z6$W6n6pup>n<1W-B_}ZH$uLXA44S$lf6?MtwdKZaG0b*|k$ZPa@p0POA1XW|z7cPP z(veyX71J^D6X-4H_-W4=`9c_v3gzjimMI2#fi=JZYt(*%v zoTuBgyqP<*3%;YnCZG$7C4wO$m{1vO`N=ODXKtuYn}CX`?^^+u>Y6g4Di{kxWyn<1 z0Uct)M8!tbf*1ofZ4K2SqGl?xiw>E?u!|1U4NGMV!w?xzS#iD$mWU1oFsETH+3orL zXMHc*uiN=yd-KiZ>FIQO&@tj1RlxRT7U}qf5ou(ULe4lyU{!bW4}l(VyooA6>23N5*m9Es@^5)$&fm?p z=3_W&$uInyal$x@4l!A`XPej9K%|KTLitRNfmr^sJ}Hu|2GLyMFk0 zn>SJQD)}V`G05gs9;YBjC=jeAqH=z7dGn*-{QBwN_m|&9cu-=^jsWR8COv9PLR@Sn za@rn$e0cZA{r2+w%m2G>pLjrT?*WR@kDUe(3DEj@dHWCN%bS-^f4zSCMZ}<^HY8eW zOA4WdEtoC6JYC-XxYm66?a!Du**3tk*cJH}*e<$VFxPzj3^CCWCg)+^Hw%V<=-re0iEnqMI)iSn`#K090K2Y zK0H}NlwTG-%Z_7JyTuAb~e#z zOJqTYVTh{CVRJ%N}o^}ST-BujS5=`j4 zu%S5ywJBk*V}&E=UR)=MJ8A?Hvq6q zw+1A(q`SnWtKbe?O}bll%|t1$mW(C`4iyn$_1YY$)(5W9N3cU7h@({m0v!X&9HSoW zV1ZH<$@~rz9@G-T$vr;p1Q_%TV*{{kk72@$=df7&Hw%Rz@C`*?+F7g%o4dR$*f{yS)Lc|lm3m;Q6iNkU<=!d`&zwd` z8PgnES%O--S|f6Su5o+oR$fNyP4BkUeEAJIad3sYOgJ%qz%w6EG7MBKmlifbMh-3p z`;=S~-&aVY>1>N2Q7rE@XR8l0`bj2D-C@4VDVE^xa!oz_guH=C)@nxe@PG?YD(kRnZqe(1ZrD@8y z+PLEjukn!tZxm`vt;#_S+4Fl35YVU0D(&E}m%k-$j1%6k;404YMe zzhEa<*c|Bu;2zLbPfCH{<4edx7UO$j9a&q_xJA(4nmtY%+Mk_|i!h!;QSP5BRHX+T zm}f`(siT_(mvzAM=dL2GQ4_b{y@YRi8@L2x#amOZ(qqL~NpE3|^;&=6IX46&j~D@ceQlKqW-aav;= z!tCWC*;k4E%}&*zU8YGg$SoNeofM5zu zGGzyz$T^fSe;|;HoFB$^v9(`6|APJ1(d7%(k;zfiLI*BtT(@8bJw2Tsz8??o_SX;B zPyY(rBjgdf1Z_J7p&LZ|e2xR=f^2UdzWJwZd-wUn|JtAbCIH3=ye{-2$dGwDQD6oW zefZ(wn}4+Z_VMTczV>I?h8a+$!b=Vca>=>g_V(uc^V>h(KL39G@Do&IQ(wgr>9RBA zd=@>e+YS4Rs>18piFoO3h zIwfb$ICstQH!BFuFk!99L`btOP=pxe#1*Uyg(s9=`=J1ccuP;1KTxGnCmeG}3#U*w z9fv8|R-1W2=ADU)G{;%JCsiVCt!atqkx+8nGa_!sZ3W|uCW}fAyzJLdYwgJbX42)X z0%F8OLn-iO#}0k{qS5F98AJ17}rc zTbcbDCtW06tRB@&pkxT@tEIFS+-LP>reLf%BX9uIVq;;P@B$I9@u4;WwdLW_p`tKd zqM}{_bBJyJ-rzYSLv)A@uqZ@avxevpQOtpD^J{GGe%`IEU37E&w;;|Y;uTn92BygZ zUO(MFe7>H4KRukcckfP5PvdgdZK%NPgbAC|mV33iF{Rn2D%@OIXk{WQL%lGCQ*;?# z_I112%Wd7R>w2BneZSr2T)S83@RA3X;%qjtB+_#%U5>29YBeJR7zcQuHD}l%cokNn~CtYa=TT`TVcD` zwKajnUMYvKMRl?)RZ%ShGB_K+8?3>_)8?xBfwZx=%~=CRvxUsYF(y>upeWPca~kk+ zUejX8l~4_u`t`xg>%UNuciSvfb!tHf7fBnCrgGtXs>~KlA&g5Sx9_f^Dzj8JrSPp8 z5Cn8rMjh{Id!VcUiKDXe$syg~d?74$2x(a_Y)LNh!WKCH<47+?V{z=;T;h11HGI5 zngHu{p~=FB)fz&DBMwcVq9fG8VzBW4#9fGtdW6G4B}|+J*@BJ^*F)TUbQ$w8N`yk3 z0b-6)G=nWHQf6q8QOBo6L+%C#fAE2}yn_G4-Ik(Kza(dX##+OEfODN5OcJ_oWY1%| zkh0kx@3)dI(rT%&KuUhzF0CKdh9jUQ)H)G&HfI>Uqj%iS#vC`Z=}1zJTXH@?o2KXx z*JsPA<#W7zY>TX9_mbiI#C|c}99e~%TCmRt8FWbigS8(HQ!I&+Io=k8Yvh!Km>MXk zKP-9oOa5u(%CB9ip!GO~-x`3l`;xz@qx?%+IN==>zflTnP_1`4Mbf@156Z2JvR=*R zejFF6q+rtmr<=tAh=U@Ma?Vm9e zUUJp7Z!&HGTo1OUJ~YDjX?y$Q`OS~>_0xyH{U_Lq45m(Gcd9dvg@tGFcP)|g`R(7G zp1!|+`rGy6UqzM-1#$T}qOLT3h71$3oiE@1Nw;St2`UW};ZE}65NR*J;#j`5~ zL1~!-K^h1hnJFGvkWVMK9#tL9Z&JCWF<`0f9Ll7|now3D+#F8!p`ru)BA-O@YB*|* zOn{X;e^7u|;*po0D) z=G=WrSZHA9@_XQj;*3nglne}n`evZ{_ymZ=u9lGX%KNCMoMrR-rm7_ysdz$Tg`?FG z09|z9GN5IbV;bERl`tZ_^_X)S=dt3_Iz@0ul|X_rS{I$B{tKNEenXMh*hw$&;nX)# zoiv*L&?opCV5xd>e4U~y1F~RiimDFS!ko}CRmBt-un8Fu-)%iahCt)cC{u{_QQ2TOofgi-lk!n_Ig{dS9^Y**W0>Y_v>xn-3885 z6lw6&NJv3rPzucfMYkmGwZE(7ZWTzwKj)-`lHj6*6ysMSLv&LeqQlcv82gB_9gH1G zniR2%M>I~E^*3UI5eK{QIaNkXs@iJ^ReV~>c@`gGh&?5~QOkn|ar!JWnPM`dVy>Bm zLnkYmGPz@UQ4f!3m_IHwpqqIEp zh5K+ASh1DUEkYBKk_pOW`xwY@c+-Pm2+6f(*_0+IGE2S%th8%b9eG0wInw zT`eUT5{t-q1Z9!lHoGa(T*6Qo@myvuOD^ci5vr5nb3et=S~xgKyq5qTj<<(yMY5a$ zUOuS!mvPy6Xv8)Ax|}jYq^>NtgX2nibCx+#3A@67C=<~WtB@jX#bVbkqhU8xPmlIM z3FDpV*u^RE9>NehTH9af}Yk+Pb|lankFnyn=)AT2yMVENnxvK%RglmJXY z=0vb_h%yo-CgE=!ghY;Qb~TdmbxH@r_NAS?pTttKFv-<*Ws*e!XigK)#i%P|nx(aL z=(e=o3i%CnA=gq1gB`<6FwQd0B8qvl+lyhAWG1<#%~!3Of~2Ozc1@SvGe9;uKaTBj zt$F+O3+5G=bOyQ@c6#@5hkk1dCSH&GG#=lb9=|t}=TCpNmk&^{x|-8Ud_C?-0IV`( z9B}3WM9<$`-u(l{`SWl8x?X-41;zk*uW@WBGe*1EZ3EUa5j}ra-?K^Z}yD+(V z2qboexTQEfy0OeA)?WKonst6KCs2-~6OeMuH)Q=n+IlT20ZII8Y{R9bK)8gzgkjKt zN_EIDio1``?U)r4MA;B(cd1K6)UDg(ibO!dGuIf>oGeSR1CoGSufqq9Sr)=>+DI)9 z*}#_lbqk@ZinuPj-^S~N6%#+esB*?k1dADEe&OQDQ@L-uq&Tf+J~^!xAO10pU}1Lp zhxlnEvT%xF->6gC7yWWvwBl}$vk{J<9hG%`DM z0ODnmO@_!##*j7O+g_HM4E0ozwLv!M5Zxj&qC?d8&iFSF5tXqd6_Rhyuzg{l_Hw)a z{{Ho{jfc~C^Y;AsxSbz#+XmEBmN|)rsK`*+6sp7Ld!P35vaYxFd|l5k^YwMVzRuk} z?tnxmwb9Nocjq~oE(>nwq^?Qu31lPtl(ktxUxF8Z4sk?bA=375@UcQXm@TrY4%MOR zXUb;Mho}cKVlT&_-;*^yqJ52BEl~4<9=UNP0YXa9-u8m$iPh2<`FJ3q(n6&Nu zmMAY!%MKfO68!4JOysVphY>CkAq-wG&$0P%ueH2iDk_r=V^bYwz19$<~vcFFGD=uKoEp+g~6a$RjcqB#@iSYAC!$J;z`R7)U z+spIs^Ybr|9ivl(3}rz#t_x%r1AznvGZ~lD)8Aj7et7x(>-EDwi|n!~y;urmW67FL z5GPmnHcTMnBB0Ziqnorm7CsDgsfdoPdB?2Go2Ehh*V7^^GA5HtiW7ps5v$Mn|;r$ zp8O^iK;0vE7rO}LX0%*RMJFoyEHs!y*v;0+6|kRDd>pOuq3ocwKP79;8a#Mp^^W6{ zGA@((K$Y&~BhH!dySz7sBHR>0C3Rq&BNvqk-d@r5u&u*w@XlDfB2-p=T};WjVQzMk zh_hGo)mgIk+7UD1J|j1+WNamHS%N!_{E*6n#Bs||;-cWeBAi)jAzGO^lA=djcoh_B zy#b0z(%)oZsRBz43(DZsa<7*Sh>9*Vg$YELZ+ltGJo!QtwjeVqddM(Y{s!i?JQSEV zRTc{*L!m=eWk$M(nW_wvVYaIb(G5C)O>Bs55_Ld^iicN*$n;%%qF#ey82rqdSQT;{ z`h1(u*Y*47+ez^7czS%Zy?b|lcv1{mnn@72-qt?X^UHjBUC+eymXBntOE>!?Fc~r+8Qu@5l-r z7H=czI zdWnh9R}86%DRu#@gV^b4MN??2Noh2OI!%2=7&=ThA%RprX8qScBvm|^~k|KJJ`Bu+c2yn;=ZpGKc>5#Ak>&%`(V5meB5E6(zz@3?~#6NzJ@?8 z7g$)pMlesuhiXTP?4{syI)WmhT0_9o!V}`5@Omk-1Bl39+eA-obf?tQV6!$cD6Pke zJjfeELGUVOxcfhnCNUKyC$L!oeU>Y7QJw1EmfyY_&$Wb8L}F&@HmZ9b&#Y8YecCyc zAP%c4A2NT8-!D($%jx8nmn16rgn=cPNgU1;VtTyVq&q$d$IU5mr4hhwW=Ya;zq{{& zCR-G>J63FZw)I(PI{9EXLJ6ozYR0V0+(Ro;f6C=*h!UhBlJ{^|Npg){1g-9?G|TQ_}d{0;DqIys5N$(rqa8bUK;kz?wN;Ymp3h1{Hn#AYuIwA2K#k zbwDz681>MAsCH0bn%V%o@_*|m4wPnVAt^?ylmS(6DY8jj7cfy?-}tl5b_URqZK9@# zv_~6n^s2&PfjPz*G;&Ujm(y|VC!bBelkKc}vbAi!+T4rIIYSj<2}N*#_1qzniwb_O zcS@)ku*u~~$AhgkUq0A;hM8xz<2b1G}TM9g*>m+vm$ z{ym_d-~a2nz8CR>{&4hP(sF^6QZxWqrgA=i`}gDY^!oc>*X@IjvCQ`d3oro15Y>~6 z4SInqp=}fRNq|>)j04j!;R|9hBYne9C45n6ax^~F93<)J7*2R=n0nY~6xH0OOtqN+Qy^Hf08xc4m`sSMC>BiCj6EeVg{as50E8z! zDMXIf~hW%!@HD9l<*V}%Z z(+@-y=8ebJ2HL?12~kHprxFn$@z**iDN9%|1|++imRUp@bBm?v5D9v32#=Fr`QRA- zK>iP>=1|>6RD;a|A5n!gcDn>;dEIb{K9r9H38j|4MH46hvPW4rnAuu!Zt|LIH;5tj zoEXd&7UuL5jV&sUdJ=}mHW>Wy3=fN)EYp_xHWtP>Jjr6Nwa4}rk7-~cQa^l}7-ccz z^?qUPPvK9V*4_YzafB5`3ki*p+%dC;0OKnWWC?gTVFZP`lCEom-UzTJU5+ECRRleot3XG^wF#k-W_dKl2=8jP zf%ji&Iuk@gV}a2ch9^o<1uiDJ>YV%FK_17wn(LExus9Biq_xm2JnGQ?Yk3oBG%bx5 zAs$O>r?g@zrZN+_GbIclOJziQc2kMVS=paZ6MZLB+2_Fn=G8=f)){OCwF8^>u`umA zC6+x++VHuN!eKOQs1-Y{ERc^uJGfV_`<0F0LuK%?28uljeBr1F{EGoEM_N?w% z4%V9Z^g-Rf2*iAJjVfaR9E$WTUl2>0BSbn_*9f&%j8x51Oc`H8a@~%tB*_YnaG1#A zc_cJS_LKcNfD^jANFdELk9^$0ZfO60rKcK*X0vfnCDFBYgCxlvn_1&)jxGmV^CzI~ zWjN{S1RXZlx_*Z3mb2Cr@qz5s21#HCwD$Lfw`Ne^*z#m^u9pvMJ_kvg066jTW8<|P z43}3rpWvhq-#)zgaqj!eum5WEvxwMHx;X%A-=z(ZEv`UDeZ0K=r|sdJm(PFQKmH9c zQT;;NivmJmD$^q7XS;m!kLRZ!USEE>e)8$t?`AecF}9lJWNP?jJ_W#PTdt0OvOmTkZMxFr$U3wE^V^^ z1vn*G=Jclo23s`;mv|pW5uqZ5>WdZ6BLkx*P*5{T&mx#AVeWum6g!3`JDc=Zs)HIq zlj}|*RC{Ia-Er~(O^;vt)r}#`o@sPW`V_RLp#xYIs$k|{_Ru=1+XVPCj#Fwi)Ve*| zY#r{CPpLHiQ_R%Fyeqe_Ymjol3ciSa)kNBH2;a1)Kvqxtbk|Qv5(Ka(t7Q1vq)(CC zJ-GnyrZ$C`qT#*DDYNu4T1Y+@E(H0+>QyS`+!c#h9JLCDWKJS|6_kFAWfg>7Od@wu zw3#+DYnj_>YlRMiVTr|&r;Do3Ol&PvtSF2TWH*~0`SD^O23Z~u@uHc`^_Z0;V$-}Z zs(8u0NEi{_#Fp=!F>Im}I(TZ#W;#`e$eK%32W(r*G`<{h^oodzx?YiZoET(beR#fo z{`?}c_S^1$j&6_v6Isj?0L-vh?$nw`2L=$W(=hS7#R!^yY@(Zg7~K%Mkc?cg)A@t8 zABAU{$5&xV$9pg)GE`KsDaMGJ9UkeZIvvWM@POWAaAIb#r%$hug;t)tYLsx;G}uB! z7o2Qn)3>+yu{OTrWEzZ#9t=Y)?ncQf9}!|;LdSjJ0~PqN2z)G{s+LCG4fkKrA7QL` z8c`3AXq7P}QkCE1+|U%_emeV#+TK)xg@M5t4Re~AN%;x4~GYL-UY^ zgYsx+lXn_4^Sq-2zX#Ij~_HRTW^>dhv^ z6@@#*SsQ2kM7KR$Wa@ebQtr)ma(jYg#)sNuq$L;MSSm(=l-b)=d9!UK7AIUP(pwJ7 zGwQ<>*HlN{&i1^9bPx6cgxxvj_;4S^zDbPHU$ z%R`L8IGtoPp12r6vTTI2gII!)YMMDC0!?%D!0fFzC73AmE+ZCVv1=14v##o`H(9gH zMv>&@YbEa`2}n?&^IWsNtX(VjL)i`kNzt+5gX0Bl3az-zj-+W6z9(79owS|`vA%j| z`E-#A9n|pho-ZB{!K?%OwpOim@7U3=v=xm1H56#?Iqdl3T$Q0IfSF4^8e=ar(2F)( z`REcsq80JA=y-fdqFehe3C`XfXxoP)jizO@eT5I|%cLmo;Iumx8F=k;ZaE#+Ms%2_ zVmHUU;(&YU1qUu7+=AY^T^l&F{pNWv(hUd}ED|$Tg`f}mrsn6Kgw4c?!U9dP*O1u* z-+E}e={TwCoC~j?%{SE4qEMx2`x6eqqw2{4MT$a+xu$Y?)XNj5%$JXA_mIckP7~>L z{qohbWMX;@SR&_l=Qlr!oL@fvG+%#Dra4Iyq$oqh=+h256IU2xs*s z<;!j-6Eq_78*4$8FEr$_8%5-54;HIhG!f%VBM{Y+Vu*OGR&oK10u@GC6LanBRAvCTKnV!~%hJuaiQ`!2R@-uBzo%*JV)_3R<6eeP>5h#zhmXR)M6l`EW@m<*G(JZUa) zp6Q3BLs{Vpgwqo#LNU)7?f|niD(azh-46c>@{Qja!`29YWX1ec*YAqNX?!wHi?Q{}S9PM1#ZQORa@A%YqJ)8YdOu zLAxY^3jJx5IJwC2hAt$REF$xZuk4Iy^jXE$gKbL9E)3h3NE%S`iTt^#FtkTJ9rypPXxi zut}~1ncE@}v%7L2KY7ZbZ$Ai;US1E(v$4`zN*G2g34!*aAqB@78pTkHvJ>6$m}YJo zprw<3L-J_iN%Cx15#?hI6xV%s7Hdz!L03f+`nk|fU?MYtnU1xAI^da zN2L$_g1bqv_Vl|x+)rQYQu}o5zo1*&!7+!18{V}xXc+kwPVjrb$2DG9Kwld<%IU8` zG;^!E-V{Zqa~&?}pE`9KxjK2WExVhk)^~O};otBDjV%V4?0zITrLZ}y{8id8i#W-9 z@rPW_hsc-LL?3Gz(9c6r^Mge>N3L3rB}@6C)D}B`ct*4D4;sS)G%Om0zATKy*ESTKfw;%amk~z8bzFO?{w; zpB5lk2EA-=f7nj%*7f=I^S{AvGD;EFrp+&VnCx2r8m3@-JiYnTc7Ahx|8KX~UquX? zx;xPuNb?b4MfLBUNMk&G_xSct^ZN4n_y20^l{tcjgZW}*jHc-#0t{e4w(~dNkJGoe z*U$6zd7K{H0iXaatgFSjeLL38Yyz`kt@md(X*ZYmuM-53xY1B1(c4j zu2D^@flwBmfd%9)Cz?cMVn)@8YE2RuJNyR`%CPGL;~Gq3%9{G@fE_*^knQV&+&gyHllH8J zT7$(SwD36UBB&+*0W|yKePx=q-f^+bqmGSoLNIX2^U#B&FA`p- z9+g<>)`H4f>9D%u3lWoJc6m}o@|M=O#yrua5i8U2;(r44te*~;?%v5=Z%PR^StBOV z$OE`V3*rFU;Ym0MmZCLJ_S!3x?%Q7pXI7xaZ)*1q=+cR_(JCKuObZNUq##^tCCc@27?+ZA~O8)9^e4( z+F`JS7w}6JHlBOrB|)d{uUZw(pQW3^WN-#p7GW?fk1@al0o(~Bo>?)yz-v_02+8wc z2Xj4^nc=wi#Nwb?FK;?mRV_8}cv^U{!_TI*otsw7(@kKEeJp0}Fz^9}JXoZIUlwR2 zWVwl^xebHHTwJz1*8}NOb#w~KZ*E0O+`>shMVYGv5HL@SVEn&uJ}q56s%n)IRlrGt`?(5O5E<;v%419Qnc`IX#^_ zXk$o_z*^g>KaBs;MCt}X_K4qcuNogj!V&maeaP)FB%q~^uGo`VM3}>m zS=IYHTPXUZWQPb@F3y{MGyphY;4 z!IqsFDW%e9197HaIj9+yZUcuwu(`@kX=$BGg7llZ4J9$%J$vVlTfTgz-F?w`{_Z|x zU?_S=n6$dpT958-W)s=7{@eotkY*J1FWUG@yZ^x}hjO%y6H&hip}}yx=E$k{-TvbC zJ2D)bQ)DcRIFt{VaCdwhh3tY4hquNj>BN z{};yhtkvME>%gsPj1bVo-9*M3nh@NHr&S}CzgsOBGIQm4Z}+{wdI9W9Yq7YVUFBSa(lM7UYE7tB0CDE^Wn6l3@sN9noi4Q3(DC^9 zX{)N_@q}+aDyU*UMl;`;oqCV$H<}CkBMSJY_?WuP_TVyLPGb7-)RJzhzwgpbQc*| zYO+B#RTbTqC{+FMR?M-yq^F39*iyk@I6}n`rKHBPwM%Sm84C(nWD{TtuzXWT1hkBH zg|O~Rutcs}15gFHNxtH|OvdntgjW2*107!IiJv2uAtF*4A8dn{fwwlH&F6`Uh)P78 z6&OdsgmiC!JL)Z2g^D8v7$J|)kP8c8Da^|H;Pw_4>nNWwPW%PKA3S3vcfT-Hkw?-_ z+u_j-*KYOR0MGl#avqZcXUNb-zF*5cLSgiU_?~iVJcOQ<(NyokQb{3AOj4PK45_S) zIl4R&!DzXV$_bX4X=4Q6lt(0XfPmBywR!zz|8_}-`}uB9IYLoUwzP-BVhPuo?J(_> zE8z9qxL!Pzl*kD%Oi@ZR*Rgz`b}3ViUUDqCL~0D*u&aHT4FCzv`AUHI^^R`lBq>Pz zazU*x2?+ZP(5)|D6-}x2#fRQl_A{ma*k=Rr*sM)79D%cLNN$WO&n5d0W6aXq5Q~jn zSW*OG$U6_>B8#PUTQsSx3gYV5^IS~B7i^-5*Gd92WYGRNEOI=S8xhDtZt@Ri9 z;;OGdy{H!-TPvil_1}+ed=a_%4+i%iUXeDZ9IyFWWaU3@v+-+dx~|C~$nk=sO)NOJ z+Q}nFF2?;=oh<3w*Tv%BOC#(dP|Sbt*ghq)O|~7{Y=bRQ-i_cu+XFAvuIy|-mR-_5 zxVyhvW=fS6*`@tlB#HNpim8YbwyU42I!+=w=fZrYv`3bQBn(-R244?(Za!>)r+k)Uq9KpK|JMexiObRlOXlIR;}l9EKwG6LG)ofem^d6Z=XM|m-iwQgAf}U zxhmi@{6M?2BuUa3SsQK?=XdB2rcZ6RN8R{Q^6(@9|OjsyHaUQtQ_r@3~u5 z`9HmmmkH3hqeEu&*ODE|?no{>JSVkPa&Pf-3C>;rbL>eXt0&%%<#=x&ddk%Crj8?t z5AFR|o>D)xl)g3vt#7qr)NenKS0s&}dZg8ONnWYm__Na8L}sw#jc>w1g;70}@nwnVwqD#jcg{+1|F;Xv=OMOR73qO~Mw9KH!~Ky8^cDz8RO zvBOA9`Beg7Z$HAD9tuLYF&aj;#H##Vqu~x?2AbLVImc_hD*{W{Df2>fK9E=dTh0i~ z2XUrw$7*`O1R`P{F(Ftc3z#gC-Au(`sxV!)G-gVdiAE()HB9xV#MU7dRkJ1OItY0D zta~V+XB;L(u;fC9sG4L67wmkdB2eMnz-*3;a)2iTBdRo3Xu~x@zT)1zAl@Qbqqr`4 zF8jU&sxpo<#8BG-B@p4j$ZU!F9K1XN3BJ80LLOKKTXV6}VZ;H@_O!G*AVJlLda$-e z<$8E%WATzGy}?4It%B%^reL;=`9}=xqZvG9`IyTqy#}L2QS(YdCZ~tJm3*5w`css7 zPXeegj1p0V%KDEcgS;sh@$}#qLl4*&EJ*87PE!l#X9yN$gan?4!^Nho!DD?V{az4z zL(5w|?ywXbHxZTC&$yxE9;#wY8{I))CI*t%Bt!g7iI!)X-8d+MT8{RQ_T`l|%=FEx z7il?!OgY^GNNM$QAk}l@(4?Lv$2jC@{JgJ2Ab62xnmT9cT-`%@!TC7LjtgANH;eC8}zNA4s_&@>|gLj?mvKf=N zz>0Rr?gfz+YiI+)dTC;nx|X7A@cnWQSvLJaDiA-w_w5i>yQ`i)pmR1p#S@ zWOeda*Ph!T zQu8a?m`eJnMX84gPSH-9m5d4V(>=|NL?S5!IS0lm?&L~HR}oR0d+ec&-KErgs8fED zg3$$zJVj0L1$$ZjFS5xv3v_>dZ}StNqMJWX1QF#UtT}_Pwdao0S$9E62(O!Lu7*rWP=Sr&C3DWvV8)(pHp!A_{*B_?e@FO zmoS;~fVDEBE)M^nPJ<^r3ydXLkdu)=Oq{X^wX^dzMz!mt!kb$OTy{YN=mRYH+|2 zYk+AcK2un>-UmT5|wI)KZ4s+4TCC8E2{740^s|`Ic?_AFI{EB;EmklgiOK zlhSbPPXW9;6<+yHb97y*LegN8{<-;Lyj5Vll#wxMq^R*V8>8NvVj&=Jm# zrY1bb)x?Q!BI=t%ATS3g@g+$wy#g=%0gWjT#KE_!0u>CwCJ;bn`Qa?$=hvEU5RDUC zR0WDPL^jAtBT&ZIlsls*0+a%qi~4)0~Bg#c`_34^eaX zohzpqpch&YLd$fDF>r&5Q+OFsjoBoq#Yr=wJnWb(?5+91yqWY-O8t=EQLN<(z}UUz z|0G0suFW!0E8fm*gUZ=9z{`oPv{%fFl`fO2>Y1k}IE$SUQAh|pLt#;W#>t#O$b1a3>1O>s+Ss-QVdl|;ikYrU(zoO?O^z^`LC zrOX;?%dxdm*Ku;mJu=+sb8=qm=dlYlid7C2msgs;ZXkJ7TR6HFHB81>YE_~MgCr$6 zy1F1BupVSoE#&ihiXFABUvRVx>i1?a`Nw=pbhxG$ywiw59p87Q2nG?OtcN=kRJ(1%t^)aJMx-2%U91VKOUPyK( zr%{t_l5(yq;7KLEXHpf*b<6;U$ylaysV>DF5GFHC#Z;J>sX;^NEo|cwP9H%CxNBFj zCAJin<$TbHB{9e^2 false, 'categories' => true, 'tags' => false, 'sort' => true]; +$list_settings = class_exists('AKSRT_Settings') + ? AKSRT_Settings::get_post_list_style_settings() + : ['render_mode' => 'card', 'card_style' => 'standard']; +$other_page_settings = class_exists('AKSRT_Settings') + ? AKSRT_Settings::get_other_page_settings() + : ['page_search_sidebar' => true]; +$popular_categories = get_categories([ + 'taxonomy' => 'category', + 'hide_empty' => true, + 'number' => 4, + 'orderby' => 'count', + 'order' => 'DESC', +]); + +$current_category = isset($_GET['aksrt_category']) ? absint($_GET['aksrt_category']) : 0; +$search_categories = get_categories([ + 'taxonomy' => 'category', + 'hide_empty' => true, + 'number' => 0, + 'orderby' => 'name', + 'order' => 'ASC', + 'fields' => 'all', +]); + +$search_sidebar_id = is_active_sidebar('search_sidebar') ? 'search_sidebar' : 'sidebar-blog'; +$has_sidebar = !empty($other_page_settings['page_search_sidebar']) && is_active_sidebar($search_sidebar_id); +$current_orderby = sanitize_key((string) get_query_var('orderby')); +$current_order = strtoupper(sanitize_key((string) get_query_var('order'))); +$current_sort = 'relevance'; + +if ($current_orderby === 'date' && $current_order === 'DESC') { + $current_sort = 'latest'; +} elseif ($current_orderby === 'date' && $current_order === 'ASC') { + $current_sort = 'oldest'; +} elseif ($current_orderby === 'comment_count') { + $current_sort = 'popular'; +} + +if ($current_category > 0) { + $filtered_query_args = [ + 's' => $search_query, + 'post_type' => 'post', + 'post_status' => 'publish', + 'posts_per_page' => (int) get_option('posts_per_page'), + 'paged' => max(1, (int) get_query_var('paged')), + 'cat' => $current_category, + 'ignore_sticky_posts' => true, + ]; + + if ($current_orderby !== '') { + $filtered_query_args['orderby'] = $current_orderby; + } + if ($current_order !== '') { + $filtered_query_args['order'] = $current_order; + } + + $loop_query = new WP_Query($filtered_query_args); +} + +$search_count = isset($loop_query->found_posts) ? (int) $loop_query->found_posts : 0; + +$render_config = [ + 'context' => 'search', + 'mode' => (string) ($list_settings['render_mode'] ?? 'card'), + 'card_style' => (string) ($list_settings['card_style'] ?? 'standard'), + 'highlight_query' => $search_query, +]; + +$search_type_options = [ + 'post' => __('文章', 'aksrt'), +]; +if (!empty($filter_settings['categories'])) { + $search_type_options['category'] = __('分类', 'aksrt'); +} +if (!empty($filter_settings['tags'])) { + $search_type_options['tag'] = __('标签', 'aksrt'); +} + +$search_sort_options = [ + 'relevance' => [ + 'label' => __('综合', 'aksrt'), + 'args' => ['orderby' => false, 'order' => false], + ], + 'latest' => [ + 'label' => __('最新', 'aksrt'), + 'args' => ['orderby' => 'date', 'order' => 'desc'], + ], + 'oldest' => [ + 'label' => __('最早', 'aksrt'), + 'args' => ['orderby' => 'date', 'order' => 'asc'], + ], + 'popular' => [ + 'label' => __('热门', 'aksrt'), + 'args' => ['orderby' => 'comment_count', 'order' => 'desc'], + ], +]; +?> + + + + diff --git a/sidebar.php b/sidebar.php new file mode 100644 index 00000000..14aa3fc6 --- /dev/null +++ b/sidebar.php @@ -0,0 +1,44 @@ + + + diff --git a/single-aksrt_resource.php b/single-aksrt_resource.php new file mode 100644 index 00000000..6b3c10cd --- /dev/null +++ b/single-aksrt_resource.php @@ -0,0 +1,350 @@ + 'far fa-clock', + 'text' => sprintf(__('发布于 %s', 'aksrt'), get_the_date()), + ]; + } + $author_meta_items[] = [ + 'icon' => 'far fa-eye', + 'text' => sprintf(__('浏览 %s', 'aksrt'), function_exists('aksrt_format_number') ? aksrt_format_number($views) : number_format_i18n($views)), + ]; + $author_meta_items[] = [ + 'icon' => 'fas fa-download', + 'text' => sprintf(__('下载 %s', 'aksrt'), number_format_i18n($downloads)), + ]; + if ($word_count > 0) { + $author_meta_items[] = [ + 'icon' => 'far fa-file-word', + 'text' => sprintf(__('约 %s 字', 'aksrt'), number_format_i18n($word_count)), + ]; + } + if ($reading_minutes > 0) { + $author_meta_items[] = [ + 'icon' => 'far fa-hourglass-half', + 'text' => sprintf(_n('约 %d 分钟', '约 %d 分钟', $reading_minutes, 'aksrt'), $reading_minutes), + ]; + } + + $show_action_card = !empty($single_settings['like_enabled']) + || !empty($single_settings['favorite_enabled']) + || !empty($single_settings['share_enabled']) + || !empty($single_settings['print_enabled']) + || !empty($single_settings['pdf_export_enabled']); + $copyright_text = trim((string) ($single_settings['copyright_text'] ?? '')); + if ($copyright_text === '') { + $copyright_text = sprintf( + /* translators: %s: author display name */ + __('本文由 %s 原创发布,转载请注明出处。', 'aksrt'), + get_the_author() + ); + } + + set_query_var('aksrt_resource_meta', $resource_meta); + ?> + + +
    + + +
    +
    +
    +
    + +
    + + +
    > + + + + + +
    + <?php echo esc_attr(get_the_title()); ?> +
    +
    +

    +
    + + + +
    +
    +
    + + + +
    +

    +
    + + + + + + +
    + '

    ', + 'after' => '

    ', + ]); + ?> +
    + + + + +
    +
    +
    + + + + + + + + + +
    + + + + +
    + + +
    + +
    + + +
    + +
    + + + 'ids']); + if (!empty($single_settings['related_enabled']) && !empty($resource_cat_ids)) { + $post_types = function_exists('aksrt_resource_post_types') ? aksrt_resource_post_types() : ['post']; + $args = [ + 'post_type' => $post_types, + 'posts_per_page' => (int) ($single_settings['related_count'] ?? 6), + 'post__not_in' => [$post_id], + 'category__in' => array_map('intval', $resource_cat_ids), + 'meta_key' => '_aksrt_res_download_count', + 'orderby' => 'meta_value_num', + 'order' => 'DESC', + 'no_found_rows' => true, + ]; + if (!post_type_exists('aksrt_resource')) { + $args['meta_query'] = [[ + 'key' => '_aksrt_is_resource', + 'value' => '1', + 'compare' => '=', + ]]; + } + $related_resource_query = new WP_Query($args); + } + if ($related_resource_query instanceof WP_Query && $related_resource_query->have_posts()) : + ?> + + +
    +
    + + +
    +
    +
    + + + +
    + + + + + + + +
    + + + 'far fa-calendar-alt', + 'text' => get_the_date(), + ]; + } + $author_meta_items[] = [ + 'icon' => 'far fa-eye', + 'text' => sprintf(__('浏览 %s', 'aksrt'), function_exists('aksrt_format_number') ? aksrt_format_number($views) : number_format_i18n($views)), + ]; + $author_meta_items[] = [ + 'icon' => 'far fa-comment-dots', + 'text' => sprintf(__('评论 %s', 'aksrt'), number_format_i18n($comment_count)), + ]; + + if ($word_count > 0) { + $author_meta_items[] = [ + 'icon' => 'far fa-file-word', + 'text' => sprintf(__('约 %s 字', 'aksrt'), number_format_i18n($word_count)), + ]; + } + if ($reading_minutes > 0) { + $author_meta_items[] = [ + 'icon' => 'far fa-hourglass', + 'text' => sprintf(_n('约 %d 分钟', '约 %d 分钟', $reading_minutes, 'aksrt'), $reading_minutes), + ]; + } + + $copyright_text = trim((string) ($single_settings['copyright_text'] ?? '')); + if ($copyright_text === '') { + $copyright_text = sprintf( + __('本文由 %s 原创发布,转载请注明出处。', 'aksrt'), + get_the_author() + ); + } + + $show_action_card = !empty($single_settings['like_enabled']) + || !empty($single_settings['favorite_enabled']) + || !empty($single_settings['share_enabled']) + || !empty($single_settings['print_enabled']) + || !empty($single_settings['pdf_export_enabled']); + $has_sidebar_widgets = is_active_sidebar('sidebar-article'); + $show_sidebar = $has_sidebar_widgets || $show_toc; + $cover_breadcrumbs_html = ''; + if (!empty($single_settings['breadcrumbs']) && function_exists('aksrt_breadcrumbs')) { + ob_start(); + aksrt_breadcrumbs($single_settings['breadcrumbs_home'] ?? ''); + $cover_breadcrumbs_html = trim((string) ob_get_clean()); + } + $post_front_content = (string) ($single_settings['front_content'] ?? ''); + $post_after_content = (string) ($single_settings['after_content'] ?? ''); + $content_maxheight = !empty($single_settings['maxheight_enabled']) ? max(0, (int) ($single_settings['maxheight'] ?? 0)) : 0; + $post_toc_title = __('文章目录', 'aksrt'); + $post_toc_widget = ''; + $post_toc_drawer = ''; + $post_share_dropdown = ''; + $post_reward_modal = ''; + $post_episode_count = max(0, (int) get_post_meta($post_id, '_aksrt_featured_video_episode', true)); + $post_slide_images = array_values(array_filter((array) ($single_settings['slide_images'] ?? []))); + $author_card_html = ''; + if (class_exists('AKSRT_Docs_Presenter')) { + $docs_presenter = AKSRT_Docs_Presenter::get_instance(); + if ($docs_presenter->is_docs_page($post_id)) { + get_template_part('templates/pages/page-documentnav'); + get_footer(); + return; + } + } + if (!empty($single_settings['share_enabled'])) { + $post_share_dropdown = ''; + } + if (!empty($single_settings['rewards_enabled']) && !empty($single_settings['rewards_image'])) { + $post_reward_modal = ''; + } + if ($show_toc) { + $post_toc_widget = '

    ' . esc_html($post_toc_title) . '

      '; + $post_toc_drawer = '' + . '
      ' + . ''; + } + if (function_exists('aksrt_get_user_dashboard_view')) { + $author_dashboard = aksrt_get_user_dashboard_view((int) get_the_author_meta('ID'), ['viewer_id' => get_current_user_id()]); + $author_recent_posts = is_array($author_dashboard['recent_posts'] ?? null) ? $author_dashboard['recent_posts'] : []; + $author_card_stats = [ + ['label' => __('文章', 'aksrt'), 'value' => (int) ($author_dashboard['post_count'] ?? 0)], + ['label' => __('评论', 'aksrt'), 'value' => (int) ($author_dashboard['comment_count'] ?? 0)], + ['label' => __('收藏', 'aksrt'), 'value' => (int) ($author_dashboard['favorite_count'] ?? 0)], + ]; + $author_card_html = '
      ' + . '
      ' + . '' . get_avatar((int) get_the_author_meta('ID'), 56, '', esc_attr(get_the_author())) . '' + . '
      ' . esc_html(get_the_author()) . ''; + $author_bio = trim((string) get_the_author_meta('description')); + if ($author_bio !== '') { + $author_card_html .= '

      ' . esc_html($author_bio) . '

      '; + } + $author_card_html .= '
      '; + foreach ($author_card_stats as $stat) { + $author_card_html .= '' . esc_html((string) $stat['value']) . ' ' . esc_html($stat['label']) . ''; + } + $author_card_html .= '
      '; + if (!empty($author_recent_posts)) { + $author_card_html .= '

      ' . esc_html__('最新文章', 'aksrt') . '

      '; + } + $author_card_html .= '
      '; + } + $post_status_badge = ''; + $post_status = get_post_status($post_id); + if ($post_status && $post_status !== 'publish') { + $status_map = [ + 'draft' => ['label' => __('草稿', 'aksrt'), 'class' => 'jb-yellow'], + 'pending' => ['label' => __('待审核', 'aksrt'), 'class' => 'jb-red'], + 'private' => ['label' => __('私密', 'aksrt'), 'class' => 'b-black opacity8'], + 'future' => ['label' => __('定时发布', 'aksrt'), 'class' => 'c-blue'], + ]; + if (isset($status_map[$post_status])) { + $post_status_badge = '' . esc_html($status_map[$post_status]['label']) . ''; + } + } + ?> + + +
      + + +
      +
      +
      +
      + + + +
      > + +
      + +
      +
      + +
      + +

      + + + +
      + + + +
      + 0) : ?> + + +
      +
      + +
      +
      + $slide_url) : ?> +
      + <?php echo esc_attr(get_the_title()); ?> +
      + +
      +
      +
      + +
      + +

      + + + +
      + + + +
      + + + +
      +
      + +
      + <?php echo esc_attr(get_the_title()); ?> +
      +
      + +
      + +

      + + + +
      + + + +
      +
      +
      + + + +
      +

      + + + +
      + + + + +
      + +
      + + + + +
      + + + + + +
      + +
      + + + +
      + + +
      + + '

      ', + 'after' => '

      ', + ]); + ?> +
      + +
      + + + + + + +
      +
      + + + + + + + + + + + + + + +
      + + + + + + + + +
      + + + +
      ©
      + +
      THE END
      + + + + + + + + get_post_type($post_id), + 'post_status' => 'publish', + 'post__not_in' => [$post_id], + 'posts_per_page' => $related_count, + 'ignore_sticky_posts' => true, + ]; + if (($single_settings['related_type'] ?? 'category') === 'tag') { + $tag_ids = wp_get_post_terms($post_id, 'post_tag', ['fields' => 'ids']); + if (!empty($tag_ids) && !is_wp_error($tag_ids)) { + $related_args['tag__in'] = $tag_ids; + } + } else { + $cat_ids = wp_get_post_terms($post_id, 'category', ['fields' => 'ids']); + if (!empty($cat_ids) && !is_wp_error($cat_ids)) { + $related_args['category__in'] = $cat_ids; + } + } + $related_query = new WP_Query($related_args); + if ($related_query->have_posts()) : + ?> + + + + + +
      + +
      + +
      + + +
      + + + + +
      +
      +
      + + + + diff --git a/slim-forum.php b/slim-forum.php new file mode 100644 index 00000000..e1c29846 --- /dev/null +++ b/slim-forum.php @@ -0,0 +1,217 @@ + 0) { + $ch = $content[$i]; + $next = ($i + 1 < $len) ? $content[$i + 1] : ''; + + if ($in_line_comment) { + if ($ch === "\n") $in_line_comment = false; + $i++; + continue; + } + if ($in_block_comment) { + if ($ch === '*' && $next === '/') { $in_block_comment = false; $i += 2; continue; } + $i++; + continue; + } + if ($in_string_single) { + if ($ch === '\\') { $i += 2; continue; } + if ($ch === "'") $in_string_single = false; + $i++; + continue; + } + if ($in_string_double) { + if ($ch === '\\') { $i += 2; continue; } + if ($ch === '"') $in_string_double = false; + $i++; + continue; + } + if ($ch === '/' && $next === '/') { $in_line_comment = true; $i += 2; continue; } + if ($ch === '/' && $next === '*') { $in_block_comment = true; $i += 2; continue; } + if ($ch === "'") { $in_string_single = true; $i++; continue; } + if ($ch === '"') { $in_string_double = true; $i++; continue; } + + if ($ch === '{') $depth++; + elseif ($ch === '}') $depth--; + $i++; + } + + if ($depth !== 0) { + echo "WARNING: Could not find matching closing brace for: $signature\n"; + return $content; + } + + $close_brace = $i - 1; + + // Get indentation of the method + $line_start = strrpos(substr($content, 0, $pos), "\n"); + if ($line_start === false) $line_start = 0; + else $line_start++; + $indent = ''; + for ($j = $line_start; $j < $pos; $j++) { + if ($content[$j] === ' ' || $content[$j] === "\t") $indent .= $content[$j]; + else break; + } + + // Replace from open_brace to close_brace (inclusive) + $replacement = "{\n" . $indent . " " . $body . "\n" . $indent . "}"; + $content = substr($content, 0, $open_brace) . $replacement . substr($content, $close_brace + 1); + + return $content; +} + +// --- DRAFT SERVICE METHODS --- + +$content = replace_method($content, + 'public function publish_draft(int $user_id, int $draft_id, array $data)', + 'return $this->draft_service()->publish_draft($user_id, $draft_id, $data);'); + +$content = replace_method($content, + 'public function ajax_delete_draft(): void', + '$this->draft_service()->ajax_delete_draft();'); + +$content = replace_method($content, + 'public function ajax_load_draft(): void', + '$this->draft_service()->ajax_load_draft();'); + +$content = replace_method($content, + 'public function ajax_save_draft(): void', + '$this->draft_service()->ajax_save_draft();'); + +$content = replace_method($content, + 'public function load_draft_data(int $user_id, int $draft_id): ?array', + 'return $this->draft_service()->load_draft_data($user_id, $draft_id);'); + +$content = replace_method($content, + 'public function delete_draft(int $user_id, int $draft_id): bool', + 'return $this->draft_service()->delete_draft($user_id, $draft_id);'); + +$content = replace_method($content, + 'public function get_user_drafts(int $user_id, int $limit = 20): array', + 'return $this->draft_service()->get_user_drafts($user_id, $limit);'); + +$content = replace_method($content, + 'public function get_user_draft_count(int $user_id): int', + 'return $this->draft_service()->get_user_draft_count($user_id);'); + +$content = replace_method($content, + 'public function save_draft(int $user_id, array $data)', + 'return $this->draft_service()->save_draft($user_id, $data);'); + +// --- COMMENT SERVICE METHODS --- + +$content = replace_method($content, + 'public function get_commenter_badge(int $user_id, int $topic_id): ?array', + 'return $this->comment_service()->get_commenter_badge($user_id, $topic_id);'); + +$content = replace_method($content, + 'public function is_hot_comment(int $comment_id): bool', + 'return $this->comment_service()->is_hot_comment($comment_id);'); + +$content = replace_method($content, + 'public function maybe_mark_hot_comment(int $comment_id): bool', + 'return $this->comment_service()->maybe_mark_hot_comment($comment_id);'); + +$content = replace_method($content, + 'public function set_comment_topping(int $comment_id, int $level): bool', + 'return $this->comment_service()->set_comment_topping($comment_id, $level);'); + +$sig_forum_comments = "public function get_forum_comments(int \$topic_id, string \$sort = 'newest', bool \$author_only = false, int \$page = 1, int \$per_page = 30): array"; +$content = replace_method($content, $sig_forum_comments, + 'return $this->comment_service()->get_forum_comments($topic_id, $sort, $author_only, $page, $per_page);'); + +$content = replace_method($content, + 'public function get_adopted_comment_id(int $topic_id): int', + 'return $this->comment_service()->get_adopted_comment_id($topic_id);'); + +$content = replace_method($content, + 'public function adopt_answer(int $topic_id, int $comment_id, int $operator_id)', + 'return $this->comment_service()->adopt_answer($topic_id, $comment_id, $operator_id);'); + +$content = replace_method($content, + 'public function render_forum_comments(array $comments, int $topic_id, int $adopted_id = 0): void', + '$this->comment_service()->render_forum_comments($comments, $topic_id, $adopted_id);'); + +$content = replace_method($content, + 'public function ajax_comment_topping(): void', + '$this->comment_service()->ajax_comment_topping();'); + +$content = replace_method($content, + 'public function ajax_forum_comments(): void', + '$this->comment_service()->ajax_forum_comments();'); + +// --- SCORE/NOTIFICATION SERVICE METHODS --- + +$content = replace_method($content, + 'public function on_forum_comment_posted(int $comment_id, $approved, array $commentdata = []): void', + '$this->score_service()->on_forum_comment_posted($comment_id, $approved, $commentdata);'); + +$content = replace_method($content, + 'public function notify_topic_scored(int $topic_id, int $scorer_id, int $amount): void', + '$this->score_service()->notify_topic_scored($topic_id, $scorer_id, $amount);'); + +$content = replace_method($content, + 'public function notify_topic_reply(int $topic_id, int $commenter_id): void', + '$this->score_service()->notify_topic_reply($topic_id, $commenter_id);'); + +$content = replace_method($content, + 'public function notify_moderation_result(int $topic_id, string $action, int $operator_id): void', + '$this->score_service()->notify_moderation_result($topic_id, $action, $operator_id);'); + +$content = replace_method($content, + 'public function notify_topic_pending(int $topic_id, int $author_id): void', + '$this->score_service()->notify_topic_pending($topic_id, $author_id);'); + +$content = replace_method($content, + 'private function notify(int $user_id, string $type, string $content, array $data = []): void', + '$this->score_service()->notify($user_id, $type, $content, $data);'); + +// --- Write result --- +$result = file_put_contents($file, $content); +if ($result === false) { + echo "ERROR: Cannot write file\n"; + exit(1); +} + +echo "SUCCESS: File updated. New size: " . strlen($content) . " bytes\n"; +echo "Line count: " . (substr_count($content, "\n") + 1) . "\n"; \ No newline at end of file diff --git a/style.css b/style.css new file mode 100644 index 00000000..bd6d6ed2 --- /dev/null +++ b/style.css @@ -0,0 +1,22 @@ +/* +Theme Name: AKSRT +Theme URI: https://github.com/Lexo0522/aksrt +Author: Kate +Author URI: https://github.com/Lexo0522 +Description: AKSRT Blog Theme - Modern WordPress theme with custom admin panel, OAuth social login, and beautiful blog layout. Based on fresh frontend with dark/light mode. +Version: 1.0.0 +Requires at least: 6.4 +Tested up to: 6.8 +Requires PHP: 8.1 +License: GPL v2 or later +License URI: https://www.gnu.org/licenses/gpl-2.0.html +Text Domain: aksrt +Tags: blog, two-columns, right-sidebar, custom-menu, featured-images, translation-ready, dark-mode + +============================================================ +AKSRT WordPress Theme v3.0 - Fresh Frontend + Admin Backend + +Fresh frontend templates (dark/light mode, responsive). +Full admin panel preserved (settings, OAuth, banners, etc). +============================================================ +*/ diff --git a/templates/forum/forum-board.php b/templates/forum/forum-board.php new file mode 100644 index 00000000..e4575e28 --- /dev/null +++ b/templates/forum/forum-board.php @@ -0,0 +1,297 @@ +term_id : 0; +$paged = max(1, (int) get_query_var('paged', 1)); +$view = $forum ? $forum->get_board_view($board_id, [ + 'user_id' => $current_user_id, + 'paged' => $paged, +]) : [ + 'board' => null, + 'topics' => [], + 'sticky_topics' => [], + 'moderators' => [], + 'pagination' => ['page' => 1, 'max_pages' => 1, 'total' => 0], + 'permissions' => [], + 'application' => null, + 'board_options' => [], +]; + +$board_data = $view['board'] ?? null; +$topics = $view['topics'] ?? []; +$sticky_topics = $view['sticky_topics'] ?? []; +$moderators = $view['moderators'] ?? []; +$permissions = $view['permissions'] ?? []; +$application = $view['application'] ?? null; +$board_options = $view['board_options'] ?? []; +$compose_url = add_query_arg('board', $board_id, home_url('/compose/')); +$admin_forum_url = admin_url('admin.php?page=aksrt-theme-admin#/forum/topics'); + +// Forum settings — names +$forum_settings = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_forum_settings() : []; +$forum_names = $forum_settings['names'] ?? []; +$name_topic = $forum_names['posts'] ?? __('主题', 'aksrt'); +$name_reply = $forum_names['comment'] ?? __('回复', 'aksrt'); +$name_sticky = __('置顶', 'aksrt'); +$name_featured = __('精华', 'aksrt'); +?> + +
      + +
      +
      +
      +

      + +

      + +
      + + +
      +
      + +
      + + + + + + + + + + + + + + + + + + +
      +
      + +
      +
      +

      + +

      + +

      + +

      + + +

      + +

      + +
      + +
      +

      + +
      + +
      + + + + +
      + +
      + +

      + +
      + + +
      +

      + +

      + +

      + + +
      + + +
      + + +
      +
      + +
      + +
      + + + + + +
      + + + 0 + ? ' is-sticky is-sticky-' . (int) ($topic['sticky_level'] ?? 0) + : ''; + ?> +
      +
      + + + +
      + + +
      + +
      + + +
      + +
      + + +
      + + + +
      + + +
      +
      + 0 && $item_sticky_label !== '') : + $item_sticky_mod = $item_sticky_level >= 3 ? 'global' : ($item_sticky_level === 2 ? 'super' : 'plate'); + ?> + + + + + + + + + + + + + + +
      +
      + + + + + +
      +
      + +
      + + +
      + + +
      + 0 ? 0 : 1; + ?> + + + + + +
      + +
      + + + 1) : ?> +
      + (int) ($view['pagination']['max_pages'] ?? 1), + 'current' => $paged, + 'prev_text' => '', + 'next_text' => '', + ]); + ?> +
      + + +
      + +

      + + + +
      + +
      +
      +
      + + diff --git a/templates/forum/forum-compose.php b/templates/forum/forum-compose.php new file mode 100644 index 00000000..10ab1111 --- /dev/null +++ b/templates/forum/forum-compose.php @@ -0,0 +1,409 @@ +get_compose_view($current_user_id, ['board_id' => $board_id]) : [ + 'boards' => [], + 'selected_board_id' => 0, + 'visibility_options' => [], + 'publish_mode' => 'pending', + 'draft_count' => 0, + 'drafts' => [], + 'capabilities' => [], +]; + +$forum_settings = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_forum_settings() : []; +$title_placeholder = $forum_settings['other']['t_placeholder'] ?? __('请输入标题', 'aksrt'); +$content_placeholder = $forum_settings['other']['c_placeholder'] ?? __('请输入内容', 'aksrt'); +$title_maxlen = max(5, min(100, (int) ($forum_settings['other']['title_strlen_limit'] ?? 30))); +$vote_max = (int) ($forum_settings['other']['vote_max'] ?? 8); +$forum_name = $forum_settings['names']['forum'] ?? __('论坛', 'aksrt'); + +$boards = $view['boards'] ?? []; +$visibility_options = $view['visibility_options'] ?? []; +$publish_mode = (string) ($view['publish_mode'] ?? 'pending'); +$draft_count = (int) ($view['draft_count'] ?? 0); +$drafts = $view['drafts'] ?? []; +$caps = $view['capabilities'] ?? []; +$nonce = wp_create_nonce('aksrt_forum_compose'); + +// Load draft data if resuming +$draft_data = null; +if ($draft_id > 0 && $forum) { + $draft_data = $forum->load_draft_data($current_user_id, $draft_id); + if ($draft_data) { + $board_id = (int) ($draft_data['board_id'] ?? 0); + } +} + +$initial_content = $draft_data ? $draft_data['content'] : ''; +?> + +
      + +
      +
      +
      +

      +

      + +

      +
      + + + +
      + + 0) : ?> +
      + + + + +
      + + +
      + +
      +
      + + + + / + +
      + +
      + + 'content', + 'textarea_rows' => 18, + 'media_buttons' => !empty($caps['can_upload_image']) || !empty($caps['can_upload_video']) || !empty($caps['can_upload_file']), + 'teeny' => false, + 'quicktags' => true, + 'tinymce' => [ + 'toolbar1' => 'formatselect,bold,italic,underline,strikethrough,blockquote,bullist,numlist,alignleft,aligncenter,alignright,link,unlink,wp_more,fullscreen', + 'toolbar2' => 'pastetext,removeformat,charmap,outdent,indent,undo,redo,wp_help', + 'plugins' => 'paste,link,lists,fullscreen,wordpress,wplink,charmap', + ], + ]; + + if (empty($caps['can_upload_image']) && empty($caps['can_upload_video']) && empty($caps['can_upload_file'])) { + $editor_settings['tinymce']['toolbar1'] = str_replace(',wp_more', '', $editor_settings['tinymce']['toolbar1']); + } + + wp_editor($initial_content, 'compose-content', $editor_settings); + ?> +
      +
      + + +
      + + +
      +

      +
      +

      + + + + +

      +
      + + +
      + +
      +
      + + +
      +

      +
      +
      + + + +
      +
      +
      + + +
      +

      +
      +
      + + +
      + +
      + + +
      + + +
      + + + + +
      + +
      +
      + + +
      +

      +
      +
      + +
      + + +
      +
      +
      + + +
      +

      +
      +
      + +
      + + + + + + +
      +
      + + + +
      +

      +
      +
      + +
      + + + + + + +
      +
      + + + +
      +

      + +

      +
      > +
      + + +
      + +
      + +
      + +
      + + +
      + +
      + + +
      + +
      + + +
      +
      +
      + +
      +
      +
      +
      + + diff --git a/templates/forum/forum-edit.php b/templates/forum/forum-edit.php new file mode 100644 index 00000000..77e1c40f --- /dev/null +++ b/templates/forum/forum-edit.php @@ -0,0 +1,362 @@ +can_edit_topic($user_id, $topic_id)) { + wp_safe_redirect(get_permalink($topic_id)); + exit; +} + +$topic = get_post($topic_id); + +// Board +$board_id = 0; +$board_name = ''; +if ($forum->is_plate_cpt_active()) { + $board_id = (int) get_post_meta($topic_id, '_aksrt_plate_id', true); + if ($board_id > 0) { + $board_name = get_the_title($board_id); + } +} else { + $terms = wp_get_post_terms($topic_id, 'aksrt_forum_board', ['fields' => 'all']); + if (!is_wp_error($terms) && !empty($terms)) { + $board_id = (int) $terms[0]->term_id; + $board_name = (string) $terms[0]->name; + } +} + +// Tags +$tags = wp_get_post_terms($topic_id, 'aksrt_forum_tag', ['fields' => 'names']); +$tag_str = !is_wp_error($tags) && !empty($tags) ? implode(', ', $tags) : ''; + +// Topic category +$topic_cat = ''; +if (taxonomy_exists('aksrt_forum_topic_cat')) { + $cats = wp_get_post_terms($topic_id, 'aksrt_forum_topic_cat', ['fields' => 'names']); + if (!is_wp_error($cats) && !empty($cats)) { + $topic_cat = $cats[0]; + } +} + +// Post meta +$visibility = get_post_meta($topic_id, '_aksrt_visibility', true) ?: 'public'; +$bbs_type = get_post_meta($topic_id, '_aksrt_bbs_type', true) ?: 'standard'; +$special_topic = get_post_meta($topic_id, '_aksrt_special_topic', true) ?: ''; +$cover_type = get_post_meta($topic_id, '_aksrt_cover_type', true) ?: ''; +$cover_image = get_post_meta($topic_id, '_aksrt_cover_image', true) ?: ''; +$featured_slide = get_post_meta($topic_id, '_aksrt_featured_slide', true) ?: []; +$featured_video = get_post_meta($topic_id, '_aksrt_featured_video', true) ?: ''; +$credits_cost = (int) get_post_meta($topic_id, '_aksrt_credits_cost', true); +$pay_price = get_post_meta($topic_id, '_aksrt_pay_price', true) ?: ''; +$visibility_roles = get_post_meta($topic_id, '_aksrt_visibility_roles', true) ?: []; +$vote_enabled = get_post_meta($topic_id, '_aksrt_vote_enabled', true) === '1'; +$vote_title = get_post_meta($topic_id, '_aksrt_vote_title', true) ?: ''; +$vote_multiple = get_post_meta($topic_id, '_aksrt_vote_multiple', true) === '1'; +$vote_type = get_post_meta($topic_id, '_aksrt_vote_type', true) ?: 'standard'; +$vote_expiry_days = (int) get_post_meta($topic_id, '_aksrt_vote_expiry_days', true); +$vote_options_raw = get_post_meta($topic_id, '_aksrt_vote_options', true) ?: []; +$vote_options_text = is_array($vote_options_raw) ? implode("\n", array_column($vote_options_raw, 'label')) : ''; + +// Settings & capabilities +$forum_settings = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_forum_settings() : []; +$title_maxlen = max(5, min(100, (int) ($forum_settings['other']['title_strlen_limit'] ?? 30))); +$vote_max = (int) ($forum_settings['other']['vote_max'] ?? 8); +$caps = $forum->get_compose_capabilities($user_id); +$visibility_options = $forum->get_visibility_options(); +$nonce = wp_create_nonce('aksrt_forum_compose'); + +get_header(); +?> + +
      + +
      +
      +
      +

      +
      + + + +
      + +
      + +
      +
      + + + + post_title)); ?> / + +
      + +
      + + post_content, 'compose-content', [ + 'textarea_name' => 'content', + 'textarea_rows' => 18, + 'media_buttons' => !empty($caps['can_upload_image']) || !empty($caps['can_upload_video']) || !empty($caps['can_upload_file']), + 'teeny' => false, + 'quicktags' => true, + 'tinymce' => [ + 'toolbar1' => 'formatselect,bold,italic,underline,strikethrough,blockquote,bullist,numlist,alignleft,aligncenter,alignright,link,unlink,wp_more,fullscreen', + 'toolbar2' => 'pastetext,removeformat,charmap,outdent,indent,undo,redo,wp_help', + 'plugins' => 'paste,link,lists,fullscreen,wordpress,wplink,charmap', + ], + ]); + ?> +
      +
      + + +
      + + +
      +

      +
      +
      + + + + +
      + +
      +
      + + +
      +

      +
      +

      +
      +
      + + +
      +

      +
      +
      + + +
      + +
      + + +
      + + +
      + + + + +
      + +
      +
      + + +
      +

      +
      +
      + +
      + + +
      +
      +
      + + + +
      +

      +
      +
      + +
      + + + + + + +
      +
      + + + + +
      +

      +
      +
      + +
      + + + + + + +
      +
      + + + + +
      +

      + +

      +
      > +
      + + +
      + +
      + +
      + +
      + + +
      + +
      + + +
      + +
      + + +
      +
      +
      + + +
      +
      +
      +
      + + diff --git a/templates/forum/forum-plate-cat.php b/templates/forum/forum-plate-cat.php new file mode 100644 index 00000000..59f76208 --- /dev/null +++ b/templates/forum/forum-plate-cat.php @@ -0,0 +1,107 @@ +term_id : 0; + +$forum_settings = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_forum_settings() : []; +$forum_names = $forum_settings['names'] ?? []; +$forum_name = $forum_names['forum'] ?? __('论坛', 'aksrt'); + +$plates = []; +if ($forum && $cat_id > 0) { + $plates = $forum->get_sorted_plates('menu_order', 'ASC', 100); + $plates = array_filter($plates, static function ($plate) use ($cat_id): bool { + return isset($plate['plate_cat']['id']) && (int) $plate['plate_cat']['id'] === $cat_id; + }); + $plates = array_values($plates); +} + +$cat_moderator_ids = get_term_meta($cat_id, '_aksrt_cat_moderator', true); +$cat_moderators = []; +if (is_array($cat_moderator_ids)) { + foreach ($cat_moderator_ids as $uid) { + $user = get_userdata((int) $uid); + if ($user) { + $cat_moderators[] = [ + 'id' => (int) $user->ID, + 'name' => $user->display_name, + 'avatar' => function_exists('aksrt_get_avatar_url') ? aksrt_get_avatar_url($user->ID, 36) : get_avatar_url($user->ID, ['size' => 36]), + ]; + } + } +} +?> + +
      + + +
      +
      +

      name : ''); ?>

      + description) : ?> +

      description); ?>

      + + + +
      + + + + + + + + + +
      + +
      + + + +
      + + + +
      +
      +
      + + diff --git a/templates/forum/forum-plate.php b/templates/forum/forum-plate.php new file mode 100644 index 00000000..e0ab9917 --- /dev/null +++ b/templates/forum/forum-plate.php @@ -0,0 +1,312 @@ +get_board_view($plate_id, [ + 'user_id' => $current_user_id, + 'paged' => $paged, +]) : [ + 'board' => null, + 'topics' => [], + 'sticky_topics' => [], + 'moderators' => [], + 'pagination' => ['page' => 1, 'max_pages' => 1, 'total' => 0], + 'permissions' => [], + 'application' => null, + 'board_options' => [], +]; + +$board_data = $view['board'] ?? null; +$topics = $view['topics'] ?? []; +$sticky_topics = $view['sticky_topics'] ?? []; +$moderators = $view['moderators'] ?? []; +$permissions = $view['permissions'] ?? []; +$application = $view['application'] ?? null; +$compose_url = add_query_arg('board', $plate_id, home_url('/compose/')); + +$forum_settings = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_forum_settings() : []; +$forum_names = $forum_settings['names'] ?? []; +$name_topic = $forum_names['posts'] ?? __('主题', 'aksrt'); +$name_reply = $forum_names['comment'] ?? __('回复', 'aksrt'); +$is_followed = $forum && $current_user_id > 0 ? $forum->is_user_following_plate($current_user_id, $plate_id) : false; +?> + +
      + + + +
      +
      + +

      +

      +
      +
      + + +
      +
      +
      + + + +

      + +

      + +
      + + + + + +
      +
      + +
      + 0) : ?> + + + + + + + + + + + is_forum_admin($current_user_id); + $is_plate_author = $board_data && (int) ($board_data['author_id'] ?? 0) === $current_user_id; + ?> + + + + + + +
      +
      + + +
      + +
      + + +
      +
      +

      + +
      + +
      + + + + + + + +
      + +
      + +

      + +
      + +
      +

      + +

      + + +

      + +

      + +
      + + +

      + + +
      + + +
      + + +
      +
      + + +

      +
      + + +
      + + +
      +
      + +

      + +
      +
      + + + + + +
      + + + 0 + ? ' is-sticky is-sticky-' . $item_sticky_level + : ''; + ?> +
      +
      + + + +
      + +
      +
      + + + + + + + + + + +
      +
      + + +
      +
      + + +
      + +
      + + + + +
      + +
      + + + + +
      + + + +
      + + +
      + + +
      +
      + + + 1) : ?> +
      + (int) ($view['pagination']['max_pages'] ?? 1), + 'current' => $paged, + 'prev_text' => '', + 'next_text' => '', + ]); + ?> +
      + + +
      + +

      + + + +
      + +
      +
      + + +
      + + diff --git a/templates/forum/forum-tag.php b/templates/forum/forum-tag.php new file mode 100644 index 00000000..9e79ecbd --- /dev/null +++ b/templates/forum/forum-tag.php @@ -0,0 +1,92 @@ +get_tag_archive_view([ + 'term_id' => $term->term_id, + 'paged' => $paged, +]) : ['topics' => [], 'term' => null, 'total' => 0, 'max_pages' => 0]; + +$topics = $view['topics']; +$total = $view['total']; +$max_pages = $view['max_pages']; +$term_name = $term->name ?? ''; +$term_desc = $term->description ?? ''; +?> + +
      + +
      +
      +

      + + +

      + +

      + +

      +
      + + +
      + +
      +
      +

      + +

      + +

      + +
      + + + + + + + + + + + + +
      +
      +
      + +
      + + 1) : ?> + + + +
      + +

      +
      + +
      +
      + + diff --git a/templates/forum/forum-topic.php b/templates/forum/forum-topic.php new file mode 100644 index 00000000..31765353 --- /dev/null +++ b/templates/forum/forum-topic.php @@ -0,0 +1,635 @@ +get_topic_view($topic_id, $current_user_id) : [ + 'topic' => null, + 'board' => null, + 'permissions' => [], + 'board_options' => [], + 'visibility_options' => [], + 'review_options' => [], +]; + +$topic = $view['topic'] ?? null; +$board = $view['board'] ?? null; +$permissions = $view['permissions'] ?? []; +$board_options = $view['board_options'] ?? []; +$visibility_options = $view['visibility_options'] ?? []; +$review_options = $view['review_options'] ?? []; + +// Forum settings for names, breadcrumbs, reply +$forum_settings = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_forum_settings() : []; +$forum_names = $forum_settings['names'] ?? []; +$forum_bc = $forum_settings['post_page'] ?? []; +$reply_settings = $forum_settings['reply'] ?? []; +$show_breadcrumbs = $forum_bc['breadcrumbs'] ?? true; +$bc_show_home = $forum_bc['breadcrumbs_bbs_home'] ?? true; +$bc_forum_name = $forum_bc['breadcrumbs_bbs_home_name'] ?: ($forum_names['forum'] ?? __('论坛', 'aksrt')); +$reply_placeholder = $reply_settings['placeholder'] ?? __('写下你的回复…', 'aksrt'); +$name_sticky = $forum_names['posts'] ?? __('置顶', 'aksrt'); +$name_featured = $forum_names['topic'] ?? __('精华', 'aksrt'); +$name_reply = $forum_names['comment'] ?? __('回复', 'aksrt'); + +if (!$topic) { + get_template_part('templates/parts/content', 'none'); + get_footer(); + return; +} + +if (empty($topic['can_view'])) : + $deny_vis = $topic['visibility']['key'] ?? ''; + $credits_cost = (int) get_post_meta($topic_id, '_aksrt_credits_cost', true); + $pay_price = get_post_meta($topic_id, '_aksrt_pay_price', true); + ?> +
      +
      +
      + +

      + +

      + + +

      + +

      + 0) : ?> +

      + + + + + + + +

      + + + + +

      + +
      +
      +
      + increment_view_count($topic_id); + $topic['views'] = (int) ($topic['views'] ?? 0) + 1; +} + +$is_locked = !empty($topic['locked']); +$comments_enabled = !empty($permissions['can_reply']); +?> + +
      + +
      + +
      + + + + + / + + + / + +
      + + +
      +
      +

      + 0 && $sticky_label !== '') : + $sticky_mod = $sticky_level >= 3 ? 'global' : ($sticky_level === 2 ? 'super' : 'plate'); + ?> + + + + + + + + + + + + + + +

      + +
      + + + + + + + + + + + + + + + + 0) : ?> + + +
      + +
      + + + + + + + + + + + + + + + + + + + + +
      +
      + + + + +
      + +
      + + + + +
      + +
      + + + +
      + + + +
      + + + +
      + + + + + + + + + + + + + # + + +
      + + +
      + +
      + + + +
      + + + + + + 0) : ?> + + + 0) : ?> + + + +
      + + + + +
      +
      +

      + + + + + + + + 0) : ?> + + + +
      + +
      +
      + + + +
      + +
      + + + + + + + + +
      +
      +
      + + + +
      +
      +

      +

      +
      + + +
      + + +
      + + +
      +
      +
      + + +
      + +
      + + +
      + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      + +
      + + +
      + +
      + + +
      +
      +
      + + +
      + +
      +

      + + +

      +
      + + + + +
      +
      + +
      + get_forum_comments($topic_id, 'newest', false, 1, 30); + $forum->render_forum_comments($reply_data['comments'], $topic_id, $reply_data['adopted_id']); + $reply_max_pages = $reply_data['max_pages']; + $reply_total = $reply_data['total']; + } else { + $reply_max_pages = 1; + $reply_total = 0; + } + ?> +
      + + 1) : ?> +
      + +
      + +
      + + +
      +

      + '', + 'comment_notes_before' => '', + 'comment_field' => '
      ', + 'submit_button' => '', + 'submit_field' => '
      %1$s %2$s
      ', + ]); + ?> +
      + +
      + + +
      + + + +
      +
      + + diff --git a/templates/pages/page-archives.php b/templates/pages/page-archives.php new file mode 100644 index 00000000..b73a017e --- /dev/null +++ b/templates/pages/page-archives.php @@ -0,0 +1,68 @@ + + +
      + + +
      +
      +
      +
      + get_results( + "SELECT DISTINCT YEAR(post_date) AS year, MONTH(post_date) AS month, COUNT(*) AS count + FROM {$wpdb->posts} + WHERE post_type = 'post' AND post_status = 'publish' + GROUP BY YEAR(post_date), MONTH(post_date) + ORDER BY year DESC, month DESC" + ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery + + $current_year = 0; + foreach ($years as $row) : + if ((int) $row->year !== $current_year) : + $current_year = (int) $row->year; + ?> +

      + +

      + month, (int) $row->count)); ?> +

      +
        + (int) $row->year, + 'monthnum' => (int) $row->month, + 'posts_per_page' => -1, + 'post_status' => 'publish', + ]); + foreach ($month_posts as $mp) : + ?> +
      • + + post_title); ?> +
      • + +
      + +
      +
      +
      +
      + + 'settings', 'oauth_bound' => $provider], aksrt_get_user_center_url())); + exit; + } +} + +get_header(); +?> + +
      +
      +
      + + + +
      + +

      +

      + + + +
      + + + + + +

      +

      + ' . esc_html($provider_name) . ''); ?> +

      + + + + + +
      + + +
      + +
      + + +
      +
      +
      + +
      + + + +
      +
      + + + + +
      + + + + + + +
      +
      +
      + + [], + 'shipping_methods' => [], + 'address_book' => [], + 'coupon_enabled' => false, + 'currency_symbol' => '¥', + 'pending_orders' => [], + ]; + +$pending_orders = is_array($cart_view['pending_orders'] ?? null) ? $cart_view['pending_orders'] : []; +$shipping_methods = is_array($cart_view['shipping_methods'] ?? null) ? array_values($cart_view['shipping_methods']) : []; +$payment_methods = is_array($cart_view['payment_methods'] ?? null) ? array_values($cart_view['payment_methods']) : []; +$address_book = is_array($cart_view['address_book'] ?? null) ? $cart_view['address_book'] : []; +?> + +
      + + +
      +
      +
      +

      +

      +
      + +
      + +
      + + + +
      +
      + +
      + +
      +
      +
      +
      +

      + +
      +
      +
      + +

      + +
      +
      +
      + +
      +
      +
      +

      + +
      +
      + +
      + +

      +
      + + $address) : ?> +
      + +
      + + + + + +
      +
      + +
      +
      + + + + +
      +
      +

      +
      +
      +
      +

      +
      + $method) : ?> + + +
      +
      + +
      +

      +
      + $method) : ?> + + +
      +
      +
      +
      + +
      +
      +

      +
      +
      + +

      + + +
      +
      +
      +
      + + +
      +
      +
      + + diff --git a/templates/pages/page-categories.php b/templates/pages/page-categories.php new file mode 100644 index 00000000..25cf8c4e --- /dev/null +++ b/templates/pages/page-categories.php @@ -0,0 +1,130 @@ + 'category', + 'hide_empty' => false, // 全部展示,含 0 篇文章的分类 + 'orderby' => 'menu_order name', + 'order' => 'ASC', +]); +if (is_wp_error($all_terms)) { + $all_terms = []; +} + +$top_level = []; +$children_by_parent = []; +foreach ($all_terms as $term) { + if ((int) $term->parent === 0) { + $top_level[] = $term; + } else { + $children_by_parent[(int) $term->parent][] = $term; + } +} + +$total_cats = count($all_terms); +$total_counts = wp_count_posts(); +$total_posts = $total_counts && isset($total_counts->publish) ? (int) $total_counts->publish : 0; + +$page_title = trim((string) get_the_title()); +if ($page_title === '') { + $page_title = __('分类目录', 'aksrt'); +} +?> + +
      + + + + + +
      + term_id; + $cat_link = get_category_link($cat_id); + $cover_url = class_exists('AKSRT_Settings') + ? AKSRT_Settings::get_category_cover_url($cat_id) + : (get_template_directory_uri() . '/assets/img/placeholder.svg'); + $desc = class_exists('AKSRT_Settings') + ? AKSRT_Settings::get_category_recommend_desc($cat_id) + : (string) $cat->description; + $children = $children_by_parent[$cat_id] ?? []; + ?> + + +
      + +
      +

      +
      + + + +
      + +get_root_pages($default_order) + : get_pages([ + 'sort_column' => $default_order === 'title' ? 'post_title' : ($default_order === 'date' ? 'post_date' : 'menu_order, post_title'), + 'sort_order' => 'ASC', + 'parent' => 0, + 'hierarchical' => 0, + 'post_status' => 'publish', + ]); + +// Recent updates +$recent_pages = $presenter + ? $presenter->get_recent_pages(8) + : get_pages([ + 'sort_column' => 'post_modified', + 'sort_order' => 'DESC', + 'number' => 8, + 'post_status' => 'publish', + ]); + +$show_sidebar = !empty($doc_settings['toc_enabled']) || !empty($recent_pages); +?> + +
      + +
      +

      + +

      + +
      + +
      +
      +
      +
      + + + + + + +
      +
      + + + + +
      + +
      + + +
      + +
      + +
      + get_flat_tree($p->ID, 1) + : get_pages([ + 'child_of' => $p->ID, + 'sort_column' => 'menu_order', + 'sort_order' => 'ASC', + 'parent' => $p->ID, + 'post_status' => 'publish', + ]); + $child_count = count($children); + $thumb = get_the_post_thumbnail_url($p->ID, 'medium'); + $excerpt = wp_trim_words($p->post_excerpt, 30, '...'); + ?> + + +
      + > +
      + +
      + +
      + + +
      +

      post_title); ?>

      + +

      + +
      + 0) : ?> + + + + + + + + ID)); ?> + +
      +
      + + +
      + +
      + +
      + +

      +
      + +
      + + + + + +
      +
      +
      +
      + + 'post', + 'post_status' => 'publish', + 'posts_per_page' => $per_page, + 'paged' => $paged, + 'orderby' => 'date', + 'order' => 'DESC', + 'ignore_sticky_posts' => true, + 'meta_query' => [ + [ + 'key' => AKSRT_Domain_Model::RESOURCE_MARKER_KEY, + 'value' => '1', + ], + ], +]; + +if ($type_filter !== '') { + $query_args['meta_query'][] = [ + 'key' => AKSRT_Domain_Model::PAY_TYPE_KEY, + 'value' => $type_filter, + ]; +} + +$resource_query = new WP_Query($query_args); + +$is_logged_in = is_user_logged_in(); +$user_credits = 0; +$user_download_count = 0; +if ($is_logged_in) { + $uid = get_current_user_id(); + if (class_exists('AKSRT_Credits')) { + $user_credits = AKSRT_Credits::balance($uid); + } + $dl_meta = get_user_meta($uid, '_aksrt_downloaded_resources', true); + $user_download_count = is_array($dl_meta) ? count($dl_meta) : 0; +} + +$currency_name = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_currency_name() : __('积分', 'aksrt'); +$dl_settings = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_cms_features() : []; +$dl_requires_login = !empty($dl_settings['download_requires_login']); +?> + +
      + + + +
      +
      +
      +
      +
      + +
      + + + +
      +
      + + + +
      +
      + + +
      + +
      + + + + + + + + +
      +
      + + + + +
      + +
      + +
      +
      + have_posts()) : ?> +
      + have_posts()) : $resource_query->the_post(); ?> +
      + +
      + + + +
      + +
      +

      + +

      +

      +
      + + + + + 0) : + ?> + + + + + +
      + __('免费', 'aksrt'), 'free' => true]; + ?> + +
      +
      + +
      + + +
      + +

      +
      + +
      + +
      +
      +
      +
      + +get_home_view($current_user_id) : [ + 'boards' => [], + 'recent_topics' => [], + 'hot_topics' => [], + 'stats' => [], + 'permissions' => ['can_compose' => false, 'can_manage' => false], + 'applications' => [], + 'home_tabs' => [], + 'active_index' => 1, + 'quick_posts' => true, +]; + +$boards = $view['boards'] ?? []; +$recent_topics = $view['recent_topics'] ?? []; +$hot_topics = $view['hot_topics'] ?? []; +$stats = $view['stats'] ?? []; +$permissions = $view['permissions'] ?? []; +$applications = $view['applications'] ?? []; +$compose_url = home_url('/compose/'); + +$forum_settings = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_forum_settings() : []; +$forum_names = $forum_settings['names'] ?? []; +$name_forum = $forum_names['forum'] ?? __('论坛', 'aksrt'); +$name_plate = $forum_names['plate'] ?? __('版块', 'aksrt'); +$name_topic = $forum_names['posts'] ?? __('主题', 'aksrt'); +$name_reply = $forum_names['comment'] ?? __('回复', 'aksrt'); +$admin_forum_url = admin_url('admin.php?page=aksrt-theme-admin#/forum'); + +$home_tabs = $view['home_tabs'] ?? []; +$active_index = max(0, min(count($home_tabs) - 1, (int) ($view['active_index'] ?? 1))); +$quick_posts = !empty($view['quick_posts']); +$nonce = wp_create_nonce('aksrt_public_nonce'); + +// Resolve active tab from query param or default +$active_tab_type = isset($_GET['forum_tab']) ? sanitize_key(wp_unslash($_GET['forum_tab'])) : ''; +if ($active_tab_type === '' && isset($home_tabs[$active_index])) { + $active_tab_type = $home_tabs[$active_index]['type'] ?? 'synthesis'; +} +if ($active_tab_type === '') { + $active_tab_type = 'synthesis'; +} +?> + +
      + +
      +
      +

      + +

      + +
      + + + + + + + + + + + + + + +
      +
      + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      +
      + + +
      + +
      + + +
      +
      +
      +
      +
      +
      + + +
      + + + + +
      + + + + + + +
      + + + + +
      + +

      +
      + + + + get_follow_stream($current_user_id) : ['items' => []]; + if (!empty($follow_data['items'])) : ?> +
      + render_topic_row($topic); } ?> +
      + 1) : ?> +
      + +
      + + 0) : ?> +
      + +

      +
      + +
      + +

      +
      + + + + get_pending_stream($current_user_id) : ['items' => []]; + if (!empty($pending_data['items'])) : ?> +
      + render_topic_row($topic); } ?> +
      + +
      + +

      +
      + + + + +
      + render_topic_row($topic); } + } ?> +
      + +
      + +

      +
      + + +
      +
      + + +
      +
      +
      +
      +
      +
      + + diff --git a/templates/pages/page-friends.php b/templates/pages/page-friends.php new file mode 100644 index 00000000..0a71ef07 --- /dev/null +++ b/templates/pages/page-friends.php @@ -0,0 +1,61 @@ + + +
      + + + +
      + + empty( $n['read'] ) ); +} elseif ( $current_type !== 'all' ) { + $filtered = array_filter( $filtered, static fn( $n ) => ( $n['type'] ?? '' ) === $current_type ); +} +$filtered = array_values( $filtered ); + +// 分页 +$per_page = 20; +$current_page = max( 1, (int) ( $_GET['msg_page'] ?? 1 ) ); +$total = count( $filtered ); +$total_pages = max( 1, (int) ceil( $total / $per_page ) ); +$offset = ( $current_page - 1 ) * $per_page; +$paged_items = array_slice( $filtered, $offset, $per_page ); + +// 统计各类型数量 +$type_counts = [ 'all' => count( $all_notifications ), 'unread' => 0 ]; +foreach ( $all_notifications as $n ) { + $t = $n['type'] ?? 'notice'; + $type_counts[ $t ] = ( $type_counts[ $t ] ?? 0 ) + 1; + if ( empty( $n['read'] ) ) { + $type_counts['unread']++; + } +} + +// 导航定义 +$nav_items = [ + 'all' => __( '全部消息', 'aksrt' ), + 'unread' => __( '未读', 'aksrt' ), + 'conversation' => __( '私信', 'aksrt' ), + 'comment_reply' => __( '评论回复', 'aksrt' ), + 'comment_approved' => __( '评论审核', 'aksrt' ), + 'credits_added' => __( '积分增加', 'aksrt' ), + 'credits_deducted' => __( '积分扣减', 'aksrt' ), + 'download_ready' => __( '下载就绪', 'aksrt' ), + 'upload_moderation'=> __( '图片审核', 'aksrt' ), + 'notice' => __( '系统通知', 'aksrt' ), +]; + +// 图标映射 +$icon_map = [ + 'comment_reply' => 'fas fa-comment-dots', + 'comment_approved' => 'fas fa-check-circle', + 'credits_added' => 'fas fa-coins', + 'credits_deducted' => 'fas fa-coins', + 'download_ready' => 'fas fa-download', + 'upload_moderation'=> 'fas fa-image', + 'verify_email' => 'fas fa-envelope-open-text', + 'pending_comments' => 'fas fa-clock', + 'conversation' => 'fas fa-envelope', + 'notice' => 'fas fa-bell', +]; +?> + +
      + + +
      + get_conversations( $user_id, 30 ) : []; + $pm_nonce = wp_create_nonce( 'aksrt_pm' ); + ?> +
      + +
      + +
      + /> +
      +
      + + 0 ) : ?> + + +
      +
      +
      +
      +
      + +
      + +
      + +

      +

      +
      + +
      + + + + + +
      + +
      + + + +
      +
      + + + NEW + +
      + +
      + + +
      + +
      +
      + + + + + + + + + +
      +
      + +
      + + 1 ) : ?> +
      + $current_type, 'msg_page' => $i ] ); + ?> + + + + + + +
      + + + +
      + +

      +
      + +
      +
      + + diff --git a/templates/pages/page-newposts.php b/templates/pages/page-newposts.php new file mode 100644 index 00000000..635f9241 --- /dev/null +++ b/templates/pages/page-newposts.php @@ -0,0 +1,67 @@ + 'post', + 'post_status' => 'publish', + 'posts_per_page' => $per_page, + 'paged' => $paged, + 'orderby' => 'date', + 'order' => 'DESC', +]); +?> + +
      + + +
      +
      +
      +
      + have_posts()) : ?> +
      + have_posts()) : $query->the_post(); ?> +
      + +

      + +

      +

      +
      + +
      + ' . paginate_links(['total' => $query->max_num_pages]) . '
      '; + } + ?> + + +

      + +
      +
      +
      + +
      + + AKSRT_Order::STATUS_PENDING, + 'shipped' => AKSRT_Order::STATUS_DELIVERED, + 'received' => AKSRT_Order::STATUS_COMPLETED, + 'review' => AKSRT_Order::STATUS_PENDING_REVIEW, + 'after' => AKSRT_Order::STATUS_REFUNDING, +]; +if ($tab !== '' && isset($tab_status_map[$tab])) { + $status = $tab_status_map[$tab]; +} + +$orders_view = function_exists('aksrt_get_orders_page_view') + ? aksrt_get_orders_page_view($user_id, $paged, $per_page, $status) + : ['items' => [], 'total' => 0, 'total_pages' => 1, 'page' => 1]; + +$orders = (array) ($orders_view['items'] ?? []); +$total_pages = max(1, (int) ($orders_view['total_pages'] ?? 1)); + +$shop = class_exists('AKSRT_Shop') ? AKSRT_Shop::get_instance() : null; +$shipping = class_exists('AKSRT_Shipping') ? AKSRT_Shipping::get_instance() : null; + +$tabs = [ + '' => __('全部', 'aksrt'), + 'pending' => __('待支付', 'aksrt'), + 'shipped' => __('待收货', 'aksrt'), + 'review' => __('待评价', 'aksrt'), + 'after' => __('售后', 'aksrt'), +]; +?> + +
      +
      +
      +

      +
      + + +
      + $tab_label) : + $is_active = $tab === $tab_key; + $tab_url = $tab_key !== '' ? add_query_arg(['tab' => $tab_key, 'order_page' => 1]) : remove_query_arg(['tab', 'order_status', 'order_page']); + ?> + + + + +
      + + +
      + +
      + +
      + + + +
      + + +
      + + +
      + + + +
      + + + + +
      +
      + ¥ + × +
      +
      + + +
      + + ¥ +
      + +
      + + +
      + + ¥ + 0) : ?> + () + +
      + + + +
      +

      + +

      + +
      + + + +
      + + + + + + + + + + + + + + + + + + + + + + + +
      +
      + +
      + + 1) : ?> +
      + $i]); + ?> + + + + + + +
      + + +
      + +

      +
      + + + + + + + get_user_favorites($user_id); + ?> +
      +

      + + + +

      + +
      + +
      +
      + + diff --git a/templates/pages/page-posts-navs.php b/templates/pages/page-posts-navs.php new file mode 100644 index 00000000..d6a33f9d --- /dev/null +++ b/templates/pages/page-posts-navs.php @@ -0,0 +1,59 @@ + true, 'orderby' => 'count', 'order' => 'DESC', 'number' => 20]); +$tags = get_tags(['hide_empty' => true, 'orderby' => 'count', 'order' => 'DESC', 'number' => 40]); +?> + +
      + + +
      +
      +
      +
      + +
      +

      + +
      + + + +
      +

      +
      + + name); ?> + +
      +
      + +
      +
      +
      +
      +
      + + false, 'is_edit' => false, 'product' => [], 'taxonomy' => ['categories' => [], 'tags' => []], 'shipping_modes' => [], 'status_options' => []]; + +$product = (array) ($edit_view['product'] ?? []); +?> + +
      + + +
      + +
      + +

      +
      + +
      +
      +

      + +

      +

      +
      +
      + +
      +
      +
      +

      + + +
      + + +
      + + + + +
      + +
      +

      + + +
      + +
      +

      +
      + + +
      + + + + +
      +
      + + +
      + +
      +
      + + diff --git a/templates/pages/page-product.php b/templates/pages/page-product.php new file mode 100644 index 00000000..10691f8a --- /dev/null +++ b/templates/pages/page-product.php @@ -0,0 +1,413 @@ +get_product_detail_view($product_id) : []); + +$user_id = get_current_user_id(); +$is_fav = $shop && $user_id > 0 ? $shop->is_favorite($user_id, $product_id) : false; +$options = $product['options'] ?? []; +$has_options = !empty($options); +$price_range = $product['price_range'] ?? []; +$params = $product['params'] ?? []; +$services = $product['services'] ?? []; +$pay_modo = $product['pay_modo'] ?? '0'; +$scores = class_exists('AKSRT_Product_Review') ? AKSRT_Product_Review::get_instance()->get_product_scores($product_id) : []; +$reviews = class_exists('AKSRT_Product_Review') ? AKSRT_Product_Review::get_instance()->get_reviews($product_id, ['per_page' => 5]) : ['items' => [], 'total' => 0]; +$author_id = (int) get_post_field('post_author', $product_id); +$author = $author_id > 0 ? get_userdata($author_id) : null; +?> + +
      + + + +
      +
      + + + + +
      +
      + + + + + + + + + +
      + +

      + + +
      + + + - + + + + + + + + + + + + +
      + + +

      + + + + +
      + $group) : ?> +
      + +
      + + + +
      +
      + + +
      + + + +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      0 ? '¥' . esc_html(number_format($ship_fee, 2)) : esc_html__('包邮', 'aksrt'); + ?>
      +
      +
      + + + + + + + + + + +
      + + +
      + + + + + + + + + + +
      + + +
      + + +
      +
      + + + +
      + +
      + display_name); ?> + +
      +
      + +
      +
      + + +
      + + +
      +
      + +
      +
      + +
      + +
      + + +
      + + + +
      +
      + + +
      + +
      +
      + + + + + + + + +
      +

      + +
      + + + +
      + +
      + + + +

      + +
      +
      +
      + + + 'aksrt_product', + 'posts_per_page' => 6, + 'post_status' => 'publish', + 'post__not_in' => [$product_id], + 'tax_query' => !empty($product['categories']) ? [[ + 'taxonomy' => 'aksrt_product_cat', + 'field' => 'term_id', + 'terms' => wp_list_pluck($product['categories'], 'term_id'), + ]] : [], + 'orderby' => 'rand', + ]); + ?> + have_posts()) : ?> + + +
      + + + + + + + + + + + + +
      + + + +
      + +
      +
      + +

      +
      +
      + +
      + + diff --git a/templates/pages/page-profile.php b/templates/pages/page-profile.php new file mode 100644 index 00000000..98b2cbf4 --- /dev/null +++ b/templates/pages/page-profile.php @@ -0,0 +1,1419 @@ + + +
      + + + $uid, 'display_name' => sanitize_text_field( $_POST['nickname'] ) ] ); + } + if ( isset( $_POST['description'] ) ) { + wp_update_user( [ 'ID' => $uid, 'description' => sanitize_textarea_field( $_POST['description'] ) ] ); + } + $contacts = []; + if ( isset( $_POST['contacts'] ) && is_array( $_POST['contacts'] ) ) { + foreach ( $_POST['contacts'] as $c ) { + $u = isset( $c['url'] ) ? esc_url_raw( $c['url'] ) : ''; + $i = isset( $c['icon'] ) ? sanitize_key( $c['icon'] ) : ''; + // 清理 FA5 类名前缀:fab fa-xxx / fabfa-xxx / fa-xxx → xxx + $i = preg_replace('/^(fab|fas|far)[\s-]*/', '', $i); + $i = preg_replace('/^fa[\s-]*/', '', $i); + $i = preg_replace('/^[\s-]+/', '', $i); + $i = trim( $i, '- ' ); + if ( ! $u || ! $i ) continue; + $n = aksrt_icon_key_to_name( $i ); + $contacts[] = [ 'name' => $n, 'url' => $u, 'icon' => $i ]; + } + } + update_user_meta( $uid, 'aksrt_user_contacts', $contacts ); + } + wp_safe_redirect( add_query_arg( 'saved', '1', aksrt_get_user_center_url() ) ); + exit; +} + +// ─── 确定目标用户 ───────────────────────────────────────────── +// 来源: /user/{昵称} (自定义重写规则) +// 注意: get_queried_object_id() 在页面中返回的是页面 ID 而非用户 ID +$target_user_id = 0; +$target_user_nicename = get_query_var('user_nicename'); + +if ( $target_user_nicename ) { + $user = get_user_by( 'slug', $target_user_nicename ); + if ( $user ) { + $target_user_id = $user->ID; + } +} + +// 兜底:检查 queried object +if ( ! $target_user_id ) { + $qid = get_queried_object_id(); + if ( $qid && get_userdata( $qid ) ) { + $target_user_id = $qid; + } +} + +$current_user = wp_get_current_user(); +$target_user = $target_user_id ? get_userdata( $target_user_id ) : null; +$is_own_profile = false; + +if ( ! $target_user ) { + if ( is_user_logged_in() ) { + $target_user = $current_user; + $target_user_id = $current_user->ID; + $is_own_profile = true; + } else { + wp_safe_redirect( aksrt_get_login_url( aksrt_get_user_center_url() ) ); + exit; + } +} elseif ( is_user_logged_in() && (int) $current_user->ID === (int) $target_user->ID ) { + $is_own_profile = true; +} + +if ( $target_user_nicename && ! $is_own_profile && function_exists( 'aksrt_get_user_home_url' ) ) { + wp_safe_redirect( aksrt_get_user_home_url( $target_user ) ); + exit; +} + +$user_id = $target_user->ID; +$post_count = (int) count_user_posts( $user_id, 'post', true ); +$comment_count = (int) get_comments( [ 'user_id' => $user_id, 'count' => true, 'status' => 'approve' ] ); + +// 加载用户自定义联系方式 +$user_contacts = get_user_meta( $user_id, 'aksrt_user_contacts', true ) ?: []; +if ( ! is_array( $user_contacts ) ) $user_contacts = []; +$user_center_settings = class_exists( 'AKSRT_Settings' ) + ? AKSRT_Settings::get_user_center_settings() + : [ + 'tabs' => [ 'profile', 'posts', 'comments', 'favorites' ], + 'avatar_upload' => true, + 'cover_upload' => true, + 'social_links' => true, + ]; +$user_home_settings = class_exists( 'AKSRT_Settings' ) + ? AKSRT_Settings::get_user_home_settings() + : [ + 'tabs' => [ 'posts', 'comments', 'favorites' ], + 'sidebar' => true, + 'cover' => true, + 'follow' => true, + 'posts_per_page' => 10, + ]; +$user_home_manager = class_exists( 'AKSRT_User_Home' ) ? AKSRT_User_Home::get_instance() : null; +$profile_signals = $user_home_manager ? $user_home_manager->get_profile_signals( $user_id ) : []; +$profile_notice = $user_home_manager ? $user_home_manager->get_profile_notice( $user_id ) : []; +$show_report_button = $user_home_manager ? $user_home_manager->show_report_entry() : false; +$public_visible_tabs = $user_home_manager + ? $user_home_manager->get_visible_tabs( false ) + : [ + 'posts' => [ 'label' => __( '文章', 'aksrt' ) ], + 'comments' => [ 'label' => __( '评论', 'aksrt' ) ], + 'favorites' => [ 'label' => __( '收藏', 'aksrt' ) ], + ]; + +// 用户自定义背景图 +$user_background_url = get_user_meta( $user_id, 'aksrt_background_image', true ) ?: ''; +$allow_bg_upload = $is_own_profile + && ! empty( $user_center_settings['cover_upload'] ) + && class_exists( 'AKSRT_Settings' ) + && aksrt_get_setting( 'allow_background_upload', false ); +$show_avatar_edit = $is_own_profile && ! empty( $user_center_settings['avatar_upload'] ); +$avatar_settings_url = admin_url( 'profile.php' ); +$public_home_url = function_exists( 'aksrt_get_user_home_url' ) ? aksrt_get_user_home_url( $target_user ) : home_url( '/' ); + +$favorite_post_ids = function_exists( 'aksrt_get_user_interaction_posts' ) + ? aksrt_get_user_interaction_posts( '_aksrt_favorite_posts', $user_id ) + : []; +$favorite_count = count( $favorite_post_ids ); +$notification_unread_count = $is_own_profile && function_exists( 'aksrt_get_user_unread_notification_count' ) + ? aksrt_get_user_unread_notification_count( $user_id ) + : 0; +$dashboard_tabs = class_exists( 'AKSRT_User_Center' ) + ? AKSRT_User_Center::get_instance()->get_dashboard_tabs() + : [ + 'profile' => [ 'label' => __( '个人信息', 'aksrt' ), 'icon' => 'user' ], + 'posts' => [ 'label' => __( '我的文章', 'aksrt' ), 'icon' => 'file-alt' ], + 'comments' => [ 'label' => __( '我的评论', 'aksrt' ), 'icon' => 'comment' ], + 'favorites' => [ 'label' => __( '我的收藏', 'aksrt' ), 'icon' => 'star' ], + 'settings' => [ 'label' => __( '账号设置', 'aksrt' ), 'icon' => 'cog' ], + ]; +$dashboard_tab_keys = array_keys( $dashboard_tabs ); +$public_tab_keys = array_keys( $public_visible_tabs ); +if ( empty( $public_tab_keys ) ) { + $public_visible_tabs = [ + 'posts' => [ 'label' => __( '文章', 'aksrt' ) ], + 'comments' => [ 'label' => __( '评论', 'aksrt' ) ], + 'favorites' => [ 'label' => __( '收藏', 'aksrt' ) ], + ]; + $public_tab_keys = array_keys( $public_visible_tabs ); +} +$public_tab_labels = [ + 'posts' => __( '文章', 'aksrt' ), + 'comments' => __( '评论', 'aksrt' ), + 'favorites' => __( '收藏', 'aksrt' ), +]; +foreach ( $public_visible_tabs as $public_tab_key => $public_tab_config ) { + if ( ! empty( $public_tab_config['label'] ) ) { + $public_tab_labels[ $public_tab_key ] = (string) $public_tab_config['label']; + } +} +$public_tab_counts = [ + 'posts' => $post_count, + 'comments' => $comment_count, + 'favorites' => $favorite_count, +]; + +// 消息中心页面 URL +$messages_page = get_pages( [ 'meta_key' => '_wp_page_template', 'meta_value' => 'templates/pages/page-messages.php', 'number' => 1 ] ); +$messages_url = ! empty( $messages_page ) ? get_permalink( $messages_page[0] ) : aksrt_get_user_center_url( 'notif' ); + +// ─── 文章状态计数(仅自己模式;用于状态筛选 chip) ──────────── +$user_status_counts = [ + 'publish' => 0, + 'draft' => 0, + 'pending' => 0, + 'private' => 0, +]; +if ( $is_own_profile ) { + global $wpdb; + $rows = $wpdb->get_results( $wpdb->prepare( + "SELECT post_status, COUNT(*) AS num FROM {$wpdb->posts} + WHERE post_author = %d AND post_type = 'post' + AND post_status IN ('publish','draft','pending','private') + GROUP BY post_status", + $user_id + ) ); + foreach ( (array) $rows as $row ) { + $user_status_counts[ $row->post_status ] = (int) $row->num; + } + $post_count = (int) array_sum( $user_status_counts ); +} + +// ─── 分页与筛选参数(自己 / 他人 共享) ──────────────────────── +$per_page = $is_own_profile ? 10 : max( 5, (int) ( $user_home_settings['posts_per_page'] ?? 10 ) ); +$default_dashboard_tab = in_array( 'profile', $dashboard_tab_keys, true ) ? 'profile' : ( $dashboard_tab_keys[0] ?? 'profile' ); +$active_tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : $default_dashboard_tab; +$public_default_tab = $public_tab_keys[0] ?? 'posts'; +$public_active_tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : $public_default_tab; +// 兼容旧链接:签到 / 邀请已经并入新面板 +if ( 'signin' === $active_tab ) { + $active_tab = 'credits'; +} elseif ( 'invite' === $active_tab ) { + $active_tab = 'settings'; +} +if ( ! in_array( $active_tab, $dashboard_tab_keys, true ) ) { + $active_tab = $default_dashboard_tab; +} +if ( ! in_array( $public_active_tab, $public_tab_keys, true ) ) { + $public_active_tab = $public_default_tab; +} +$posts_page = max( 1, isset( $_GET['posts_page'] ) ? (int) $_GET['posts_page'] : 1 ); +$comments_page = max( 1, isset( $_GET['comments_page'] ) ? (int) $_GET['comments_page'] : 1 ); +$favorites_page = max( 1, isset( $_GET['favorites_page'] ) ? (int) $_GET['favorites_page'] : 1 ); + +$valid_post_status = [ 'all', 'publish', 'draft', 'pending', 'private' ]; +$post_status_filter = isset( $_GET['post_status'] ) ? sanitize_key( wp_unslash( $_GET['post_status'] ) ) : 'publish'; +if ( ! in_array( $post_status_filter, $valid_post_status, true ) ) { + $post_status_filter = 'publish'; +} + +// 空状态图标 SVG(提取以减少重复) +$svg_empty_post = ''; +$svg_empty_comment = ''; +$svg_empty_favorite = ''; +?> + +
      + + + +
      + + 'GitHub', + 'gitee' => 'Gitee', + 'qq' => 'QQ', + 'google' => 'Google', + ]; + ?> + + + + + +
      +
      + +
      + + + + +
      +
      +
      + display_name ); ?> +
      +
      +

      + display_name ); ?> + +

      + description ) : ?> +

      description ); ?>

      + + +
      + + + + + + +
      + +
      + + user_login ); ?> + + user_email ) : ?> + + user_email ); ?> + + +
      +
      +
      + + + + + + + + +
      +
      +
      + + +
      + + + + +
      + +
      + + +
      + $tab ) : ?> + + +
      + + + +
      > +
      + + + + +
      +

      + +

      +
      + + +
      +

      + +

      +
      + + + +
      +

      +

      +
      + $contact ) : ?> +
      + + + + +
      + +
      + +
      + + + +
      + + +
      +
      +
      + + +
      > +
      +
      +

      + +

      + __( '已发布', 'aksrt' ), + 'draft' => __( '草稿', 'aksrt' ), + 'pending' => __( '待审', 'aksrt' ), + 'private' => __( '私密', 'aksrt' ), + 'all' => __( '全部', 'aksrt' ), + ]; + ?> +
      + $status_text ) : + $count_for_chip = ( 'all' === $status_key ) + ? $post_count + : ( $user_status_counts[ $status_key ] ?? 0 ); + if ( 'publish' !== $status_key && 'all' !== $status_key && $count_for_chip < 1 ) { + continue; + } + $chip_url = add_query_arg( [ + 'tab' => 'posts', + 'post_status' => $status_key, + 'posts_page' => 1, + ], remove_query_arg( [ 'posts_page' ] ) ); + $chip_active = ( $post_status_filter === $status_key ); + ?> + + + + + +
      +
      + + $user_id, + 'post_status' => ( 'all' === $post_status_filter ) + ? [ 'publish', 'draft', 'pending', 'private' ] + : $post_status_filter, + 'posts_per_page' => $per_page, + 'paged' => $posts_page, + 'no_found_rows' => false, + 'update_post_meta_cache' => false, + 'update_post_term_cache' => true, + ]; + $q = new WP_Query( $ps_args ); + if ( $q->have_posts() ) : ?> +
      + have_posts() ) : $q->the_post(); + $st = get_post_status(); + $status_label = ''; + if ( 'publish' !== $st ) { + $st_map = [ + 'draft' => __( '草稿', 'aksrt' ), + 'pending' => __( '待审', 'aksrt' ), + 'private' => __( '私密', 'aksrt' ), + 'future' => __( '定时', 'aksrt' ), + ]; + $status_label = $st_map[ $st ] ?? $st; + } + set_query_var( 'aksrt_profile_item_status', $status_label ); + + $edit_link = get_edit_post_link(); + set_query_var( 'aksrt_profile_item_action', $edit_link ? [ + 'type' => 'edit', + 'url' => $edit_link, + 'label' => __( '编辑', 'aksrt' ), + ] : [] ); + + get_template_part( 'templates/parts/content', 'profile-list-item' ); + endwhile; ?> +
      + max_num_pages, $posts_page, 'posts_page' ); ?> + +
      +
      + + +
      > +
      +
      +

      + +

      +
      + $user_id, + 'number' => $per_page, + 'offset' => ( $comments_page - 1 ) * $per_page, + 'status' => 'approve', + 'update_comment_meta_cache' => false, + ] ); + if ( $cs ) : ?> +
      + +
      + + +
      +
      + + +
      > +
      +
      +

      + +

      +
      + 'post', + 'post_status' => 'publish', + 'post__in' => $favorite_post_ids, + 'orderby' => 'post__in', + 'posts_per_page' => $per_page, + 'paged' => $favorites_page, + 'ignore_sticky_posts' => true, + 'no_found_rows' => false, + 'update_post_meta_cache' => false, + ] ); + if ( $favorites_query->have_posts() ) : ?> +
      + have_posts() ) : $favorites_query->the_post(); + set_query_var( 'aksrt_profile_item_action', [ + 'type' => 'unfavorite', + 'url' => '#', + 'label' => __( '取消收藏', 'aksrt' ), + ] ); + set_query_var( 'aksrt_profile_item_status', '' ); + get_template_part( 'templates/parts/content', 'profile-list-item' ); + endwhile; ?> +
      + max_num_pages, $favorites_page, 'favorites_page' ); ?> + +
      +
      + + + +
      > + + +
      +
      +
      +
      + + +
      +
      + +
      + +
      + + +
      +

      + +
      +
      + + + +
      +
      +
      +
      +
      + +
      +
      + + + + + + +
      +
      + +
      + + +
      + +
      + + +
      + + + + + + +
      +
      + + + + + + + get_recent_signins($user_id, 30); + $signin_dates = array_map(function ($entry) { + return $entry['signin_date'] ?? ''; + }, $signin_log); + $signin_dates = array_filter($signin_dates); + } + ?> + + + + + +
      +
      +

      + + +

      +
      + $user_id, 'page' => $credits_page, 'per_page' => 10 ] ); + } else { + $log = [ 'items' => [], 'total' => 0 ]; + } + if ( ! empty( $log['items'] ) ) : + ?> +
      + = 0 ? 'is-positive' : 'is-negative'; + ?> +
      +
      + +
      +
      +
      + + + () + +
      +
      + +
      +
      +
      + + 0 ? '+' : '' ) . $delta ); ?> + + + + +
      +
      + +
      + 1 ) { + aksrt_render_profile_pagination( $total_pages, $credits_page, 'credits_page' ); + } + ?> + +
      +
      + +
      +

      +
      + +
      +
      + + +
      > + get_frontend_bindings_data($user_id); + $user_email = $target_user->user_email ?: ''; + $binding_name_map = [ + 'github' => 'GitHub', + 'gitee' => 'Gitee', + 'qq' => 'QQ', + 'google' => 'Google', + ]; + $binding_rows = []; + foreach ( (array) ( $oauth_data['bindings'] ?? [] ) as $binding ) { + $key = $binding['key'] ?? ''; + if ( ! $key ) continue; + $binding_rows[ $key ] = [ + 'key' => $key, + 'name' => $binding_name_map[ $key ] ?? ( $binding['name'] ?? ucfirst( $key ) ), + 'icon' => $binding['icon'] ?? '', + 'is_bound' => true, + 'url' => '', + ]; + } + foreach ( (array) ( $oauth_data['available'] ?? [] ) as $binding ) { + $key = $binding['key'] ?? ''; + if ( ! $key ) continue; + $binding_rows[ $key ] = [ + 'key' => $key, + 'name' => $binding_name_map[ $key ] ?? ( $binding['name'] ?? ucfirst( $key ) ), + 'icon' => $binding['icon'] ?? '', + 'is_bound' => false, + 'url' => $binding['url'] ?? '', + ]; + } + ?> + + +
      +
      + +

      +
      + + +

      +
      + + +
      + + + + + + + + + +
      + + + + + + + + + + + +
      + +

      + +
      + + +
      +
      + +

      +
      +
      + +
      +
      + +
      + + +
      +
      + +
      + +
      +
      + +
      + + +
      +
      + +
      +
      +
      + + +
      +
      + +

      +
      +
      +
      +
      + +

      + +

      +
      +
      + + -- +
      +
      + +
      +
      + + 0 + +
      +
      + + 0 + +
      +
      + + 0 + +
      +
      +
      +
      +

      + +
      +
      +
      + +
      +
      +
      +
      +
      + + + + + + + + + +
      + + + + + + +
      +
      +
      + + + +
      > +
      + display_name ); ?> +

      display_name ); ?>

      + + +
      + + + + + + +
      + + + +
      + + + + + + + + + +
      + +
      + +
      + description ) : ?> +

      description ) ); ?>

      + +

      + +
      + + + + + + + + + + + + + + + + + +
      +
      +
      + + +
      + + +
      + + +
      +
      + + + +
      + + +
      > + $user_id, + 'posts_per_page' => $per_page, + 'post_status' => 'publish', + 'paged' => $posts_page, + 'no_found_rows' => false, + 'update_post_meta_cache' => false, + ] ); + if ( $author_posts->have_posts() ) : ?> +
      + have_posts() ) : $author_posts->the_post(); + set_query_var( 'aksrt_profile_item_action', [ + 'type' => 'view', + 'url' => get_permalink(), + 'label' => __( '阅读', 'aksrt' ), + ] ); + set_query_var( 'aksrt_profile_item_status', '' ); + get_template_part( 'templates/parts/content', 'profile-list-item' ); + endwhile; ?> +
      + max_num_pages, $posts_page, 'posts_page' ); + wp_reset_postdata(); + set_query_var( 'aksrt_profile_item_action', [] ); + else : + aksrt_render_profile_empty( $svg_empty_post, __( 'TA 还没有发布文章', 'aksrt' ) ); + endif; ?> +
      + + + +
      > + $user_id, + 'status' => 'approve', + 'number' => $per_page, + 'offset' => ( $comments_page - 1 ) * $per_page, + 'type' => 'comment', + 'update_comment_meta_cache' => false, + ] ); + if ( $user_comments ) : ?> +
      + +
      + + +
      + + + +
      > + 'post', + 'post__in' => $favorite_post_ids, + 'orderby' => 'post__in', + 'posts_per_page' => $per_page, + 'paged' => $favorites_page, + 'no_found_rows' => false, + 'update_post_meta_cache' => false, + ] ); + } else { + $favorite_query = null; + } + + if ( $favorite_query && $favorite_query->have_posts() ) : + ?> +
      + have_posts() ) : $favorite_query->the_post(); + set_query_var( 'aksrt_profile_item_action', [ + 'type' => 'view', + 'url' => get_permalink(), + 'label' => __( '阅读', 'aksrt' ), + ] ); + set_query_var( 'aksrt_profile_item_status', '' ); + get_template_part( 'templates/parts/content', 'profile-list-item' ); + endwhile; ?> +
      + max_num_pages, $favorites_page, 'favorites_page' ); + wp_reset_postdata(); + set_query_var( 'aksrt_profile_item_action', [] ); + else : + aksrt_render_profile_empty( $svg_empty_favorite, __( 'TA 还没有公开收藏内容', 'aksrt' ) ); + endif; ?> +
      + +
      + + + + + + + + + + + +
      + + + +
      +
      +
      + +

      + + + +

      + + + + + + + +

      + + + + + + + + + +

      + + + + + +
      + + + + + +
      + +
      + + + +
      +
      + +
      + +
      + + + +
      +
      + + +
      + + + +

      + + + + + + + + + +
      + + + +
      + +
      + + +
      +
      + + + + +
      + + + + +
      +
      +
      + + 0, 'total_earned' => 0, 'total_withdrawn' => 0, 'records' => []]; + +$balance = (float) ($revenue_view['balance'] ?? 0); +$total_earned = (float) ($revenue_view['total_earned'] ?? 0); +$total_withdrawn = (float) ($revenue_view['total_withdrawn'] ?? 0); +$records = $revenue_view['records'] ?? []; + +$per_page = 20; +$paged = max(1, (int) ($_GET['rev_page'] ?? 1)); +$total_records = count($records); +$total_pages = max(1, (int) ceil($total_records / $per_page)); +$paged_records = array_slice($records, ($paged - 1) * $per_page, $per_page); +?> + +
      +
      +

      +
      + + +
      +
      +
      +
      + ¥ + +
      +
      +
      +
      +
      + ¥ + +
      +
      +
      +
      +
      + ¥ + +
      +
      +
      + + + 0) : ?> +
      + + + +
      + + + +
      +

      + + +
      + +
      +
      + +
      +
      + + +
      +
      + ¥ +
      +
      + +
      + + 1) : ?> +
      + $i]); + ?> + + + + + + +
      + + + +
      + +

      +
      + +
      +
      + + diff --git a/templates/pages/page-sign.php b/templates/pages/page-sign.php new file mode 100644 index 00000000..c1cae16d --- /dev/null +++ b/templates/pages/page-sign.php @@ -0,0 +1,345 @@ + 'signin', + 'signin' => 'signin', + 'register' => 'signup', + 'signup' => 'signup', +]; +$tab_source = $requested_tab !== '' ? $requested_tab : $route_tab; +$tab = $tab_aliases[$tab_source] ?? 'signin'; +$redirect_to = !empty($_GET['redirect_to']) ? esc_url_raw(wp_unslash((string) $_GET['redirect_to'])) : home_url(); +$registration_open = class_exists('AKSRT_Settings') && AKSRT_Settings::registration_enabled(); + +$titles = [ + 'signin' => __('用户登录', 'aksrt'), + 'signup' => __('用户注册', 'aksrt'), +]; +$page_title = ($titles[$tab] ?? __('登录 / 注册', 'aksrt')) . ' - ' . get_bloginfo('name'); + +$interim = isset($_REQUEST['interim-login']); + +// 搂4.1 Defense-in-depth: redirect out when close_sign is active (backup of auth router gate) +if (class_exists('AKSRT_Settings') && AKSRT_Settings::close_sign() && !is_user_logged_in() && !$interim) { + wp_safe_redirect(home_url('/'), 302); + exit; +} + +// §4.1 close_signin: password login disabled — force signin tab to show social-only message +$close_signin = class_exists('AKSRT_Settings') ? AKSRT_Settings::close_signin() : false; +if ($close_signin && $tab === 'signin') { + // Don't allow password form submission + $_SERVER['REQUEST_METHOD'] = 'GET'; +} + +$user_id = get_current_user_id(); +if ($user_id) { + if ($interim && is_super_admin()) { + echo '
      ' . esc_html__('登录成功', 'aksrt') . '
      '; + exit; + } + wp_safe_redirect($redirect_to); + exit; +} + +if ($tab === 'signup' && !$registration_open) { + $tab = 'signin'; +} + +remove_action('wp_footer', 'aksrt_render_footer_tabbar', 20); +remove_action('wp_footer', 'aksrt_render_float_buttons', 30); + +$background_html = ''; +$sign_ui = class_exists('AKSRT_Settings') ? AKSRT_Settings::sign_page_ui() : []; +$card_position = $sign_ui['card_position'] ?? 'center'; +$sign_show_logo = $sign_ui['show_logo'] ?? true; +$sign_show_header = $sign_ui['show_header'] ?? true; +$sign_theme_mode = $sign_ui['theme_mode'] ?? 'default'; +if (class_exists('AKSRT_Settings')) { + $bg_ids = $sign_ui['background'] ?? []; + if (is_array($bg_ids) && !empty($bg_ids)) { + $bg_url = wp_get_attachment_url((int) $bg_ids[array_rand($bg_ids)]); + if ($bg_url) { + $background_html = '
      '; + } + } elseif (is_string($bg_ids) && $bg_ids !== '') { + // BC: comma-separated string from old storage + $ids = array_filter(array_map('absint', explode(',', $bg_ids))); + if (!empty($ids)) { + $bg_url = wp_get_attachment_url($ids[array_rand($ids)]); + if ($bg_url) { + $background_html = '
      '; + } + } + } +} + +$login_error = ''; +if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['log'], $_POST['pwd']) && $tab === 'signin') { + if (class_exists('AKSRT_Auth_Service')) { + $result = AKSRT_Auth_Service::login_user($_POST, false); + if (empty($result['success'])) { + $login_error = $result['error'] ?? __('登录失败', 'aksrt'); + } else { + wp_safe_redirect($result['redirect'] ?? home_url('/')); + exit; + } + } +} + +$register_error = ''; +$register_success = ''; +if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['user_login'], $_POST['user_email']) && $tab === 'signup') { + if (class_exists('AKSRT_Auth_Service')) { + $result = AKSRT_Auth_Service::register_user($_POST, ['oauth_bind_data' => null, 'obk' => '']); + if (!empty($result['success'])) { + if (!empty($result['verification_required'])) { + $register_success = sprintf( + '%s %s', + esc_html($result['message'] ?? __('Registration successful. Please complete email verification.', 'aksrt')), + esc_url($result['verify_url'] ?? ''), + esc_html__('前往验证', 'aksrt') + ); + } else { + $register_success = sprintf( + __('Registration successful. Please continue on the login page.', 'aksrt'), + esc_url(aksrt_get_login_url()) + ); + } + } else { + $register_error = $result['error'] ?? __('注册失败', 'aksrt'); + } + } +} + +$invite_prefill = ''; +if (!empty($_GET['invite'])) { + $invite_prefill = sanitize_key(wp_unslash($_GET['invite'])); +} elseif (!empty($_COOKIE['aksrt_invite'])) { + $invite_prefill = sanitize_key(wp_unslash($_COOKIE['aksrt_invite'])); +} +?> + + + + + + + <?php echo esc_html($page_title); ?> + + + +> + +
      +
      + + + + + +
      + + + + +
      + + +
      +
      + +
      + +
      + +
      + +
      + +
      +
      +
      + +
      + + +
      +
      +
      + + +
      + + + +
      + + + render_social_buttons(); ?> + ' . $social . '
      '; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> + + + + +
      + + +
      +
      +
      ['href' => []]]); ?>
      +
      + +
      + +
      +
      +
      + +
      +
      +
      + +
      + + +
      +
      +
      + +
      + + +
      +
      + +
      + +
      >
      + +

      + +
      + + + +

      + + + +
      + +
      + + +
      + + + + + + + + + + diff --git a/templates/pages/page-special.php b/templates/pages/page-special.php new file mode 100644 index 00000000..bab7f36b --- /dev/null +++ b/templates/pages/page-special.php @@ -0,0 +1,148 @@ + true, + 'summary_enabled' => true, + 'summary_length' => 200, + 'default_cover' => '', + 'layout' => 'grid', + 'columns' => 2, + 'source_type' => 'category', + 'source_ids' => '', + 'subpage_display' => 'cards', + 'subpage_count' => 6, + 'sidebar' => true, + 'pagination' => 'numeric', + 'per_page' => 12, + 'post_count' => false, + 'orderby' => 'date', +]; +$settings = array_merge($defaults, $special); + +// Per-page meta override +$meta_layout = get_post_meta($post_id, '_aksrt_special_layout', true); +$meta_columns = get_post_meta($post_id, '_aksrt_special_columns', true); +$meta_source = get_post_meta($post_id, '_aksrt_special_source_type', true); +$meta_ids = get_post_meta($post_id, '_aksrt_special_source_ids', true); +if ($meta_layout) $settings['layout'] = sanitize_key($meta_layout); +if ($meta_columns) $settings['columns'] = max(1, min(4, (int) $meta_columns)); +if ($meta_source) $settings['source_type'] = sanitize_key($meta_source); +if ($meta_ids !== '' && $meta_ids !== false) $settings['source_ids'] = $meta_ids; + +$layout = in_array($settings['layout'], ['grid', 'list', 'masonry'], true) ? $settings['layout'] : 'grid'; +$columns = max(1, min(4, (int) $settings['columns'])); +$show_cover = !empty($settings['cover_enabled']); +$show_summary = !empty($settings['summary_enabled']); +$summary_len = max(50, min(500, (int) $settings['summary_length'])); +$subpage_style = in_array($settings['subpage_display'], ['cards', 'list', 'timeline'], true) ? $settings['subpage_display'] : 'cards'; +$per_page = max(4, min(48, (int) $settings['per_page'])); +$orderby = in_array($settings['orderby'], ['date', 'modified', 'views', 'comments'], true) ? $settings['orderby'] : 'date'; + +// 聚合内容源 +$source_type = sanitize_key($settings['source_type']); +$source_ids = array_filter(array_map('absint', explode(',', (string) $settings['source_ids']))); +$aggregated_posts = []; + +if (!empty($source_ids)) { + $args = [ + 'posts_per_page' => $per_page, + 'post_status' => 'publish', + 'orderby' => ($orderby === 'modified') ? 'modified' : 'date', + 'order' => 'DESC', + ]; + if ($source_type === 'category') { + $args['category__in'] = $source_ids; + } elseif ($source_type === 'tag') { + $args['tag__in'] = $source_ids; + } + $aggregated_query = new WP_Query($args); + $aggregated_posts = $aggregated_query->posts; +} + +// 子页面列表 +$children_per_page = max(1, min(20, (int) $settings['subpage_count'])); +$children = get_pages([ + 'child_of' => $post_id, + 'sort_column' => 'menu_order', + 'sort_order' => 'ASC', + 'parent' => $post_id, + 'number' => $children_per_page, +]); +?> + +
      + + + + +
      + +
      + +
      + +
      + + +
      +

      + +
      + + + +
      +

      + +
      + +
      + +can_submit($user_id); + +if (!$can_submit) { + wp_redirect(home_url('/')); + exit; +} + +// Check if editing existing draft +$edit_id = isset($_GET['edit']) ? absint($_GET['edit']) : 0; +$edit_post = null; +if ($edit_id > 0) { + $edit_post = get_post($edit_id); + if (!$edit_post || (int) $edit_post->post_author !== $user_id || $edit_post->post_status !== 'draft') { + $edit_post = null; + $edit_id = 0; + } +} + +get_header(); + +// Submit settings +$submit_settings = []; +if (class_exists('AKSRT_Settings')) { + $all = AKSRT_Settings::get_all(); + $submit_settings = [ + 'enabled' => !empty($all['front_submit_enabled']), + 'review_mode' => $all['front_submit_review_mode'] ?? 'pending', + 'allow_cover' => !isset($all['front_submit_cover']) || !empty($all['front_submit_cover']), + 'allow_excerpt' => !isset($all['front_submit_excerpt']) || !empty($all['front_submit_excerpt']), + 'allow_category' => !isset($all['front_submit_category']) || !empty($all['front_submit_category']), + 'allow_tags' => !isset($all['front_submit_tags']) || !empty($all['front_submit_tags']), + 'max_images' => (int) ($all['front_submit_max_images'] ?? 5), + 'allowed_cat_ids' => is_array($all['post_article_cat'] ?? null) ? array_map('intval', $all['post_article_cat']) : [], + 'topics_s' => !empty($all['post_article_topics_s']), + 'custom_filter_s' => !isset($all['post_article_custom_filter_s']) || !empty($all['post_article_custom_filter_s']), + ]; +} + +// §3.7 topics: load topics taxonomy terms +$topics_terms = []; +if (!empty($submit_settings['topics_s']) && taxonomy_exists('topics')) { + $topics_terms = get_terms(['taxonomy' => 'topics', 'hide_empty' => false, 'orderby' => 'name', 'order' => 'ASC']); + if (is_wp_error($topics_terms)) $topics_terms = []; +} +$current_topics = []; +if ($edit_post && taxonomy_exists('topics')) { + $terms = wp_get_object_terms($edit_post->ID, 'topics', ['fields' => 'ids']); + if (!is_wp_error($terms)) $current_topics = $terms; +} + +// §3.4 custom_filter: load dimension definitions for submit form +$custom_dimensions = []; +if (!empty($submit_settings['custom_filter_s']) && class_exists('AKSRT_Settings')) { + $filter_all = AKSRT_Settings::get_post_filter_settings(); + $custom_dimensions = $filter_all['custom_filter']; +} + +// §3.4 custom_filter: load current values for edit mode +$current_cf = []; +if ($edit_post && !empty($custom_dimensions)) { + foreach ($custom_dimensions as $dim) { + $current_cf[$dim['key']] = get_post_meta($edit_post->ID, '_aksrt_cf_' . $dim['key'], true); + } +} + +// §3.7 post_article_cat: filter categories by allowed list +$cat_args = [ + 'taxonomy' => 'category', + 'hide_empty' => false, + 'orderby' => 'name', + 'order' => 'ASC', +]; +if (!empty($submit_settings['allowed_cat_ids'])) { + $cat_args['include'] = $submit_settings['allowed_cat_ids']; +} +$categories = get_categories($cat_args); + +// Current values for edit mode +$current_title = $edit_post ? $edit_post->post_title : ''; +$current_content = $edit_post ? $edit_post->post_content : ''; +$current_excerpt = $edit_post ? $edit_post->post_excerpt : ''; +$current_cats = $edit_post ? wp_get_post_categories($edit_post->ID) : []; +$current_tags = $edit_post ? implode(', ', wp_get_post_tags($edit_post->ID, ['fields' => 'names'])) : ''; +$current_thumb = $edit_post ? get_the_post_thumbnail_url($edit_post->ID, 'large') : ''; +?> + +
      +
      +
      +

      + +

      +

      + +

      +
      + +
      + 0) : ?> + + + +
      + + +
      + + +
      + + +
      + + +
      + +
      + + +
      + + + +
      + + +
      + +
      + + +
      + + +
      + + + + +
      + + +
      + + + +
      + +
      + + + + + + + + +
      + +
      +
      + + +
      + +
      + + +
      +
      > + + +
      +
      + + +
      + + +
      + + + +
      + + + 'post', + 'post_status' => 'draft', + 'author' => $user_id, + 'numberposts' => 10, + 'orderby' => 'modified', + 'order' => 'DESC', + ]); + if (!empty($user_drafts)) : + ?> + + +
      +
      + + admin_url('admin-ajax.php'), + 'nonce' => wp_create_nonce('aksrt_submit_nonce'), + 'i18n' => [ + 'confirmLeave' => __('你有未保存的更改,确定要离开吗?', 'aksrt'), + 'saving' => __('保存中...', 'aksrt'), + 'saved' => __('草稿已保存', 'aksrt'), + 'submitting' => __('提交中...', 'aksrt'), + 'submitted' => __('投稿已提交', 'aksrt'), + 'error' => __('操作失败,请重试', 'aksrt'), + 'confirmDelete' => __('确定要删除这个草稿吗?', 'aksrt'), + ], + ]); +} + +get_footer(); diff --git a/templates/pages/page-user-home.php b/templates/pages/page-user-home.php new file mode 100644 index 00000000..8d62fdd6 --- /dev/null +++ b/templates/pages/page-user-home.php @@ -0,0 +1,556 @@ +ID; + +// 数据聚合:复用共享 helper +$dashboard = function_exists('aksrt_get_user_dashboard_view') + ? aksrt_get_user_dashboard_view($user_id, ['viewer_id' => get_current_user_id()]) + : []; + +$is_own_profile = !empty($dashboard['is_own_profile']); +$user_home_settings = class_exists('AKSRT_Settings') + ? AKSRT_Settings::get_user_home_settings() + : [ + 'tabs' => ['posts', 'comments', 'favorites'], + 'sidebar' => true, + 'cover' => true, + 'follow' => true, + 'posts_per_page' => 10, + ]; + +$post_count = (int) ($dashboard['post_count'] ?? 0); +$comment_count = (int) ($dashboard['comment_count'] ?? 0); +$favorite_post_ids = $dashboard['favorite_post_ids'] ?? []; +$favorite_count = (int) ($dashboard['favorite_count'] ?? 0); +$user_contacts = $dashboard['user_contacts'] ?? []; +$user_center_settings = class_exists('AKSRT_Settings') + ? AKSRT_Settings::get_user_center_settings() + : ['social_links' => true]; +$per_page = (int) $user_home_settings['posts_per_page']; +$credits_enabled = !empty($dashboard['credits_enabled']); +$credit_balance = $dashboard['credit_balance'] ?? 0.0; +$currency_name = $dashboard['currency_name'] ?? __('积分', 'aksrt'); +$user_home_manager = class_exists('AKSRT_User_Home') ? AKSRT_User_Home::get_instance() : null; +$visible_tabs = $user_home_manager ? $user_home_manager->get_visible_tabs($is_own_profile) : []; +$profile_signals = $dashboard['profile_signals'] ?? []; +$profile_notice = $dashboard['profile_notice'] ?? null; +$show_report_button = !empty($dashboard['show_report']); + +$enabled_tabs = array_keys($visible_tabs); +if (empty($enabled_tabs)) { + $enabled_tabs = ['posts', 'comments', 'favorites']; +} + +// Apply admin-configured display order +$_uh_order = $user_home_settings['tabs_order'] ?? []; +$_uh_ordered = []; +foreach ($_uh_order as $_t) { + if (in_array($_t, $enabled_tabs, true)) { + $_uh_ordered[] = $_t; + } +} +foreach ($enabled_tabs as $_t) { + if (!in_array($_t, $_uh_ordered, true)) { + $_uh_ordered[] = $_t; + } +} +$enabled_tabs = $_uh_ordered; + +// Device visibility: build PC/mobile tab arrays +$_uh_pc_hide = $user_home_settings['tabs_pc_hide'] ?? []; +$_uh_mobile_hide = $user_home_settings['tabs_mobile_hide'] ?? []; +$enabled_tabs_pc = array_values(array_diff($enabled_tabs, $_uh_pc_hide)); +$enabled_tabs_mobile = array_values(array_diff($enabled_tabs, $_uh_mobile_hide)); +$_uh_is_mobile = function_exists('wp_is_mobile') && wp_is_mobile(); +$display_tabs = $_uh_is_mobile ? $enabled_tabs_mobile : $enabled_tabs_pc; +$_uh_default_tab = !empty($display_tabs) ? $display_tabs[0] : $enabled_tabs[0]; + +$forum_instance = class_exists('AKSRT_Forum') ? AKSRT_Forum::get_instance() : null; +$forum_stats = $forum_instance ? $forum_instance->get_user_forum_stats($user_id) : []; +$forum_topic_count = (int) ($forum_stats['topic_count'] ?? 0); +$forum_followed_count = (int) ($forum_stats['followed_plate_count'] ?? 0); + +$tab_counts = [ + 'posts' => $post_count, + 'comments' => $comment_count, + 'favorites' => $favorite_count, + 'credits' => $credits_enabled && $is_own_profile ? (int) round($credit_balance) : 0, + 'product' => 0, + 'plate' => 0, + 'forum' => $forum_topic_count, + 'follow' => $forum_followed_count, +]; +$tab_labels = [ + 'posts' => __('文章', 'aksrt'), + 'comments' => __('评论', 'aksrt'), + 'favorites' => __('收藏', 'aksrt'), + 'credits' => sprintf(__('我的%s', 'aksrt'), $currency_name), + 'product' => __('商品', 'aksrt'), + 'plate' => __('圈子', 'aksrt'), + 'forum' => __('论坛', 'aksrt'), + 'follow' => __('关注', 'aksrt'), +]; +foreach ($visible_tabs as $tab_key => $tab_config) { + if (!empty($tab_config['label'])) { + $tab_labels[$tab_key] = (string) $tab_config['label']; + } +} + +$active_tab = isset($_GET['tab']) ? sanitize_key(wp_unslash($_GET['tab'])) : $_uh_default_tab; +if (!in_array($active_tab, $enabled_tabs, true)) { + $active_tab = $_uh_default_tab; +} +$posts_page = max(1, isset($_GET['posts_page']) ? (int) $_GET['posts_page'] : 1); +$comments_page = max(1, isset($_GET['comments_page']) ? (int) $_GET['comments_page'] : 1); +$favorites_page = max(1, isset($_GET['favorites_page']) ? (int) $_GET['favorites_page'] : 1); + +require_once AKSRT_DIR . '/templates/parts/user-shared-helpers.php'; + +$svg_empty_post = ''; +$svg_empty_comment = ''; +$svg_empty_favorite = ''; +$home_url = function_exists('aksrt_get_user_home_url') ? aksrt_get_user_home_url($user) : home_url('/'); +?> + +
      +
      +
      + + +
      +
      + + + +
      + + +
      > + $user_id, + 'posts_per_page' => $per_page, + 'post_status' => 'publish', + 'paged' => $posts_page, + 'no_found_rows' => false, + 'update_post_meta_cache' => false, + ]); + if ($author_posts->have_posts()) : + ?> +
      + have_posts()) : $author_posts->the_post(); ?> + 'view', + 'url' => get_permalink(), + 'label' => __('阅读', 'aksrt'), + ]); + set_query_var('aksrt_profile_item_status', ''); + get_template_part('templates/parts/content', 'profile-list-item'); + ?> + +
      + max_num_pages, $posts_page, 'posts_page'); + wp_reset_postdata(); + set_query_var('aksrt_profile_item_action', []); + else : + aksrt_render_profile_empty($svg_empty_post, __('TA 还没有发布文章', 'aksrt')); + endif; + ?> +
      + + + +
      > + $user_id, + 'status' => 'approve', + 'number' => $per_page, + 'offset' => ($comments_page - 1) * $per_page, + 'type' => 'comment', + 'update_comment_meta_cache' => false, + ]); + if ($user_comments) : + ?> +
      + + + + +
      + +
      + + + +
      > + + 'post', + 'post_status' => 'publish', + 'post__in' => $favorite_post_ids, + 'orderby' => 'post__in', + 'posts_per_page' => $per_page, + 'paged' => $favorites_page, + 'ignore_sticky_posts' => true, + 'no_found_rows' => false, + 'update_post_meta_cache' => false, + ]); + if ($favorites_query->have_posts()) : + ?> +
      + have_posts()) : $favorites_query->the_post(); ?> + 'view', + 'url' => get_permalink(), + 'label' => __('阅读', 'aksrt'), + ]); + set_query_var('aksrt_profile_item_status', ''); + get_template_part('templates/parts/content', 'profile-list-item'); + ?> + +
      + max_num_pages, $favorites_page, 'favorites_page'); + wp_reset_postdata(); + set_query_var('aksrt_profile_item_action', []); + else : + aksrt_render_profile_empty($svg_empty_favorite, __('TA 还没有收藏文章', 'aksrt')); + endif; + ?> + + + +
      + + + +
      > +
      +

      +
      +

      + +
      +
      + + + __('暂无商品内容', 'aksrt'), + 'plate' => __('暂无圈子内容', 'aksrt'), + ]; + foreach ($_uh_extended_tabs as $_uh_ext) : + if (!in_array($_uh_ext, $enabled_tabs, true)) continue; + ?> +
      > + +
      + + + +
      > + + + + get_user_forum_topics($user_id, ['page' => $forum_page, 'limit' => $per_page]); + if (!empty($forum_topics['items'])) : ?> +
      + +
      +
      + + + +
      + + + +
      +
      +
      + +
      + 1) : ?> +
      + add_query_arg('forum_page', '%#%', $home_url), + 'format' => '', + 'current' => $forum_page, + 'total' => $forum_topics['max_pages'], + 'add_args' => ['tab' => 'forum', 'forum_sub' => 'topics'], + ]); + ?> +
      + + + + + + get_user_favorite_topics($user_id, $forum_page, $per_page); + if (!empty($fav_topics['items'])) : ?> +
      + +
      +
      + + + +
      + + + +
      +
      +
      + +
      + 1) : ?> +
      + add_query_arg('forum_page', '%#%', $home_url), + 'format' => '', + 'current' => $forum_page, + 'total' => $fav_topics['max_pages'], + 'add_args' => ['tab' => 'forum', 'forum_sub' => 'favorites'], + ]); + ?> +
      + + + + + + get_user_managed_plates($user_id); + $followed_plates = $forum_instance->get_user_followed_plates($user_id); + $has_plates = !empty($managed_plates['owned']) || !empty($managed_plates['moderating']) || !empty($followed_plates); + if ($has_plates) : ?> + +

      + + + +

      +
      + + +
      +
      + +
      +
      + +
      + + +

      + + + + + + +
      + + + +
      > + get_user_followed_plates($user_id); + if (!empty($followed)) : ?> + + + + +
      + +
      +
      + + + +
      + + diff --git a/templates/pages/page-verify-email.php b/templates/pages/page-verify-email.php new file mode 100644 index 00000000..71a78e86 --- /dev/null +++ b/templates/pages/page-verify-email.php @@ -0,0 +1,149 @@ + + +
      +
      +
      + +

      + + +

      + +

      + +

      + +

      + + + + + + + + + + + + + + +
      + + + +
      + +
      + + +
      +
      + +

      + + + + + +
      +
      + + + + + + + +
      + 0 && $verify_vk !== '') : ?> + + +

      + +
      + + + + + + + +
      + + + +
      +
      +
      + + + +
      +
      +

      + +

      + + + + + + +
      diff --git a/templates/parts/content-gallery.php b/templates/parts/content-gallery.php new file mode 100644 index 00000000..75370127 --- /dev/null +++ b/templates/parts/content-gallery.php @@ -0,0 +1,49 @@ + + + diff --git a/templates/parts/content-post-card.php b/templates/parts/content-post-card.php new file mode 100644 index 00000000..71bd3306 --- /dev/null +++ b/templates/parts/content-post-card.php @@ -0,0 +1,174 @@ + $mode, + 'context' => $context, + ]); + + echo aksrt_render_post($config); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + return; +} + +// ============================================================ +// Fallback: self-contained rendering when shared renderer unavailable +// ============================================================ + +$args = isset($args) && is_array($args) ? $args : []; + +$lazy_cfg = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_lazyload_config() : ['enabled' => false, 'placeholder_url' => '']; +$lazy_on = !empty($lazy_cfg['enabled']); +$placeholder_url = !empty($lazy_cfg['placeholder_url']) ? (string) $lazy_cfg['placeholder_url'] : (get_template_directory_uri() . '/assets/img/placeholder.svg'); + +$forced_layout = (string) get_query_var('aksrt_post_card_layout', ''); +$layout = 'card'; +if (is_string($forced_layout) && in_array($forced_layout, ['card', 'list', 'masonry', 'grid-cms'], true)) { + $layout = $forced_layout; +} elseif (function_exists('aksrt_get_post_flow') && aksrt_get_post_flow(get_the_ID()) === 'cms') { + $layout = 'grid-cms'; +} elseif (class_exists('AKSRT_Settings')) { + $layout = AKSRT_Settings::get_archive_layout(); +} + +$post_id = get_the_ID(); +$author_id = get_the_author_meta('ID'); +$author = get_the_author(); +$avatar_url = get_avatar_url($author_id, ['size' => 40]); +$time_ago = human_time_diff(get_the_time('U'), current_time('timestamp')) . __('前', 'aksrt'); +$excerpt = wp_trim_words(get_the_excerpt(), 'list' === $layout ? 30 : 20, '...'); +$tags = get_the_tags() ?: []; +$categories = get_the_category() ?: []; +$comment_count = (int) get_comments_number(); +$views = function_exists('aksrt_get_post_views') ? aksrt_get_post_views($post_id) : 0; +$is_sticky = is_sticky($post_id); +$title_html = isset($args['title_html']) ? (string) $args['title_html'] : get_the_title(); +$resource_badge = function_exists('aksrt_resource_card_badge_text') ? aksrt_resource_card_badge_text($post_id) : ''; + +$img_url = ''; +if (has_post_thumbnail()) { + $img_url = get_the_post_thumbnail_url($post_id, 'medium_large'); +} elseif (function_exists('aksrt_get_first_content_image_url')) { + $img_url = (string) aksrt_get_first_content_image_url($post_id); +} +$has_img = !empty($img_url); + +$scene = (string) get_query_var('aksrt_post_card_scene', ''); +$is_home_scene = $scene === 'home' || is_front_page() || (is_home() && !is_paged()); +$category_limit = $is_home_scene ? 1 : 3; +$tag_limit = $is_home_scene ? 2 : 3; + +// Badges HTML +ob_start(); +?> +
      + + + + + name); ?> + + + # name); ?> + +
      + +
      + + + + + +
      + + +
      +
      + + + + + diff --git a/templates/parts/content-profile-comment-item.php b/templates/parts/content-profile-comment-item.php new file mode 100644 index 00000000..dc54da53 --- /dev/null +++ b/templates/parts/content-profile-comment-item.php @@ -0,0 +1,50 @@ +comment_post_ID); +if (!$post) { + return; +} + +$snippet = wp_trim_words(wp_strip_all_tags($comment->comment_content), 30, '…'); +$post_title = get_the_title($post); +$comment_link = get_comment_link($comment); +$post_link = get_permalink($post); +$date_iso = get_comment_date('c', $comment); +$date_label = get_comment_date('Y-m-d', $comment); +$time_diff = human_time_diff(strtotime($comment->comment_date), current_time('timestamp')); +?> + diff --git a/templates/parts/content-profile-list-item.php b/templates/parts/content-profile-list-item.php new file mode 100644 index 00000000..a0d7c258 --- /dev/null +++ b/templates/parts/content-profile-list-item.php @@ -0,0 +1,106 @@ +'edit'|'view'|'unfavorite', 'url'=>'...', 'label'=>'...'] + * - aksrt_profile_item_status: 文章状态徽章文本(如 "草稿" / "私密") + * + * @package AKSRT + */ + +defined('ABSPATH') || exit; + +$action = get_query_var('aksrt_profile_item_action', []); +if (!is_array($action)) { + $action = []; +} +$action_type = isset($action['type']) ? (string) $action['type'] : ''; +$action_url = isset($action['url']) ? (string) $action['url'] : ''; +$action_label = isset($action['label']) ? (string) $action['label'] : ''; + +$status_label = (string) get_query_var('aksrt_profile_item_status', ''); + +$post_id = get_the_ID(); +$permalink = get_permalink(); +$title = get_the_title(); +$date_iso = get_the_date('c'); +$date_label = get_the_date(); +$excerpt = wp_trim_words(get_the_excerpt(), 30, '…'); +$comment_count = (int) get_comments_number(); +$categories = get_the_category() ?: []; +$tags = get_the_tags() ?: []; +?> +
      > +
      +
      +

      + + + + +

      + +
      + + +

      + + +
      + + + + name); ?> + + + + + + #name); ?> + + + + + + + + + + + '; + break; + case 'unfavorite': + $icon_svg = ''; + break; + case 'view': + default: + $icon_svg = ''; + break; + } + if ('unfavorite' === $action_type) : + ?> + + + + + + + + +
      +
      +
      diff --git a/templates/parts/docs/doc-chapter-nav.php b/templates/parts/docs/doc-chapter-nav.php new file mode 100644 index 00000000..fb33252f --- /dev/null +++ b/templates/parts/docs/doc-chapter-nav.php @@ -0,0 +1,70 @@ +get_chapter_nav($current_id); + $prev_page = $nav['prev']; + $next_page = $nav['next']; +} else { + $parent_id = wp_get_post_parent_id($current_id); + $siblings = get_pages([ + 'parent' => $parent_id ?: 0, + 'sort_column' => 'menu_order, post_title', + 'sort_order' => 'ASC', + 'post_status' => 'publish', + ]); + + $current_index = -1; + foreach ($siblings as $i => $sibling) { + if ((int) $sibling->ID === $current_id) { + $current_index = $i; + break; + } + } + + $prev_page = $current_index > 0 ? $siblings[$current_index - 1] : null; + $next_page = ($current_index >= 0 && $current_index < count($siblings) - 1) + ? $siblings[$current_index + 1] + : null; +} + +if (!$prev_page && !$next_page) return; +?> + diff --git a/templates/parts/docs/doc-index-card.php b/templates/parts/docs/doc-index-card.php new file mode 100644 index 00000000..bbfb0139 --- /dev/null +++ b/templates/parts/docs/doc-index-card.php @@ -0,0 +1,55 @@ + $page_id, + 'sort_column' => 'menu_order', + 'sort_order' => 'ASC', + 'post_status' => 'publish', +]); +$child_count = count($children); +$thumb = get_the_post_thumbnail_url($page_id, 'medium'); +$excerpt = wp_trim_words(get_the_excerpt(), 30, '...'); +?> + + +
      + > +
      + +
      + +
      + + +
      +

      + +

      + +
      + 0) : ?> + + + + + + + + + +
      +
      + + +
      diff --git a/templates/parts/docs/doc-sidebar.php b/templates/parts/docs/doc-sidebar.php new file mode 100644 index 00000000..631f1b43 --- /dev/null +++ b/templates/parts/docs/doc-sidebar.php @@ -0,0 +1,83 @@ +get_flat_tree($root_id, 3); +} else { + $doc_pages = get_pages([ + 'child_of' => $root_id, + 'sort_column' => 'menu_order, post_title', + 'sort_order' => 'ASC', + 'parent' => $root_id, + ]); +} + +$root_page = get_post($root_id); + +// Quick search for docs +$doc_settings = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_docs_mode_settings() : []; +$show_search = !empty($doc_settings['toc_enabled']); +?> + diff --git a/templates/parts/docs/doc-toc.php b/templates/parts/docs/doc-toc.php new file mode 100644 index 00000000..cf0f0d68 --- /dev/null +++ b/templates/parts/docs/doc-toc.php @@ -0,0 +1,41 @@ +]*(?:id="([^"]*)")?[^>]*>(.*?)<\/h\1>/is', $content, $matches, PREG_SET_ORDER)) { + foreach ($matches as $m) { + $level = (int) $m[1]; + $id = !empty($m[2]) ? $m[2] : ''; + $text = wp_strip_all_tags($m[3]); + if ($text === '') continue; + if ($id === '') { + $id = 'toc-' . sanitize_title($text); + } + $headings[] = ['level' => $level, 'id' => $id, 'text' => $text]; + } +} + +if (empty($headings)) return; +?> + diff --git a/templates/parts/docs/doc-tree.php b/templates/parts/docs/doc-tree.php new file mode 100644 index 00000000..aeb43f96 --- /dev/null +++ b/templates/parts/docs/doc-tree.php @@ -0,0 +1,46 @@ + $parent_id, + 'sort_column' => 'menu_order, post_title', + 'sort_order' => 'ASC', + ]); + if (empty($children)) return; + + $indent = str_repeat(' ', $depth); + echo $indent . '
        '; + foreach ($children as $child) { + $is_current = ($child->ID === $current_id); + $has_children = (int) get_pages(['child_of' => $child->ID, 'parent' => $child->ID, 'hide_empty' => true, 'fields' => 'ids']) > 0; + echo $indent . '
      • '; + echo '' . esc_html($child->post_title) . ''; + if ($has_children && $depth < 3) { + aksrt_render_doc_tree($child->ID, $current_id, $depth + 1); + } + echo '
      • '; + } + echo $indent . '
      '; + } +} + +$current_id = get_the_ID(); +$root_id = wp_get_post_parent_id($current_id) ?: $current_id; +?> + + diff --git a/templates/parts/download/download-hero.php b/templates/parts/download/download-hero.php new file mode 100644 index 00000000..992d9f21 --- /dev/null +++ b/templates/parts/download/download-hero.php @@ -0,0 +1,68 @@ + 'post', + 'post_status' => 'publish', + 'posts_per_page' => 1, + 'fields' => 'ids', + 'meta_query' => [ + [ + 'key' => AKSRT_Domain_Model::RESOURCE_MARKER_KEY, + 'value' => '1', + ], + ], +]); +$total_published = (int) $resource_count_query->found_posts; +wp_reset_postdata(); +?> +
      +
      +
      +

      + +

      + + +
      +
      + + +
      + +
      + + +
      + +
      + + +
      + + + + $type_label) : ?> + + + + +
      + +
      +
      diff --git a/templates/parts/download/download-permission-card.php b/templates/parts/download/download-permission-card.php new file mode 100644 index 00000000..64601664 --- /dev/null +++ b/templates/parts/download/download-permission-card.php @@ -0,0 +1,116 @@ +resolve($post_id); + $status = $state['state']; + $can_download = $state['can_download']; + $status_text = $state['message']; + $action_label = $state['action_label']; + $action_url = $state['action_url']; + $download_cost = $state['cost']; + $user_credits = $state['balance']; + $login_url = $state['login_url']; +} else { + // Fallback: legacy logic + $download_settings = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_cms_features() : []; + $requires_login = !empty($download_settings['download_requires_login']); + $requires_credits = !empty($download_settings['download_requires_credits']); + $download_cost = (int) ($download_settings['download_cost'] ?? 0); + + $has_downloaded = false; + if ($is_logged_in) { + $downloaded = get_user_meta(get_current_user_id(), '_aksrt_downloaded_resources', true); + $downloaded = is_array($downloaded) ? $downloaded : []; + $has_downloaded = in_array($post_id, $downloaded, true); + } + + $user_credits = 0; + if ($is_logged_in && class_exists('AKSRT_Credits')) { + $user_credits = AKSRT_Credits::balance(get_current_user_id()); + } + + $status = 'available'; + $status_text = ''; + $can_download = true; + $login_url = wp_login_url(get_permalink($post_id)); + $action_label = __('立即下载', 'aksrt'); + $action_url = ''; + + if (!$is_logged_in && $requires_login) { + $status = 'not_logged_in'; + $status_text = __('请先登录后下载', 'aksrt'); + $can_download = false; + $action_label = __('登录下载', 'aksrt'); + $action_url = $login_url; + } elseif ($requires_credits && $download_cost > 0 && $user_credits < $download_cost && !$has_downloaded) { + $status = 'insufficient_credits'; + $status_text = sprintf(__('需要 %d 积分,当前余额 %d', 'aksrt'), $download_cost, $user_credits); + $can_download = false; + $action_label = __('获取积分', 'aksrt'); + $action_url = home_url('/user/credits'); + } elseif ($has_downloaded) { + $status = 'owned'; + $status_text = __('已下载,可再次下载', 'aksrt'); + } +} + +// Map state to icon +$state_icons = [ + 'available' => ['icon' => 'fa-check-circle', 'color' => 'success'], + 'owned' => ['icon' => 'fa-download', 'color' => 'success'], + 'not_logged_in' => ['icon' => 'fa-lock', 'color' => 'warning'], + 'insufficient_credits' => ['icon' => 'fa-coins', 'color' => 'warning'], + 'no_permission' => ['icon' => 'fa-ban', 'color' => 'warning'], + 'pending_delivery' => ['icon' => 'fa-clock', 'color' => 'info'], +]; +$state_info = $state_icons[$status] ?? $state_icons['available']; +?> +
      +
      + + +
      + + 0) : ?> +
      + + + + () + +
      + + +
      + + + + + + + + + + + + + + + + +
      +
      diff --git a/templates/parts/home-layout.php b/templates/parts/home-layout.php new file mode 100644 index 00000000..d97bfa5a --- /dev/null +++ b/templates/parts/home-layout.php @@ -0,0 +1,10 @@ + 'slide', // Banner always uses slide effect + 'loop' => $slide_count > 1 && ($slider_cfg['loop'] ?? true), + 'speed' => 500, + 'autoplay' => $slide_count > 1 ? ['delay' => ($slider_cfg['autoplay_interval'] ?? 5000), 'disableOnInteraction' => false] : false, + 'pagination' => false, + 'navigation' => !empty($slider_cfg['navigation']), + 'keyboard' => ['enabled' => true], + 'spaceBetween' => ($slider_cfg['space_between'] ?? 0), +]; +?> +
      + + +
      + +
      + +
      diff --git a/templates/parts/home-modules/module-button_group.php b/templates/parts/home-modules/module-button_group.php new file mode 100644 index 00000000..eb4f461c --- /dev/null +++ b/templates/parts/home-modules/module-button_group.php @@ -0,0 +1,41 @@ + + +
      + +

      + +
      + + + + + + + + +
      +
      diff --git a/templates/parts/home-modules/module-category.php b/templates/parts/home-modules/module-category.php new file mode 100644 index 00000000..49d055a9 --- /dev/null +++ b/templates/parts/home-modules/module-category.php @@ -0,0 +1,72 @@ + $count, + 'post_status' => 'publish', + 'no_found_rows' => true, +]; +if ($cat_id > 0) { + $args['cat'] = $cat_id; + // 分类页通常希望同时展示资源 post,post 已天然包含资源;旧 CPT 兼容时合并 + $args['post_type'] = function_exists('aksrt_resource_post_types') + ? array_unique(array_merge(['post'], aksrt_resource_post_types())) + : ['post']; +} else { + $args['post_type'] = 'post'; +} + +$query = new WP_Query($args); +if (!$query->have_posts()) { + wp_reset_postdata(); + return; +} + +$display_title = $title; +if ($display_title === '' && $cat_id > 0) { + $term = get_term($cat_id, 'category'); + if ($term && !is_wp_error($term)) $display_title = $term->name; +} +$more_link = $cat_id > 0 ? get_category_link($cat_id) : ''; +?> + +
      +
      +

      + + + + + +
      + +
      + have_posts()) { + $query->the_post(); + get_template_part('templates/parts/content', 'post-card'); + } + set_query_var('aksrt_post_card_layout', ''); + wp_reset_postdata(); + ?> +
      +
      diff --git a/templates/parts/home-modules/module-category_recommend_cards.php b/templates/parts/home-modules/module-category_recommend_cards.php new file mode 100644 index 00000000..276f66b0 --- /dev/null +++ b/templates/parts/home-modules/module-category_recommend_cards.php @@ -0,0 +1,123 @@ + $id > 0), 0, 4); +$show_views = !isset($cfg['show_views']) || !empty($cfg['show_views']); +$show_dates = !isset($cfg['show_dates']) || !empty($cfg['show_dates']); + +if (empty($cat_ids)) return; + +// 懒加载配置 +$lazy_cfg = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_lazyload_config() : ['enabled' => false, 'placeholder_url' => '']; +$lazy_on = !empty($lazy_cfg['enabled']); +$placeholder = $lazy_cfg['placeholder_url'] ?: (get_template_directory_uri() . '/assets/img/placeholder.svg'); + +// 组装卡片数据 +$cards = []; +foreach ($cat_ids as $idx => $cat_id) { + $term = get_term($cat_id, 'category'); + if (!$term || is_wp_error($term)) continue; + + // 封面图 + $cover_url = class_exists('AKSRT_Settings') + ? AKSRT_Settings::get_category_cover_url($cat_id) + : $placeholder; + + // 分类描述(优先 term meta 推荐描述,其次原生 description) + $desc = class_exists('AKSRT_Settings') + ? AKSRT_Settings::get_category_recommend_desc($cat_id) + : (string) $term->description; + + // 5 篇最新文章 + $posts = get_posts([ + 'category__in' => [$cat_id], + 'posts_per_page' => 5, + 'post_status' => 'publish', + 'orderby' => 'date', + 'order' => 'DESC', + 'no_found_rows' => true, + ]); + + // 浏览量 + $views = $show_views ? aksrt_get_category_total_views($cat_id) : 0; + + $cards[] = [ + 'term' => $term, + 'cover' => $cover_url, + 'desc' => $desc, + 'posts' => $posts, + 'views' => $views, + 'count' => (int) $term->count, + 'link' => get_category_link($cat_id), + ]; +} + +if (empty($cards)) return; +?> + +
      +
      +

      +
      + +
      + +
      +
      +
      + 0) : ?> + + + + + <?php echo esc_attr($card['term']->name); ?> + + <?php echo esc_attr($card['term']->name); ?> + +
      +
      +
      +
      name); ?>
      + +
      + +
      + +
      +
      + + +
        + +
      • + ID)); ?> + + post_date)); ?> + +
      • + +
      + +
      + +
      +
      diff --git a/templates/parts/home-modules/module-data_grid.php b/templates/parts/home-modules/module-data_grid.php new file mode 100644 index 00000000..4452807d --- /dev/null +++ b/templates/parts/home-modules/module-data_grid.php @@ -0,0 +1,43 @@ + + +
      + +
      +

      +
      + +
      + +
      + + + + + +
      + +
      +
      diff --git a/templates/parts/home-modules/module-faq.php b/templates/parts/home-modules/module-faq.php new file mode 100644 index 00000000..24bf704c --- /dev/null +++ b/templates/parts/home-modules/module-faq.php @@ -0,0 +1,39 @@ + + +
      + +
      +

      +
      + +
      + $item) : + $q = (string) ($item['q'] ?? ''); + $a = (string) ($item['a'] ?? ''); + if ($q === '') continue; + ?> +
      + +
      +
      + +
      +
      diff --git a/templates/parts/home-modules/module-friend_links.php b/templates/parts/home-modules/module-friend_links.php new file mode 100644 index 00000000..8482cc80 --- /dev/null +++ b/templates/parts/home-modules/module-friend_links.php @@ -0,0 +1,47 @@ + + +
      +
      +

      +
      + + +
      diff --git a/templates/parts/home-modules/module-hero.php b/templates/parts/home-modules/module-hero.php new file mode 100644 index 00000000..cd783fb1 --- /dev/null +++ b/templates/parts/home-modules/module-hero.php @@ -0,0 +1,332 @@ + false, 'keywords' => [], 'categories' => [], 'type_options' => []]; +$show_search = !empty($search_cfg['show']) || !empty($cfg['with_search']); +$show_cards = !empty($settings['header_components_cards_enabled']); +$icon_cards = isset($settings['header_components_icon_cards']) ? (array) $settings['header_components_icon_cards'] : []; +$card_bg_mode = !empty($settings['header_components_card_shared_bg']) ? 'shared' : 'individual'; +$card_blur = !empty($settings['header_components_card_blur']); +$filter_blur_devices = isset($settings['header_components_filter_blur_devices']) ? (array) $settings['header_components_filter_blur_devices'] : ($card_blur ? ['pc', 'mobile'] : []); +$card_show_devices = isset($settings['header_components_card_show_devices']) ? (array) $settings['header_components_card_show_devices'] : ['pc', 'mobile']; +$card_icon_shape = $settings['header_components_card_icon_shape'] ?? 'circle'; + +$slide_count = count($hero_banners); +$visible_card_count = ($show_cards && !empty($icon_cards)) ? count($icon_cards) : 0; + +$container_classes = ['header-components-container', 'relative', 'card-' . $visible_card_count]; +if (in_array('pc', $filter_blur_devices, true)) { + $container_classes[] = 'has-blur-pc'; +} +if (in_array('mobile', $filter_blur_devices, true)) { + $container_classes[] = 'has-blur-mobile'; +} +if (!in_array('pc', $card_show_devices, true)) { + $container_classes[] = 'hide-cards-pc'; +} +if (!in_array('mobile', $card_show_devices, true)) { + $container_classes[] = 'hide-cards-mobile'; +} + +$search_placeholder = function_exists('aksrt_get_search_placeholder') ? aksrt_get_search_placeholder() : __('开启精彩搜索', 'aksrt'); +$search_value = trim((string) get_search_query()); +$search_type = function_exists('aksrt_get_search_form_type') ? aksrt_get_search_form_type() : 'post'; + +$render_hero_search = static function (string $search_id) use ($search_placeholder, $search_value, $search_type, $search_cfg): void { + $_kw = $search_cfg['keywords'] ?? []; + $_cats = $search_cfg['categories'] ?? []; + $_types = $search_cfg['type_options'] ?? []; + $_show_cat = !empty($search_cfg['show_input_cat']) && !empty($_cats); + $_show_type = !empty($search_cfg['show_type']) && !empty($_types); + ?> + + +
      +
      + +
      +
      + + +
      + +
      + +
      +
      + +
      + + +
      +
      + +
      +
      + +
      + +
      +
      + $slider_cfg['effect'] ?? 'fade', + 'loop' => $slide_count > 1 && ($slider_cfg['loop'] ?? true), + 'speed' => 600, + 'autoplay' => $slide_count > 1 ? ['delay' => ($slider_cfg['autoplay_interval'] ?? 5000), 'disableOnInteraction' => false] : false, + 'keyboard' => ['enabled' => true], + ]; + if (!empty($slider_cfg['pagination'])) { + $swiper_config['pagination'] = ['clickable' => true]; + } + if (!empty($slider_cfg['navigation'])) { + $swiper_config['navigation'] = true; + } + $pc_height = $slider_cfg['pc_height'] ?? ''; + $mobile_height = $slider_cfg['mobile_height'] ?? ''; + $height_style = ''; + if ($pc_height !== '') { + $height_style .= '--pc-height:' . esc_attr($pc_height) . ';'; + } + if ($mobile_height !== '') { + $height_style .= '--m-height:' . esc_attr($mobile_height) . ';'; + } + ?> +
      style=""> +
      +
      + $slide) : + $device_vis = $slide['device_visibility'] ?? 'all'; + $slide_classes = 'swiper-slide'; + if ($device_vis === 'pc') { + $slide_classes .= ' hero-slide--pc-only'; + } elseif ($device_vis === 'mobile') { + $slide_classes .= ' hero-slide--mobile-only'; + } + if (!empty($slide['video_url'])) { + $slide_classes .= ' hero-slide--has-video'; + } + $link_target = !empty($slide['link_target']) ? $slide['link_target'] : '_self'; + $has_layers = !empty($slide['layers']) && is_array($slide['layers']); + $text_parallax = !empty($slide['text_parallax']); + $text_style = ''; + if (!empty($slide['text_size_pc'])) { + $text_style .= '--hero-text-size-pc:' . esc_attr($slide['text_size_pc']) . ';'; + } + if (!empty($slide['text_size_m'])) { + $text_style .= '--hero-text-size-m:' . esc_attr($slide['text_size_m']) . ';'; + } + if ($text_parallax) { + $text_style .= '--hero-text-parallax:1;'; + } + ?> +
      data-link="" data-target=""> +
      + + + + + <?php echo esc_attr($slide['title'] ?: get_bloginfo('name')); ?> + +
      + + + +
      + + +
      +
      + + +
      > + +

      + + +
      + + +
      + +
      + +
      + +
      + + + + + +
      + + + +
      + +
      + +
      + + + +
      +
      + + + + + +
      + + + +
      + +
      + +
      + diff --git a/templates/parts/home-modules/module-highlight_cards.php b/templates/parts/home-modules/module-highlight_cards.php new file mode 100644 index 00000000..98c99435 --- /dev/null +++ b/templates/parts/home-modules/module-highlight_cards.php @@ -0,0 +1,52 @@ + + +
      + +
      +

      +
      + +
      + + < class="aksrt-highlight-card"> + + > + +
      + +

      + +

      + + > + +
      +
      diff --git a/templates/parts/home-modules/module-hotspot.php b/templates/parts/home-modules/module-hotspot.php new file mode 100644 index 00000000..4cd8a37c --- /dev/null +++ b/templates/parts/home-modules/module-hotspot.php @@ -0,0 +1,111 @@ + $ptypes, + 'posts_per_page' => $count, + 'post_status' => 'publish', + 'no_found_rows' => true, +]; + +if ($source === 'auto') { + // 优先推荐文章,不足时用最新补充 + $args['meta_query'] = [ + 'relation' => 'OR', + ['key' => 'aksrt_recommend', 'value' => '1', 'compare' => '='], + ['key' => 'aksrt_recommend', 'compare' => 'NOT EXISTS'], + ]; + $args['orderby'] = 'modified'; + $args['order'] = 'DESC'; +} elseif ($source === 'views') { + $args['meta_key'] = '_aksrt_res_view_count'; + $args['orderby'] = 'meta_value_num'; + $args['order'] = 'DESC'; +} else { + $args['orderby'] = 'date'; + $args['order'] = 'DESC'; +} + +$query = new WP_Query($args); +if (!$query->have_posts()) { + wp_reset_postdata(); + return; +} + +$default_title = __('热点推荐', 'aksrt'); +$display_title = $title ?: $default_title; +?> + +
      +
      +

      + + +

      +
      + +
        + have_posts()) : + $query->the_post(); + $cats = get_the_category(); + $cat_name = $cats ? $cats[0]->name : ''; + $views = (int) get_post_meta(get_the_ID(), '_aksrt_res_view_count', true); + ?> +
      1. + +
        + + + +
        + + + + + 0) : ?> + + +
        +
        +
      2. + +
      +
      diff --git a/templates/parts/home-modules/module-html.php b/templates/parts/home-modules/module-html.php new file mode 100644 index 00000000..1985a158 --- /dev/null +++ b/templates/parts/home-modules/module-html.php @@ -0,0 +1,29 @@ + + +
      + +
      +

      +
      + +
      + +
      +
      diff --git a/templates/parts/home-modules/module-more.php b/templates/parts/home-modules/module-more.php new file mode 100644 index 00000000..538355a3 --- /dev/null +++ b/templates/parts/home-modules/module-more.php @@ -0,0 +1,103 @@ + array_slice($cat_ids, 0, $count), + 'orderby' => 'include', + ]); +} else { + $categories = get_categories([ + 'number' => $count, + 'orderby' => 'count', + 'order' => 'DESC', + 'hide_empty' => true, + ]); +} + +if (empty($categories)) { + return; +} +?> + +
      +
      +

      +
      + +
      + term_id); + } + ?> +
      + + +
      + > +
      + +
      + +
      + +
      + name); ?> + + + count); + ?> + + +
      +
      + $cat->term_id, + 'posts_per_page' => $latest_count, + 'post_status' => 'publish', + 'orderby' => 'date', + 'order' => 'DESC', + ]); + if (!empty($recent)) : + ?> + + +
      + +
      +
      diff --git a/templates/parts/home-modules/module-partner_marquee.php b/templates/parts/home-modules/module-partner_marquee.php new file mode 100644 index 00000000..289807f0 --- /dev/null +++ b/templates/parts/home-modules/module-partner_marquee.php @@ -0,0 +1,54 @@ + + +
      + +
      +

      +
      + +
      +
      + + < class="aksrt-marquee__item"> + + <?php echo esc_attr($name); ?>> + + + + > + +
      +
      +
      diff --git a/templates/parts/home-modules/module-product_showcase.php b/templates/parts/home-modules/module-product_showcase.php new file mode 100644 index 00000000..473302aa --- /dev/null +++ b/templates/parts/home-modules/module-product_showcase.php @@ -0,0 +1,54 @@ + + +
      + +
      +

      +
      + + +
      diff --git a/templates/parts/home-modules/module-ranking.php b/templates/parts/home-modules/module-ranking.php new file mode 100644 index 00000000..316ecd67 --- /dev/null +++ b/templates/parts/home-modules/module-ranking.php @@ -0,0 +1,109 @@ + $count, + 'post_status' => 'publish', + 'no_found_rows' => true, +]; +if ($is_resource_module) { + $args['post_type'] = function_exists('aksrt_resource_post_types') ? aksrt_resource_post_types() : ['post']; + if (!post_type_exists('aksrt_resource')) { + $args['meta_query'] = [[ + 'key' => '_aksrt_is_resource', + 'value' => '1', + 'compare' => '=', + ]]; + } +} else { + $args['post_type'] = 'post'; +} + +switch ($metric) { + case 'views': + $args['meta_key'] = '_aksrt_res_view_count'; + $args['orderby'] = 'meta_value_num'; + $args['order'] = 'DESC'; + break; + case 'comments': + $args['orderby'] = 'comment_count'; + $args['order'] = 'DESC'; + break; + case 'downloads': + default: + $args['meta_key'] = '_aksrt_res_download_count'; + $args['orderby'] = 'meta_value_num'; + $args['order'] = 'DESC'; + break; +} + +$query = new WP_Query($args); +if (!$query->have_posts()) { + wp_reset_postdata(); + return; +} + +$metric_label = [ + 'views' => __('阅读量', 'aksrt'), + 'downloads' => __('下载量', 'aksrt'), + 'comments' => __('评论数', 'aksrt'), +][$metric]; + +$display_title = $title ?: sprintf(__('%s 排行榜', 'aksrt'), $metric_label); +?> + +
      +
      +

      + + +

      +
      + +
        + have_posts()) : + $query->the_post(); + $value = 0; + if ($metric === 'views') { + $value = (int) get_post_meta(get_the_ID(), '_aksrt_res_view_count', true); + } elseif ($metric === 'comments') { + $value = (int) get_comments_number(); + } else { + $value = (int) get_post_meta(get_the_ID(), '_aksrt_res_download_count', true); + } + ?> +
      1. + + + + + +
      2. + +
      +
      diff --git a/templates/parts/home-modules/module-recent.php b/templates/parts/home-modules/module-recent.php new file mode 100644 index 00000000..69b7ddae --- /dev/null +++ b/templates/parts/home-modules/module-recent.php @@ -0,0 +1,84 @@ + $count, + 'post_status' => 'publish', + 'no_found_rows' => true, + 'orderby' => 'date', + 'order' => 'DESC', +]; +if ($is_resource_module) { + $args['post_type'] = function_exists('aksrt_resource_post_types') ? aksrt_resource_post_types() : ['post']; + if (!post_type_exists('aksrt_resource')) { + $args['meta_query'] = [[ + 'key' => '_aksrt_is_resource', + 'value' => '1', + 'compare' => '=', + ]]; + } +} else { + $args['post_type'] = 'post'; +} + +$query = new WP_Query($args); +if (!$query->have_posts()) { + wp_reset_postdata(); + return; +} + +$default_title = $is_resource_module ? __('最新资源', 'aksrt') : __('最新文章', 'aksrt'); +$display_title = $title ?: $default_title; +$archive_link = $is_resource_module + ? (function_exists('aksrt_resource_archive_url') ? aksrt_resource_archive_url() : home_url('/resources/')) + : get_permalink(get_option('page_for_posts')); +?> + +
      +
      +

      + + + + + +
      + +
      + have_posts()) { + $query->the_post(); + get_template_part('templates/parts/content', 'post-card'); + } + set_query_var('aksrt_post_card_layout', ''); + set_query_var('aksrt_post_card_scene', ''); + wp_reset_postdata(); + ?> +
      +
      diff --git a/templates/parts/home-modules/module-spacing.php b/templates/parts/home-modules/module-spacing.php new file mode 100644 index 00000000..78862a99 --- /dev/null +++ b/templates/parts/home-modules/module-spacing.php @@ -0,0 +1,17 @@ + + + diff --git a/templates/parts/home-modules/module-tabs.php b/templates/parts/home-modules/module-tabs.php new file mode 100644 index 00000000..bf709fc5 --- /dev/null +++ b/templates/parts/home-modules/module-tabs.php @@ -0,0 +1,108 @@ + $x > 0); +$count = max(1, min(20, (int) ($cfg['count'] ?? 8))); +$layout = (string) ($cfg['layout'] ?? 'grid-cms'); +$title = (string) ($cfg['title'] ?? ''); + +if (!in_array($layout, ['card', 'list', 'masonry', 'grid-cms'], true)) { + $layout = 'grid-cms'; +} + +if (empty($cat_ids)) { + // 没有指定 → 取站点最热的两个分类 + $top_cats = get_terms([ + 'taxonomy' => 'category', + 'orderby' => 'count', + 'order' => 'DESC', + 'number' => 4, + 'hide_empty' => true, + 'fields' => 'ids', + ]); + if (is_wp_error($top_cats)) $top_cats = []; + $cat_ids = array_map('intval', $top_cats); +} + +if (empty($cat_ids)) return; + +$tabs = []; +foreach ($cat_ids as $cid) { + $term = get_term($cid, 'category'); + if (!$term || is_wp_error($term)) continue; + $tabs[] = [ + 'id' => $cid, + 'name' => $term->name, + 'link' => get_category_link($cid), + ]; +} +if (empty($tabs)) return; + +$active_id = $tabs[0]['id']; +?> + +
      +
      +

      + +
      + + $t) : + $tab_post_types = function_exists('aksrt_resource_post_types') + ? array_unique(array_merge(['post'], aksrt_resource_post_types())) + : ['post']; + $query = new WP_Query([ + 'cat' => $t['id'], + 'post_type' => $tab_post_types, + 'posts_per_page' => $count, + 'post_status' => 'publish', + 'no_found_rows' => true, + ]); + ?> +
      > + have_posts()) : ?> +
      + have_posts()) { + $query->the_post(); + get_template_part('templates/parts/content', 'post-card'); + } + set_query_var('aksrt_post_card_layout', ''); + set_query_var('aksrt_post_card_scene', ''); + wp_reset_postdata(); + ?> +
      + +

      + +
      + +
      diff --git a/templates/parts/home-modules/module-tag_cloud.php b/templates/parts/home-modules/module-tag_cloud.php new file mode 100644 index 00000000..eff9c826 --- /dev/null +++ b/templates/parts/home-modules/module-tag_cloud.php @@ -0,0 +1,40 @@ + 'post_tag', + 'orderby' => 'count', + 'order' => 'DESC', + 'number' => $limit, + 'hide_empty' => true, +]); +if (is_wp_error($tags) || empty($tags)) return; +?> + +
      +
      +

      +
      + + +
      diff --git a/templates/parts/home-modules/module-text_title.php b/templates/parts/home-modules/module-text_title.php new file mode 100644 index 00000000..02869c39 --- /dev/null +++ b/templates/parts/home-modules/module-text_title.php @@ -0,0 +1,32 @@ + + +
      + + < class="home-module__title">> + + +

      + +
      diff --git a/templates/parts/home-modules/module-user.php b/templates/parts/home-modules/module-user.php new file mode 100644 index 00000000..76c75166 --- /dev/null +++ b/templates/parts/home-modules/module-user.php @@ -0,0 +1,85 @@ + array_slice($user_ids, 0, $count), + 'orderby' => 'include', + 'number' => $count, + ]; +} elseif ($order === 'posts') { + $query_args = [ + 'number' => $count, + 'orderby' => 'post_count', + 'order' => 'DESC', + ]; +} else { + // 'recent' = newest registrations. + $query_args = [ + 'number' => $count, + 'orderby' => 'registered', + 'order' => 'DESC', + ]; +} + +$users = get_users($query_args); +if (empty($users)) { + return; +} +?> + +
      +
      +

      +
      + +
      + ID; + $avatar = get_avatar_url($uid, ['size' => 96]); + $display = $user->display_name ?: $user->user_login; + $bio = get_user_meta($uid, 'description', true); + $post_cnt = count_user_posts($uid); + $profile = function_exists('aksrt_get_user_home_url') + ? aksrt_get_user_home_url($uid) + : get_author_posts_url($uid); + ?> +
      + + > + + + +

      + + + + + + + + + +
      + +
      +
      diff --git a/templates/parts/login-prompt.php b/templates/parts/login-prompt.php new file mode 100644 index 00000000..ed97d2c8 --- /dev/null +++ b/templates/parts/login-prompt.php @@ -0,0 +1,40 @@ + + + diff --git a/templates/parts/modal-auth.php b/templates/parts/modal-auth.php new file mode 100644 index 00000000..ef9c9560 --- /dev/null +++ b/templates/parts/modal-auth.php @@ -0,0 +1,255 @@ +get_auth_providers_data(); +} + +$registration_open = true; +$email_verification = false; +$close_signin = false; +$invite_field_mode = 'hidden'; +$invite_field_hint = ''; +$invite_prefill = ''; +$show_qrcode_tab = false; + +if (class_exists('AKSRT_Settings')) { + $registration_open = AKSRT_Settings::registration_enabled(); + $email_verification = AKSRT_Settings::registration_requires_email_verification(); + $close_signin = AKSRT_Settings::close_signin(); + + $invite_cfg = AKSRT_Settings::get_invite_registration_config(); + $invite_field_mode = (string) ($invite_cfg['register_field'] ?? 'hidden'); + $invite_field_hint = $invite_field_mode === 'required' + ? (string) ($invite_cfg['must_desc'] ?? '') + : (string) ($invite_cfg['open_desc'] ?? ''); +} + +if (!empty($_GET['invite'])) { + $invite_prefill = sanitize_key(wp_unslash($_GET['invite'])); +} elseif (!empty($_COOKIE['aksrt_invite'])) { + $invite_prefill = sanitize_key(wp_unslash($_COOKIE['aksrt_invite'])); +} + +foreach ($oauth_providers as $provider) { + if (($provider['name'] ?? '') === 'weixingzh') { + $show_qrcode_tab = true; + break; + } +} + +$site_name = get_bloginfo('name'); +$reset_url = function_exists('aksrt_get_reset_password_url') ? aksrt_get_reset_password_url() : wp_lostpassword_url(); +?> + + diff --git a/templates/parts/page-header.php b/templates/parts/page-header.php new file mode 100644 index 00000000..6867c1c5 --- /dev/null +++ b/templates/parts/page-header.php @@ -0,0 +1,93 @@ + global setting. +$ph_style = get_query_var('aksrt_ph_style', null); +if ($ph_style === null) { + $ph_style = class_exists('AKSRT_Settings') + ? (AKSRT_Settings::get_other_page_settings()['header_style'] ?? '1') + : '1'; +} + +// '' means "don't show header" +if ($ph_style === '') { + return; +} + +$ph_eyebrow = get_query_var('aksrt_ph_eyebrow', ''); +$ph_title = get_query_var('aksrt_ph_title', ''); +$ph_desc = get_query_var('aksrt_ph_desc', ''); +$ph_extra = get_query_var('aksrt_ph_extra', ''); + +// Cover image: explicit > global setting > post thumbnail. +$ph_cover = get_query_var('aksrt_ph_cover', ''); +if (empty($ph_cover) && class_exists('AKSRT_Settings')) { + $other = AKSRT_Settings::get_other_page_settings(); + $ph_cover = $other['header_cover_img'] ?? ''; +} +if (empty($ph_cover) && has_post_thumbnail()) { + $ph_cover = get_the_post_thumbnail_url(get_the_ID(), 'full'); +} + +// Map style value to CSS modifier class. +$style_class = 'term-page-header--simple'; +if ($ph_style === '2') { + $style_class = 'term-page-header--card'; +} elseif ($ph_style === '3') { + $style_class = 'term-page-header--cover'; +} + +// Cover inline style (for cover style). +$cover_style = ''; +if ($ph_style === '3' && !empty($ph_cover)) { + $cover_style = ' style="background-image:url(\'' . esc_url($ph_cover) . '\')"'; +} +?> + + diff --git a/templates/parts/profile/part-1.php b/templates/parts/profile/part-1.php new file mode 100644 index 00000000..cd174a27 --- /dev/null +++ b/templates/parts/profile/part-1.php @@ -0,0 +1,400 @@ + + +
      + + + $uid, 'display_name' => sanitize_text_field( $_POST['nickname'] ) ] ); + } + if ( isset( $_POST['description'] ) ) { + wp_update_user( [ 'ID' => $uid, 'description' => sanitize_textarea_field( $_POST['description'] ) ] ); + } + $contacts = []; + if ( isset( $_POST['contacts'] ) && is_array( $_POST['contacts'] ) ) { + foreach ( $_POST['contacts'] as $c ) { + $u = isset( $c['url'] ) ? esc_url_raw( $c['url'] ) : ''; + $i = isset( $c['icon'] ) ? sanitize_key( $c['icon'] ) : ''; + // 清理 FA5 类名前缀:fab fa-xxx / fabfa-xxx / fa-xxx → xxx + $i = preg_replace('/^(fab|fas|far)[\s-]*/', '', $i); + $i = preg_replace('/^fa[\s-]*/', '', $i); + $i = preg_replace('/^[\s-]+/', '', $i); + $i = trim( $i, '- ' ); + if ( ! $u || ! $i ) continue; + $n = aksrt_icon_key_to_name( $i ); + $contacts[] = [ 'name' => $n, 'url' => $u, 'icon' => $i ]; + } + } + update_user_meta( $uid, 'aksrt_user_contacts', $contacts ); + } + wp_safe_redirect( add_query_arg( 'saved', '1', aksrt_get_user_center_url() ) ); + exit; +} + +// ─── 确定目标用户 ───────────────────────────────────────────── +// 来源: /user/{昵称} (自定义重写规则) +// 注意: get_queried_object_id() 在页面中返回的是页面 ID 而非用户 ID +$target_user_id = 0; +$target_user_nicename = get_query_var('user_nicename'); + +if ( $target_user_nicename ) { + $user = get_user_by( 'slug', $target_user_nicename ); + if ( $user ) { + $target_user_id = $user->ID; + } +} + +// 兜底:检查 queried object +if ( ! $target_user_id ) { + $qid = get_queried_object_id(); + if ( $qid && get_userdata( $qid ) ) { + $target_user_id = $qid; + } +} + +$current_user = wp_get_current_user(); +$target_user = $target_user_id ? get_userdata( $target_user_id ) : null; +$is_own_profile = false; + +if ( ! $target_user ) { + if ( is_user_logged_in() ) { + $target_user = $current_user; + $target_user_id = $current_user->ID; + $is_own_profile = true; + } else { + wp_safe_redirect( aksrt_get_login_url( aksrt_get_user_center_url() ) ); + exit; + } +} elseif ( is_user_logged_in() && (int) $current_user->ID === (int) $target_user->ID ) { + $is_own_profile = true; +} + +if ( $target_user_nicename && ! $is_own_profile && function_exists( 'aksrt_get_user_home_url' ) ) { + wp_safe_redirect( aksrt_get_user_home_url( $target_user ) ); + exit; +} + +$user_id = $target_user->ID; +$post_count = (int) count_user_posts( $user_id, 'post', true ); +$comment_count = (int) get_comments( [ 'user_id' => $user_id, 'count' => true, 'status' => 'approve' ] ); + +// 加载用户自定义联系方式 +$user_contacts = get_user_meta( $user_id, 'aksrt_user_contacts', true ) ?: []; +if ( ! is_array( $user_contacts ) ) $user_contacts = []; +$user_center_settings = class_exists( 'AKSRT_Settings' ) + ? AKSRT_Settings::get_user_center_settings() + : [ + 'tabs' => [ 'profile', 'posts', 'comments', 'favorites' ], + 'avatar_upload' => true, + 'cover_upload' => true, + 'social_links' => true, + ]; +$user_home_settings = class_exists( 'AKSRT_Settings' ) + ? AKSRT_Settings::get_user_home_settings() + : [ + 'tabs' => [ 'posts', 'comments', 'favorites' ], + 'sidebar' => true, + 'cover' => true, + 'follow' => true, + 'posts_per_page' => 10, + ]; +$user_home_manager = class_exists( 'AKSRT_User_Home' ) ? AKSRT_User_Home::get_instance() : null; +$profile_signals = $user_home_manager ? $user_home_manager->get_profile_signals( $user_id ) : []; +$profile_notice = $user_home_manager ? $user_home_manager->get_profile_notice( $user_id ) : []; +$show_report_button = $user_home_manager ? $user_home_manager->show_report_entry() : false; +$public_visible_tabs = $user_home_manager + ? $user_home_manager->get_visible_tabs( false ) + : [ + 'posts' => [ 'label' => __( '文章', 'aksrt' ) ], + 'comments' => [ 'label' => __( '评论', 'aksrt' ) ], + 'favorites' => [ 'label' => __( '收藏', 'aksrt' ) ], + ]; + +// 用户自定义背景图 +$user_background_url = get_user_meta( $user_id, 'aksrt_background_image', true ) ?: ''; +$allow_bg_upload = $is_own_profile + && ! empty( $user_center_settings['cover_upload'] ) + && class_exists( 'AKSRT_Settings' ) + && aksrt_get_setting( 'allow_background_upload', false ); +$show_avatar_edit = $is_own_profile && ! empty( $user_center_settings['avatar_upload'] ); +$avatar_settings_url = admin_url( 'profile.php' ); +$public_home_url = function_exists( 'aksrt_get_user_home_url' ) ? aksrt_get_user_home_url( $target_user ) : home_url( '/' ); + +$favorite_post_ids = function_exists( 'aksrt_get_user_interaction_posts' ) + ? aksrt_get_user_interaction_posts( '_aksrt_favorite_posts', $user_id ) + : []; +$favorite_count = count( $favorite_post_ids ); +$notification_unread_count = $is_own_profile && function_exists( 'aksrt_get_user_unread_notification_count' ) + ? aksrt_get_user_unread_notification_count( $user_id ) + : 0; +$dashboard_tabs = class_exists( 'AKSRT_User_Center' ) + ? AKSRT_User_Center::get_instance()->get_dashboard_tabs() + : [ + 'profile' => [ 'label' => __( '个人信息', 'aksrt' ), 'icon' => 'user' ], + 'posts' => [ 'label' => __( '我的文章', 'aksrt' ), 'icon' => 'file-alt' ], + 'comments' => [ 'label' => __( '我的评论', 'aksrt' ), 'icon' => 'comment' ], + 'favorites' => [ 'label' => __( '我的收藏', 'aksrt' ), 'icon' => 'star' ], + 'settings' => [ 'label' => __( '账号设置', 'aksrt' ), 'icon' => 'cog' ], + ]; +$dashboard_tab_keys = array_keys( $dashboard_tabs ); +$public_tab_keys = array_keys( $public_visible_tabs ); +if ( empty( $public_tab_keys ) ) { + $public_visible_tabs = [ + 'posts' => [ 'label' => __( '文章', 'aksrt' ) ], + 'comments' => [ 'label' => __( '评论', 'aksrt' ) ], + 'favorites' => [ 'label' => __( '收藏', 'aksrt' ) ], + ]; + $public_tab_keys = array_keys( $public_visible_tabs ); +} +$public_tab_labels = [ + 'posts' => __( '文章', 'aksrt' ), + 'comments' => __( '评论', 'aksrt' ), + 'favorites' => __( '收藏', 'aksrt' ), +]; +foreach ( $public_visible_tabs as $public_tab_key => $public_tab_config ) { + if ( ! empty( $public_tab_config['label'] ) ) { + $public_tab_labels[ $public_tab_key ] = (string) $public_tab_config['label']; + } +} +$public_tab_counts = [ + 'posts' => $post_count, + 'comments' => $comment_count, + 'favorites' => $favorite_count, +]; + +// 消息中心页面 URL +$messages_page = get_pages( [ 'meta_key' => '_wp_page_template', 'meta_value' => 'templates/pages/page-messages.php', 'number' => 1 ] ); +$messages_url = ! empty( $messages_page ) ? get_permalink( $messages_page[0] ) : aksrt_get_user_center_url( 'notif' ); + +// ─── 文章状态计数(仅自己模式;用于状态筛选 chip) ──────────── +$user_status_counts = [ + 'publish' => 0, + 'draft' => 0, + 'pending' => 0, + 'private' => 0, +]; +if ( $is_own_profile ) { + global $wpdb; + $rows = $wpdb->get_results( $wpdb->prepare( + "SELECT post_status, COUNT(*) AS num FROM {$wpdb->posts} + WHERE post_author = %d AND post_type = 'post' + AND post_status IN ('publish','draft','pending','private') + GROUP BY post_status", + $user_id + ) ); + foreach ( (array) $rows as $row ) { + $user_status_counts[ $row->post_status ] = (int) $row->num; + } + $post_count = (int) array_sum( $user_status_counts ); +} + +// ─── 分页与筛选参数(自己 / 他人 共享) ──────────────────────── +$per_page = $is_own_profile ? 10 : max( 5, (int) ( $user_home_settings['posts_per_page'] ?? 10 ) ); +$default_dashboard_tab = in_array( 'profile', $dashboard_tab_keys, true ) ? 'profile' : ( $dashboard_tab_keys[0] ?? 'profile' ); +$active_tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : $default_dashboard_tab; +$public_default_tab = $public_tab_keys[0] ?? 'posts'; +$public_active_tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : $public_default_tab; +// 兼容旧链接:签到 / 邀请已经并入新面板 +if ( 'signin' === $active_tab ) { + $active_tab = 'credits'; +} elseif ( 'invite' === $active_tab ) { + $active_tab = 'settings'; +} +if ( ! in_array( $active_tab, $dashboard_tab_keys, true ) ) { + $active_tab = $default_dashboard_tab; +} +if ( ! in_array( $public_active_tab, $public_tab_keys, true ) ) { + $public_active_tab = $public_default_tab; +} +$posts_page = max( 1, isset( $_GET['posts_page'] ) ? (int) $_GET['posts_page'] : 1 ); +$comments_page = max( 1, isset( $_GET['comments_page'] ) ? (int) $_GET['comments_page'] : 1 ); +$favorites_page = max( 1, isset( $_GET['favorites_page'] ) ? (int) $_GET['favorites_page'] : 1 ); + +$valid_post_status = [ 'all', 'publish', 'draft', 'pending', 'private' ]; +$post_status_filter = isset( $_GET['post_status'] ) ? sanitize_key( wp_unslash( $_GET['post_status'] ) ) : 'publish'; +if ( ! in_array( $post_status_filter, $valid_post_status, true ) ) { + $post_status_filter = 'publish'; +} + +// 空状态图标 SVG(提取以减少重复) +$svg_empty_post = ''; +$svg_empty_comment = ''; +$svg_empty_favorite = ''; +?> + +
      + + + +
      + + 'GitHub', + 'gitee' => 'Gitee', + 'qq' => 'QQ', + 'google' => 'Google', + ]; + ?> + + + + + +
      +
      + +
      + + + + +
      +
      +
      + display_name ); ?> +
      +
      +

      + display_name ); ?> + +

      + description ) : ?> +

      description ); ?>

      + + +
      + + + + + + +
      + +
      + + user_login ); ?> + + user_email ) : ?> + + user_email ); ?> + + +
      +
      +
      + + + + + + + + +
      +
      +
      + + +
      + + + + +
      + +
      + + +
      + $tab ) : ?> + + +
      + + + +
      > +
      + + + + +
      +

      + +

      +
      + + +
      +

      + +

      +
      + + + +
      +

      +

      +
      + $contact ) : ?> +
      + + + + +
      + +
      + +
      + + + +
      + + +
      +
      +
      + + +
      > +
      +
      +

      + +

      + __( '已发布', 'aksrt' ), + 'draft' => __( '草稿', 'aksrt' ), + 'pending' => __( '待审', 'aksrt' ), + 'private' => __( '私密', 'aksrt' ), + 'all' => __( '全部', 'aksrt' ), + ]; + ?> +
      + $status_text ) : + $count_for_chip = ( 'all' === $status_key ) + ? $post_count + : ( $user_status_counts[ $status_key ] ?? 0 ); + if ( 'publish' !== $status_key && 'all' !== $status_key && $count_for_chip < 1 ) { + continue; + } + $chip_url = add_query_arg( [ + 'tab' => 'posts', + 'post_status' => $status_key, + 'posts_page' => 1, + ], remove_query_arg( [ 'posts_page' ] ) ); + $chip_active = ( $post_status_filter === $status_key ); + ?> + + + + + +
      +
      + + $user_id, + 'post_status' => ( 'all' === $post_status_filter ) + ? [ 'publish', 'draft', 'pending', 'private' ] + : $post_status_filter, + 'posts_per_page' => $per_page, + 'paged' => $posts_page, + 'no_found_rows' => false, + 'update_post_meta_cache' => false, + 'update_post_term_cache' => true, + ]; + $q = new WP_Query( $ps_args ); + if ( $q->have_posts() ) : ?> +
      + have_posts() ) : $q->the_post(); + $st = get_post_status(); + $status_label = ''; + if ( 'publish' !== $st ) { + $st_map = [ + 'draft' => __( '草稿', 'aksrt' ), + 'pending' => __( '待审', 'aksrt' ), + 'private' => __( '私密', 'aksrt' ), + 'future' => __( '定时', 'aksrt' ), + ]; + $status_label = $st_map[ $st ] ?? $st; + } + set_query_var( 'aksrt_profile_item_status', $status_label ); + + $edit_link = get_edit_post_link(); + set_query_var( 'aksrt_profile_item_action', $edit_link ? [ + 'type' => 'edit', + 'url' => $edit_link, + 'label' => __( '编辑', 'aksrt' ), + ] : [] ); + + get_template_part( 'templates/parts/content', 'profile-list-item' ); + endwhile; ?> +
      + max_num_pages, $posts_page, 'posts_page' ); ?> + +
      +
      + + +
      > +
      +
      +

      + +

      +
      + $user_id, + 'number' => $per_page, + 'offset' => ( $comments_page - 1 ) * $per_page, + 'status' => 'approve', + 'update_comment_meta_cache' => false, + ] ); + if ( $cs ) : ?> +
      + +
      + + +
      +
      + + +
      > +
      +
      +

      + +

      +
      + 'post', + 'post_status' => 'publish', + 'post__in' => $favorite_post_ids, + 'orderby' => 'post__in', + 'posts_per_page' => $per_page, + 'paged' => $favorites_page, + 'ignore_sticky_posts' => true, + 'no_found_rows' => false, + 'update_post_meta_cache' => false, + ] ); + if ( $favorites_query->have_posts() ) : ?> +
      + have_posts() ) : $favorites_query->the_post(); + set_query_var( 'aksrt_profile_item_action', [ + 'type' => 'unfavorite', + 'url' => '#', + 'label' => __( '取消收藏', 'aksrt' ), + ] ); + set_query_var( 'aksrt_profile_item_status', '' ); + get_template_part( 'templates/parts/content', 'profile-list-item' ); + endwhile; ?> +
      + max_num_pages, $favorites_page, 'favorites_page' ); ?> + +
      +
      + + + +
      > + + +
      +
      +
      +
      + + +
      +
      + +
      + +
      + + +
      +

      + +
      +
      + + + +
      +
      +
      +
      +
      + +
      +
      + + + + + + +
      +
      + +
      + + +
      + +
      + + +
      + + + + + + +
      +
      + + + + + + + get_recent_signins($user_id, 30); + $signin_dates = array_map(function ($entry) { + return $entry['signin_date'] ?? ''; + }, $signin_log); + $signin_dates = array_filter($signin_dates); + } + ?> + \ No newline at end of file diff --git a/templates/parts/profile/part-3.php b/templates/parts/profile/part-3.php new file mode 100644 index 00000000..cc0f73b6 --- /dev/null +++ b/templates/parts/profile/part-3.php @@ -0,0 +1,403 @@ + + +
      +
      + + + +
      +
      +

      + + +

      +
      + $user_id, 'page' => $credits_page, 'per_page' => 10 ] ); + } else { + $log = [ 'items' => [], 'total' => 0 ]; + } + if ( ! empty( $log['items'] ) ) : + ?> +
      + = 0 ? 'is-positive' : 'is-negative'; + ?> +
      +
      + +
      +
      +
      + + + () + +
      +
      + +
      +
      +
      + + 0 ? '+' : '' ) . $delta ); ?> + + + + +
      +
      + +
      + 1 ) { + aksrt_render_profile_pagination( $total_pages, $credits_page, 'credits_page' ); + } + ?> + +
      +
      + +
      +

      +
      + +
      +
      + + +
      > + get_frontend_bindings_data($user_id); + $user_email = $target_user->user_email ?: ''; + $binding_name_map = [ + 'github' => 'GitHub', + 'gitee' => 'Gitee', + 'qq' => 'QQ', + 'google' => 'Google', + ]; + $binding_rows = []; + foreach ( (array) ( $oauth_data['bindings'] ?? [] ) as $binding ) { + $key = $binding['key'] ?? ''; + if ( ! $key ) continue; + $binding_rows[ $key ] = [ + 'key' => $key, + 'name' => $binding_name_map[ $key ] ?? ( $binding['name'] ?? ucfirst( $key ) ), + 'icon' => $binding['icon'] ?? '', + 'is_bound' => true, + 'url' => '', + ]; + } + foreach ( (array) ( $oauth_data['available'] ?? [] ) as $binding ) { + $key = $binding['key'] ?? ''; + if ( ! $key ) continue; + $binding_rows[ $key ] = [ + 'key' => $key, + 'name' => $binding_name_map[ $key ] ?? ( $binding['name'] ?? ucfirst( $key ) ), + 'icon' => $binding['icon'] ?? '', + 'is_bound' => false, + 'url' => $binding['url'] ?? '', + ]; + } + ?> + + +
      +
      + +

      +
      + + +

      +
      + + +
      + + + + + + + + + +
      + + + + + + + + + + + +
      + +

      + +
      + + +
      +
      + +

      +
      +
      + +
      +
      + +
      + + +
      +
      + +
      + +
      +
      + +
      + + +
      +
      + +
      +
      +
      + + +
      +
      + +

      +
      +
      +
      +
      + +

      + +

      +
      +
      + + -- +
      +
      + +
      +
      + + 0 + +
      +
      + + 0 + +
      +
      + + 0 + +
      +
      +
      +
      +

      + +
      +
      +
      + +
      +
      +
      +
      +
      + + + + + + + + + +
      + + + + + + +
      +
      + + + + +
      > +
      + display_name ); ?> +

      display_name ); ?>

      + + +
      + + + + + + +
      + + + +
      + + + + + + + + + +
      + +
      + +
      + description ) : ?> +

      description ) ); ?>

      + +

      + +
      + + + + + + + + + + +
      +
      +
      + + +
      + + +
      + + +
      +
      + + + +
      + + +
      > + $user_id, + 'posts_per_page' => $per_page, + 'post_status' => 'publish', + 'paged' => $posts_page, + 'no_found_rows' => false, + 'update_post_meta_cache' => false, + ] ); + if ( $author_posts->have_posts() ) : ?> +
      + have_posts() ) : $author_posts->the_post(); + set_query_var( 'aksrt_profile_item_action', [ + 'type' => 'view', + 'url' => get_permalink(), + 'label' => __( '阅读', 'aksrt' ), + ] ); + set_query_var( 'aksrt_profile_item_status', '' ); + get_template_part( 'templates/parts/content', 'profile-list-item' ); + endwhile; ?> +
      + max_num_pages, $posts_page, 'posts_page' ); + wp_reset_postdata(); + set_query_var( 'aksrt_profile_item_action', [] ); + else : + aksrt_render_profile_empty( $svg_empty_post, __( 'TA 还没有发布文章', 'aksrt' ) ); + endif; ?> +
      + + + +
      > + $user_id, + 'status' => 'approve', + 'number' => $per_page, + 'offset' => ( $comments_page - 1 ) * $per_page, + 'type' => 'comment', + 'update_comment_meta_cache' => false, + ] ); + if ( $user_comments ) : ?> +
      + +
      + + +
      + + + +
      > + 'post', + 'post__in' => $favorite_post_ids, + 'orderby' => 'post__in', + 'posts_per_page' => $per_page, + 'paged' => $favorites_page, + 'no_found_rows' => false, + 'update_post_meta_cache' => false, + ] ); + } else { + $favorite_query = null; + } + + if ( $favorite_query && $favorite_query->have_posts() ) : + ?> +
      + have_posts() ) : $favorite_query->the_post(); + set_query_var( 'aksrt_profile_item_action', [ + 'type' => 'view', + 'url' => get_permalink(), + 'label' => __( '阅读', 'aksrt' ), + ] ); + set_query_var( 'aksrt_profile_item_status', '' ); + get_template_part( 'templates/parts/content', 'profile-list-item' ); + endwhile; ?> +
      + max_num_pages, $favorites_page, 'favorites_page' ); + wp_reset_postdata(); + set_query_var( 'aksrt_profile_item_action', [] ); + else : + aksrt_render_profile_empty( $svg_empty_favorite, __( 'TA 还没有公开收藏内容', 'aksrt' ) ); + endif; ?> +
      + +
      + + + + + + + + + + + +
      + + '', 'free' => true]; + +// 下载策略检查 +$gate = function_exists('aksrt_can_download') ? aksrt_can_download($post_id) : true; +$gate_blocked = is_wp_error($gate); +$gate_code = $gate_blocked ? $gate->get_error_code() : ''; +$gate_data = $gate_blocked ? (array) $gate->get_error_data() : []; + +$paid_settings = class_exists('AKSRT_Settings') + ? AKSRT_Settings::get_paid_content_settings() + : ['download_credit' => true, 'download_captcha' => false]; + +$cost = function_exists('aksrt_resource_download_cost') ? aksrt_resource_download_cost($post_id) : (int) ceil((float) $meta['price']); +$credit_active = !empty($paid_settings['download_credit']) && $cost > 0; +$captcha_active = !empty($paid_settings['download_captcha']) || !empty($meta['require_captcha']); +if ($captcha_active && function_exists('aksrt_is_captcha_enabled_for') && !aksrt_is_captcha_enabled_for('download')) { + $captcha_active = false; +} +$balance = (int) ($gate_data['balance'] ?? 0); +$login_url = (string) ($gate_data['login_url'] ?? wp_login_url(get_permalink($post_id))); + +$is_need_login = $gate_blocked && $gate_code === 'aksrt_need_login'; +$is_low_credit = $gate_blocked && $gate_code === 'aksrt_credits_insufficient'; + +// 缩略图 +$thumbnail = get_the_post_thumbnail_url($post_id, 'medium') ?: ''; +$has_thumbnail = $thumbnail !== ''; + +// 原价 & VIP 价 +$original_price = (float) ($meta['original_price'] ?? 0); +$vip1_price = (float) ($meta['vip1_price'] ?? 0); +$vip2_price = (float) ($meta['vip2_price'] ?? 0); +$has_original = $original_price > 0 && $original_price > (float) $meta['price']; +$has_vip = $vip1_price > 0 || $vip2_price > 0; + +// 销量 +$download_count = (int) ($meta['download_count'] ?? 0); +$sales_count = (int) ($meta['sales_count'] ?? 0); +$display_count = max($download_count, $sales_count); + +// 付费类型徽章 +$pay_type_badge = function_exists('aksrt_resource_pay_type_badge') ? aksrt_resource_pay_type_badge($pay_type) : ''; + +// 货币名称 +$currency = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_currency_name() : __('积分', 'aksrt'); +?> + +
      + + 0) : ?> + + + + + + +
      + +
      + <?php echo esc_attr(get_the_title()); ?>> + +
      + +
      + + +
      +
      +

      + + +

      + + + + + +
      + + +
      + + + + + + + + + +
      + + +
      + 0) : ?> + + + + + + 0) : ?> + + + + + +
      + + + + +
      +

      +
      + +
        + $row) : + $label = isset($row['label']) ? (string) $row['label'] : ''; + if ($label === '') { + $label = sprintf(__('下载源 %d', 'aksrt'), $idx + 1); + } + $type = isset($row['type']) ? (string) $row['type'] : 'direct'; + $url = isset($row['url']) ? (string) $row['url'] : ''; + $extract_code = isset($row['extract_code']) ? trim((string) $row['extract_code']) : ''; + $has_extract = $extract_code !== ''; + + $service = function_exists('aksrt_detect_cloud_service') ? aksrt_detect_cloud_service($url) : ['key' => 'other', 'name' => $label, 'color' => '#6b7280']; + if (strpos($label, '下载源') === 0 && $service['key'] !== 'other') { + $label = $service['name']; + } + + if ($is_need_login) { + $btn_tag = 'a'; + $btn_text = __('登录下载', 'aksrt'); + $btn_icon = 'fa-sign-in-alt'; + $btn_modifier = ' resource-download-box__btn--login'; + } elseif ($is_low_credit) { + $btn_tag = 'button'; + $btn_text = sprintf(__('付费下载(%d 积分)', 'aksrt'), $cost); + $btn_icon = 'fa-coins'; + $btn_modifier = ' resource-download-box__btn--credit-low'; + } elseif ($credit_active && $cost > 0) { + $btn_tag = 'button'; + $btn_text = sprintf(__('下载(%d 积分)', 'aksrt'), $cost); + $btn_icon = 'fa-download'; + $btn_modifier = ' resource-download-box__btn--paid'; + } else { + $btn_tag = 'button'; + $btn_text = $label; + $btn_icon = 'fa-download'; + $btn_modifier = ''; + } + ?> +
      • + + + + + + + + + + + +
      • + +
      + + + +
      + + + + + + + + + + + + +
      +
      +
      + + + + +

      + + +

      + + + +
      + +
      + + +
      +

      + + +

      +
      +
      diff --git a/templates/parts/resource-meta-box.php b/templates/parts/resource-meta-box.php new file mode 100644 index 00000000..9e56efa1 --- /dev/null +++ b/templates/parts/resource-meta-box.php @@ -0,0 +1,122 @@ + __('版本', 'aksrt'), + 'size' => __('大小', 'aksrt'), + 'compat' => __('兼容性', 'aksrt'), + 'license' => __('授权', 'aksrt'), +]; + +// 至少要有一个非空字段才渲染 +$has_any = false; +foreach ($rows as $k => $_label) { + if (!empty($meta[$k])) { $has_any = true; break; } +} +$has_demo = !empty($meta['demo_url']); +$has_downloads = ($meta['download_count'] ?? 0) > 0; + +if (!$has_any && !$has_demo && !$has_downloads) return; +?> + + diff --git a/templates/parts/resource-related.php b/templates/parts/resource-related.php new file mode 100644 index 00000000..247d4ade --- /dev/null +++ b/templates/parts/resource-related.php @@ -0,0 +1,64 @@ + 'ids']); +if (empty($cat_ids)) return; + +$post_types = function_exists('aksrt_resource_post_types') ? aksrt_resource_post_types() : ['post']; + +$args = [ + 'post_type' => $post_types, + 'posts_per_page' => 6, + 'post__not_in' => [$current_id], + 'category__in' => array_map('intval', $cat_ids), + 'meta_key' => '_aksrt_res_download_count', + 'orderby' => 'meta_value_num', + 'order' => 'DESC', + 'no_found_rows' => true, +]; +if (!post_type_exists('aksrt_resource')) { + $args['meta_query'] = [ + 'relation' => 'AND', + [ + 'key' => '_aksrt_is_resource', + 'value' => '1', + 'compare' => '=', + ], + ]; +} + +$query = new WP_Query($args); + +if (!$query->have_posts()) { + wp_reset_postdata(); + return; +} +?> + + diff --git a/templates/parts/single-author-bar.php b/templates/parts/single-author-bar.php new file mode 100644 index 00000000..e05b99cc --- /dev/null +++ b/templates/parts/single-author-bar.php @@ -0,0 +1,106 @@ + + +
      +
      +
      + 'article-avatar']); ?> +
      +
      + +
      + + + + + +
      + +

      + +
      +
      + + + +
      + + + + + + + + + +
      + + +
      +
      + +
      diff --git a/templates/parts/special/special-cover.php b/templates/parts/special/special-cover.php new file mode 100644 index 00000000..4dfc8dab --- /dev/null +++ b/templates/parts/special/special-cover.php @@ -0,0 +1,66 @@ + +
      > + +
      + class="aksrt-special-cover__img"> +
      +
      + + +
      +

      + + +

      + + +
      + + + + + $post_id, 'parent' => $post_id, 'post_status' => 'publish', 'fields' => 'ids']); + if (!empty($children)) : ?> + + + + + +
      +
      +
      diff --git a/templates/parts/special/special-grid.php b/templates/parts/special/special-grid.php new file mode 100644 index 00000000..78d5e2c7 --- /dev/null +++ b/templates/parts/special/special-grid.php @@ -0,0 +1,46 @@ + + diff --git a/templates/parts/user-center/README.md b/templates/parts/user-center/README.md new file mode 100644 index 00000000..a005d1bc --- /dev/null +++ b/templates/parts/user-center/README.md @@ -0,0 +1 @@ + diff --git a/templates/parts/user-center/uc-comments.php b/templates/parts/user-center/uc-comments.php new file mode 100644 index 00000000..f994e054 --- /dev/null +++ b/templates/parts/user-center/uc-comments.php @@ -0,0 +1,81 @@ + $user_id, + 'status' => 'approve', + 'post_status' => 'publish', + 'number' => $per_page, + 'offset' => ($paged - 1) * $per_page, + 'orderby' => 'comment_date_gmt', + 'order' => 'DESC', + 'count' => true, +]); + +$total_comments = $comments; +$comments = get_comments([ + 'user_id' => $user_id, + 'status' => 'approve', + 'post_status' => 'publish', + 'number' => $per_page, + 'offset' => ($paged - 1) * $per_page, + 'orderby' => 'comment_date_gmt', + 'order' => 'DESC', +]); +$max_pages = ceil($total_comments / $per_page); +?> + +
      + +
      + comment_post_ID); + if (!$post) continue; + ?> +
      + +
      + comment_content); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> +
      +
      + +
      + + 1) : ?> +
      + str_replace(999999999, '%#%', esc_url(get_pagenum_link(999999999))), + 'format' => '?paged=%#%', + 'current' => $paged, + 'total' => $max_pages, + 'prev_text' => '', + 'next_text' => '', + ]); + ?> +
      + + +
      +
      +

      +

      +
      + +
      diff --git a/templates/parts/user-center/uc-credits.php b/templates/parts/user-center/uc-credits.php new file mode 100644 index 00000000..adb5a3e9 --- /dev/null +++ b/templates/parts/user-center/uc-credits.php @@ -0,0 +1,59 @@ + + +
      + +
      +
      + + +
      +
      + + + +
      +

      +
      + +
      +
      + + +
      + + 0 ? '+' : ''; ?> + +
      + +
      +
      + +
      +
      +

      +

      +
      + +
      diff --git a/templates/parts/user-center/uc-dashboard.php b/templates/parts/user-center/uc-dashboard.php new file mode 100644 index 00000000..7c7acc5c --- /dev/null +++ b/templates/parts/user-center/uc-dashboard.php @@ -0,0 +1,98 @@ + $user_id]) + : []; + +$credits_enabled = !empty($dashboard['credits_enabled']); +$credit_balance = $dashboard['credit_balance'] ?? 0; +$currency_name = $dashboard['currency_name'] ?? __('积分', 'aksrt'); +$is_vip = function_exists('aksrt_is_vip') && aksrt_is_vip($user_id); +$post_count = (int) ($dashboard['post_count'] ?? 0); +$comment_count = (int) ($dashboard['comment_count'] ?? 0); +$favorite_count = (int) ($dashboard['favorite_count'] ?? 0); +$order_count = (int) ($dashboard['order_count'] ?? 0); +?> + +
      + +
      +
      + + + VIP + +
      +
      +

      display_name); ?>

      +

      + description ?: __('这个人很懒,什么都没写~', 'aksrt'); + echo esc_html(wp_trim_words($desc, 30)); + ?> +

      +
      + user_registered))); ?> +
      +
      + + + +
      + + + + + +
      + + + + + + + + + 0) : ?> + + + + + + + + + +
      +
      diff --git a/templates/parts/user-center/uc-favorites.php b/templates/parts/user-center/uc-favorites.php new file mode 100644 index 00000000..24fa71f0 --- /dev/null +++ b/templates/parts/user-center/uc-favorites.php @@ -0,0 +1,75 @@ + + +
      + +
      + post_status !== 'publish') continue; + ?> +
      +
      +

      + post_title); ?> +

      +
      + ID)); ?> + ID, 'aksrt_post_views', true) ?: 0); ?> +
      + +
      + ID)) : ?> + + ID, 'thumbnail'); ?> + + +
      + +
      + + 1) : ?> +
      + str_replace(999999999, '%#%', esc_url(get_pagenum_link(999999999))), + 'format' => '?paged=%#%', + 'current' => $paged, + 'total' => $max_pages, + 'prev_text' => '', + 'next_text' => '', + ]); + ?> +
      + + +
      +
      +

      +

      +
      + +
      diff --git a/templates/parts/user-center/uc-posts.php b/templates/parts/user-center/uc-posts.php new file mode 100644 index 00000000..1a8f6e65 --- /dev/null +++ b/templates/parts/user-center/uc-posts.php @@ -0,0 +1,71 @@ + $user_id, + 'post_type' => 'post', + 'post_status' => 'publish', + 'posts_per_page' => $per_page, + 'paged' => $paged, + 'no_found_rows' => false, +]); +?> + +
      + have_posts()) : ?> +
      + have_posts()) : $query->the_post(); ?> +
      +
      +

      + +

      +
      + + + +
      +
      + + + + + +
      + +
      + + max_num_pages > 1) : ?> +
      + str_replace(999999999, '%#%', esc_url(get_pagenum_link(999999999))), + 'format' => '?paged=%#%', + 'current' => $paged, + 'total' => $query->max_num_pages, + 'prev_text' => '', + 'next_text' => '', + ]); + ?> +
      + + +
      +
      +

      +

      +
      + + +
      diff --git a/templates/parts/user-home/README.md b/templates/parts/user-home/README.md new file mode 100644 index 00000000..87f0ec7a --- /dev/null +++ b/templates/parts/user-home/README.md @@ -0,0 +1 @@ + diff --git a/templates/parts/user-home/uh-comments.php b/templates/parts/user-home/uh-comments.php new file mode 100644 index 00000000..a5ca647c --- /dev/null +++ b/templates/parts/user-home/uh-comments.php @@ -0,0 +1,76 @@ + $user_id, + 'status' => 'approve', + 'post_status' => 'publish', + 'count' => true, +]); + +$comments = get_comments([ + 'user_id' => $user_id, + 'status' => 'approve', + 'post_status' => 'publish', + 'number' => $per_page, + 'offset' => ($paged - 1) * $per_page, + 'orderby' => 'comment_date_gmt', + 'order' => 'DESC', +]); + +$max_pages = ceil($total / $per_page); +?> + +
      + +
      + comment_post_ID); + if (!$post) continue; + ?> +
      + +
      + comment_content); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> +
      +
      + +
      + + 1) : ?> +
      + str_replace(999999999, '%#%', esc_url(get_pagenum_link(999999999))), + 'format' => '?tab=comments&paged=%#%', + 'current' => $paged, + 'total' => $max_pages, + 'prev_text' => '', + 'next_text' => '', + ]); + ?> +
      + + +
      +
      +

      +
      + +
      diff --git a/templates/parts/user-home/uh-hero.php b/templates/parts/user-home/uh-hero.php new file mode 100644 index 00000000..f0a0fa9f --- /dev/null +++ b/templates/parts/user-home/uh-hero.php @@ -0,0 +1,66 @@ +ID : 0; +$dashboard = $args['dashboard'] ?? []; +$settings = $args['settings'] ?? []; +$show_cover = $settings['cover'] ?? true; +$show_follow = $settings['follow'] ?? true; +$is_own = $dashboard['is_own_profile'] ?? false; +$viewer_id = $dashboard['viewer_id'] ?? 0; + +if (!$user) return; + +$cover_url = get_user_meta($user_id, 'aksrt_user_cover', true) ?: ''; +$avatar = get_avatar_url($user_id, ['size' => 96]); +$desc = $user->description ?: ''; +$is_vip = function_exists('aksrt_is_vip') && aksrt_is_vip($user_id); +?> + +
      > +
      +
      +
      + <?php echo esc_attr($user->display_name); ?> + + VIP + +
      +
      +

      + display_name); ?> + + + + + +

      + +

      + +
      + user_registered))); ?> + +
      +
      + 0) : ?> + + + +
      +
      diff --git a/templates/parts/user-home/uh-posts.php b/templates/parts/user-home/uh-posts.php new file mode 100644 index 00000000..7b72c415 --- /dev/null +++ b/templates/parts/user-home/uh-posts.php @@ -0,0 +1,55 @@ + $user_id, + 'post_type' => 'post', + 'post_status' => 'publish', + 'posts_per_page' => $per_page, + 'paged' => $paged, + 'no_found_rows' => false, +]); + +if (!$query->have_posts()) : + wp_reset_postdata(); + ?> +
      +
      +

      +
      + + +
      + have_posts()) : $query->the_post(); ?> + + + + max_num_pages > 1) : ?> +
      + str_replace(999999999, '%#%', esc_url(get_pagenum_link(999999999))), + 'format' => '?tab=posts&paged=%#%', + 'current' => $paged, + 'total' => $query->max_num_pages, + 'prev_text' => '', + 'next_text' => '', + ]); + ?> +
      + + +
      diff --git a/templates/parts/user-home/uh-sidebar.php b/templates/parts/user-home/uh-sidebar.php new file mode 100644 index 00000000..f374ebff --- /dev/null +++ b/templates/parts/user-home/uh-sidebar.php @@ -0,0 +1,106 @@ +ID : 0; +$dashboard = $args['dashboard'] ?? []; +$settings = $args['settings'] ?? []; +$show_follow = $settings['follow'] ?? true; +$is_own = $dashboard['is_own_profile'] ?? false; +$viewer_id = $dashboard['viewer_id'] ?? 0; +$contacts = $dashboard['user_contacts'] ?? []; + +if (!$user) return; +?> + +
      + +
      +
      +
      +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      +
      +
      + + + +
      +
      +
      +
      + + + + + + +
      +
      +
      + + + + 0) : ?> + + +
      +
      +
      + + + + + + +
      +
      + + +
      diff --git a/templates/parts/user-shared-helpers.php b/templates/parts/user-shared-helpers.php new file mode 100644 index 00000000..6a456f0b --- /dev/null +++ b/templates/parts/user-shared-helpers.php @@ -0,0 +1,58 @@ + $base, + 'format' => '', + 'current' => $current, + 'total' => $total_pages, + 'prev_text' => '' . esc_html__('上一页', 'aksrt') . '', + 'next_text' => '' . esc_html__('下一页', 'aksrt') . '', + 'mid_size' => 1, + 'end_size' => 1, + ]); + if ($links) { + echo ''; // phpcs:ignore + } + } +} + +if (!function_exists('aksrt_render_profile_empty')) { + /** + * 渲染空状态卡片 + */ + function aksrt_render_profile_empty($icon_svg, $title, $desc = '', $cta_url = '', $cta_label = '') { + echo '
      '; + echo ''; // phpcs:ignore + echo '

      ' . esc_html($title) . '

      '; + if ($desc) { + echo '

      ' . esc_html($desc) . '

      '; + } + if ($cta_url && $cta_label) { + echo '' . esc_html($cta_label) . ''; + } + echo '
      '; + } +} diff --git a/templates/parts/user/author-hero.php b/templates/parts/user/author-hero.php new file mode 100644 index 00000000..463b498f --- /dev/null +++ b/templates/parts/user/author-hero.php @@ -0,0 +1,153 @@ +ID; +$dashboard = $dashboard ?? []; +$settings = $user_home_settings ?? []; +$is_author = !empty($is_author_page); +$home_url = function_exists('aksrt_get_user_home_url') ? aksrt_get_user_home_url($user) : home_url('/'); +$show_cover = !empty($settings['cover']); +$show_follow = !empty($settings['follow']); + +$post_count = (int) ($dashboard['post_count'] ?? 0); +$comment_count = (int) ($dashboard['comment_count'] ?? 0); +$favorite_count = (int) ($dashboard['favorite_count'] ?? 0); +$contacts = $dashboard['user_contacts'] ?? []; +$social_on = !empty($dashboard['social_links_on']); +$signals = $dashboard['profile_signals'] ?? []; +$notice = $dashboard['profile_notice'] ?? null; +$show_report = !empty($dashboard['show_report']); +$bg_url = $dashboard['background_url'] ?? ''; +$is_own = !empty($dashboard['is_own_profile']); +?> + +
      + +
      + > +
      +
      + + +
      +
      + 'lazy']); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } else { + echo get_avatar($user_id, 120, '', $user->display_name); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + ?> +
      + +
      +

      + display_name); ?> + +

      + + description) : ?> +

      description); ?>

      + + + +
      + + + + + + +
      + + +
      + + + + + + + + + + + + +
      + + +
      + + + +
      + +
      + +
      + + + + + + + + + + + + + + + + + + + + + + + + +
      +
      +
      + + +
      + + +
      + diff --git a/templates/shop/product-cat.php b/templates/shop/product-cat.php new file mode 100644 index 00000000..7a416cc3 --- /dev/null +++ b/templates/shop/product-cat.php @@ -0,0 +1,94 @@ +get_product_archive_view([ + 'taxonomy' => 'aksrt_product_cat', + 'term_id' => $term->term_id, + 'paged' => $paged, +]) : ['products' => [], 'term' => null, 'total' => 0, 'max_pages' => 0]; + +$products = $view['products']; +$total = $view['total']; +$max_pages = $view['max_pages']; +$term_name = $term->name ?? ''; +$term_desc = $term->description ?? ''; +?> + +
      + + +
      +
      +

      + +

      + +

      +
      + + + + + 1) : ?> + + + +
      + +

      +
      + +
      +
      + +get_product_archive_view([ + 'taxonomy' => 'aksrt_product_tag', + 'term_id' => $term->term_id, + 'paged' => $paged, +]) : ['products' => [], 'term' => null, 'total' => 0, 'max_pages' => 0]; + +$products = $view['products']; +$total = $view['total']; +$max_pages = $view['max_pages']; +$term_name = $term->name ?? ''; +?> + +
      + + +
      +
      +

      + + +

      +

      +
      + + + + + 1) : ?> + + + +
      + +

      +
      + +
      +
      + +get_results( + "SELECT DISTINCT YEAR(post_date) AS year, MONTH(post_date) AS month, COUNT(*) AS count + FROM {$wpdb->posts} + WHERE post_type = 'post' AND post_status = 'publish' + GROUP BY YEAR(post_date), MONTH(post_date) + ORDER BY year DESC, month DESC" +); // phpcs:ignore WordPress.DB.DirectDatabaseQuery + +if (empty($rows)) { + echo '
      ' + . esc_html__('暂无文章。', 'aksrt') . '
      '; + return; +} + +$current_year = 0; +$shown = 0; +?> +
      + year !== $current_year) : + $current_year = (int) $row->year; + ?> +

      + +

      month, (int) $row->count)); ?>

      +
        + (int) $row->year, + 'monthnum' => (int) $row->month, + 'posts_per_page' => -1, + 'post_status' => 'publish', + 'no_found_rows' => true, + ]); + foreach ($month_posts as $mp) : + if ($limit > 0 && $shown >= $limit) { + break; + } + $shown++; + ?> +
      • + + +
      • + +
      + 0 && $shown >= $limit) { break; } ?> + +
      diff --git a/templates/shortcodes/profile.php b/templates/shortcodes/profile.php new file mode 100644 index 00000000..635ab6bc --- /dev/null +++ b/templates/shortcodes/profile.php @@ -0,0 +1,58 @@ +" 指定用户)、$content。 + * + * @package AKSRT + */ + +defined('ABSPATH') || exit; + +// 解析目标用户:属性指定 > 当前登录用户。 +$target = isset($attrs['user']) ? trim((string) $attrs['user']) : ''; +$user = null; +if ($target !== '') { + $user = is_numeric($target) ? get_user_by('id', (int) $target) : get_user_by('login', $target); +} elseif (is_user_logged_in()) { + $user = wp_get_current_user(); +} + +if (!($user instanceof WP_User) || $user->ID <= 0) { + echo '
      ' + . '

      ' . esc_html__('登录后查看个人资料。', 'aksrt') . '

      '; + if (function_exists('aksrt_get_login_url')) { + echo '' . esc_html__('登录', 'aksrt') . ''; + } + echo '
      '; + return; +} + +$user_id = (int) $user->ID; +$avatar = function_exists('aksrt_get_avatar_url') ? aksrt_get_avatar_url($user_id, ['size' => 96]) : get_avatar_url($user_id, ['size' => 96]); +$home_url = function_exists('aksrt_get_user_home_url') ? aksrt_get_user_home_url($user) : get_author_posts_url($user_id); +$credits = function_exists('aksrt_user_credits') ? (int) aksrt_user_credits($user_id) : 0; +$post_count = (int) count_user_posts($user_id, 'post', true); +$bio = (string) get_the_author_meta('description', $user_id); +$show_credits = class_exists('AKSRT_Settings') ? AKSRT_Settings::credits_enabled() : true; +?> +
      + + <?php echo esc_attr($user->display_name); ?>> + +
      + display_name); ?> + +

      + +
      + + + + +
      +
      +
      diff --git a/uninstall.php b/uninstall.php new file mode 100644 index 00000000..af787cc3 --- /dev/null +++ b/uninstall.php @@ -0,0 +1,77 @@ +query("DROP TABLE IF EXISTS {$wpdb->prefix}{$table}"); +} + +// ── Delete AKSRT options ── +$options = $wpdb->get_col( + "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE 'aksrt_%'" +); +if ($options) { + foreach ($options as $option) { + delete_option($option); + } +} + +// ── Delete AKSRT user meta ── +$meta_keys = [ + 'aksrt_user_contacts', + 'aksrt_background_image', + 'aksrt_email_verification_status', + 'aksrt_email_verified_at', + 'aksrt_unread_message_count', + '_aksrt_active_restrictions', + '_aksrt_is_banned', + '_aksrt_favorite_posts', + 'aksrt_wechat_openid', + 'aksrt_wechat_subscriptions', +]; + +foreach ($meta_keys as $key) { + $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->usermeta} WHERE meta_key = %s", $key)); +} + +// ── Delete transients ── +$wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_aksrt_%'"); +$wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_timeout_aksrt_%'");
      + +
      + +
      + + +
      +
      +
      + + + + + + + + +
      + + + + + +
      + +
      + +
      +
      + + + +
      + +
      + $sort_option) : ?> + $sort_option['args']['orderby'], + 'order' => $sort_option['args']['order'], + 'paged' => false, + ], static function ($value): bool { + return false !== $value; + })); + if ($sort_key === 'relevance') { + $sort_url = remove_query_arg(['orderby', 'order', 'paged']); + } + ?> + + + + +
      +
      + +
      + + +
      + + + + +
      + + have_posts()) : ?> + + 'search', 'render_config' => $render_config]); ?> + + +
      + +

      +

      +
      + + + + +
      +
      + + + + name); ?> + + +
      +
      + + + +
      + + + + +
      +
      + + +
      + +
      + +