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()) : ?> +
+

+ +
+ +
+ + __('最新', '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('/ +
    +
    + + + + / + +
    + +
    + + '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_%'");