refactor(classes): organize inc/classes into domain folders

Move ~94 root class shells and root *-traits into domain directories
(core, auth, user, credits, content, payment/gateways, forum, shop,
sms, ai, messages, extensions, widgets, settings, admin). Update
bootstrap require paths, co-locate traits with owners, fix forum/user
relative requires, drop duplicate shop controller requires, and wire
home-modules load. Path-only; class names and init order unchanged.
This commit is contained in:
kate522 committed 2026-07-17 11:11:57 +08:00
1 parent 2a8fe68048
commit ce1c13b947
115 files changed
+171 -224

No files matched your search

@@ -0,0 +1,166 @@
<?php
/**
* AKSRT Category Rewrite — Remove /category/ from URLs
*
* Rewrites category URLs from /category/slug/ to /slug/.
* Adds 301 redirect for old /category/ URLs.
*
* WARNING: May conflict with other "no-category" plugins.
* Enable via Settings → SEO → Remove category base.
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_Category_Rewrite {
private static $instance = null;
private function __construct() {}
private function rewrite_version(): string {
return AKSRT_VERSION . ':category-rewrite-v1';
}
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
if (!$this->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');
}
}
@@ -0,0 +1,543 @@
<?php
/**
* AKSRT Comment Actions
*
* REST endpoints for comment edit / delete / moderate.
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_Comment_Actions {
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('rest_api_init', [$this, 'register_routes']);
}
public function register_routes(): void {
register_rest_route('aksrt/v1', '/comment/list', [
'methods' => \WP_REST_Server::READABLE,
'callback' => [$this, 'list_comments'],
'permission_callback' => '__return_true',
'args' => [
'post_id' => ['required' => true, 'type' => 'integer', 'sanitize_callback' => 'absint'],
'page' => ['required' => false, 'type' => 'integer', 'default' => 1, 'sanitize_callback' => 'absint'],
'order' => ['required' => false, 'type' => 'string', 'default' => 'newest', 'sanitize_callback' => 'sanitize_key'],
'author_only' => ['required' => false, 'type' => 'boolean', 'default' => false],
],
]);
register_rest_route('aksrt/v1', '/comment/submit', [
'methods' => 'POST',
'callback' => [$this, 'submit_comment'],
'permission_callback' => '__return_true',
]);
register_rest_route('aksrt/v1', '/comment/edit', [
'methods' => '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' => [$this, 'check_moderate_permission'],
'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'];
}
/**
* Fragment list for order / author / page without full HTML scrape.
*/
public function list_comments(\WP_REST_Request $request): \WP_REST_Response {
$post_id = (int) $request->get_param('post_id');
$post = get_post($post_id);
if (!$post) {
return new \WP_REST_Response([
'success' => false,
'code' => 'post_not_found',
'message' => __('文章不存在。', 'aksrt'),
], 404);
}
if (!function_exists('aksrt_comments_render_list_fragment')) {
return new \WP_REST_Response([
'success' => false,
'code' => 'fragment_unavailable',
'message' => __('评论片段渲染不可用。', 'aksrt'),
], 500);
}
$order = sanitize_key((string) ($request->get_param('order') ?: 'newest'));
$author_only = (bool) $request->get_param('author_only');
$page = max(1, (int) ($request->get_param('page') ?: 1));
$fragment = aksrt_comments_render_list_fragment($post_id, [
'order' => $order,
'author_only' => $author_only,
'page' => $page,
]);
return new \WP_REST_Response([
'success' => true,
'data' => $fragment,
]);
}
/**
* AJAX submit with HTML item for local insert.
* Runs through native WP comment pipeline (preprocess_comment / moderation).
*/
public function submit_comment(\WP_REST_Request $request): \WP_REST_Response {
$params = $request->get_json_params();
if (!is_array($params) || empty($params)) {
$params = $request->get_params();
}
if (!is_array($params)) {
$params = [];
}
$post_id = absint($params['comment_post_ID'] ?? $params['post_id'] ?? 0);
$content = isset($params['comment']) ? (string) $params['comment'] : '';
$parent = absint($params['comment_parent'] ?? 0);
if ($post_id <= 0 || trim(wp_strip_all_tags($content)) === '') {
return new \WP_REST_Response([
'success' => false,
'code' => 'invalid_payload',
'message' => __('请输入评论内容。', 'aksrt'),
], 400);
}
$post = get_post($post_id);
if (!$post || !comments_open($post_id)) {
return new \WP_REST_Response([
'success' => false,
'code' => 'comments_closed',
'message' => __('评论已关闭。', 'aksrt'),
], 403);
}
$nonce = (string) ($params['_aksrt_comment_nonce'] ?? $params['_wpnonce'] ?? '');
if ($nonce !== '' && !wp_verify_nonce($nonce, 'aksrt_comment_nonce') && !wp_verify_nonce($nonce, 'wp_rest')) {
return new \WP_REST_Response([
'success' => false,
'code' => 'invalid_nonce',
'message' => __('安全校验失败,请刷新后重试。', 'aksrt'),
], 403);
}
if (function_exists('aksrt_require_captcha')) {
$captcha_result = aksrt_require_captcha('comment', $params);
if ($captcha_result !== true) {
return new \WP_REST_Response([
'success' => false,
'code' => 'captcha_failed',
'message' => (string) $captcha_result,
], 400);
}
}
$user = wp_get_current_user();
$commentdata = [
'comment_post_ID' => $post_id,
'comment_content' => $content,
'comment_parent' => $parent,
'comment_type' => '',
'comment_author_IP' => isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash((string) $_SERVER['REMOTE_ADDR'])) : '',
'comment_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash((string) $_SERVER['HTTP_USER_AGENT'])) : '',
'comment_date' => current_time('mysql'),
'comment_date_gmt' => current_time('mysql', true),
];
if ($user && $user->ID > 0) {
$commentdata['user_id'] = (int) $user->ID;
$commentdata['comment_author'] = $user->display_name ?: $user->user_login;
$commentdata['comment_author_email'] = $user->user_email;
$commentdata['comment_author_url'] = $user->user_url;
} else {
$commentdata['user_id'] = 0;
$commentdata['comment_author'] = sanitize_text_field((string) ($params['author'] ?? ''));
$commentdata['comment_author_email'] = sanitize_email((string) ($params['email'] ?? ''));
$commentdata['comment_author_url'] = esc_url_raw((string) ($params['url'] ?? ''));
if ($commentdata['comment_author'] === '' || $commentdata['comment_author_email'] === '') {
return new \WP_REST_Response([
'success' => false,
'code' => 'guest_fields_required',
'message' => __('请填写昵称和邮箱。', 'aksrt'),
], 400);
}
}
foreach (['author', 'email', 'url', 'comment', 'comment_post_ID', 'comment_parent', '_aksrt_comment_nonce'] as $k) {
if (isset($params[$k])) {
$_POST[$k] = $params[$k];
}
}
$comment_id = wp_new_comment(wp_slash($commentdata), true);
if (is_wp_error($comment_id)) {
return new \WP_REST_Response([
'success' => false,
'code' => $comment_id->get_error_code() ?: 'submit_failed',
'message' => $comment_id->get_error_message() ?: __('评论提交失败。', 'aksrt'),
], 400);
}
$comment = get_comment((int) $comment_id);
if (!$comment) {
return new \WP_REST_Response([
'success' => false,
'code' => 'submit_failed',
'message' => __('评论提交失败。', 'aksrt'),
], 500);
}
$settings = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_comment_display_settings() : [];
$html = function_exists('aksrt_comments_render_single_html')
? aksrt_comments_render_single_html($comment, $settings)
: '';
$needs_moderation = '0' === (string) $comment->comment_approved;
$wait_time = 0;
return new \WP_REST_Response([
'success' => true,
'code' => 'submitted',
'message' => $needs_moderation ? __('评论已提交,等待管理员审核', 'aksrt') : __('评论发布成功', 'aksrt'),
'html' => $html,
'is_reply'=> $parent > 0,
'parent_id' => $parent,
'needs_moderation' => $needs_moderation,
'wait_time' => $wait_time,
'data' => [
'comment_id' => (int) $comment_id,
'approved' => (string) $comment->comment_approved,
],
]);
}
/**
* 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);
}
$rendered = function_exists('aksrt_render_comment_content')
? aksrt_render_comment_content($clean_content)
: wpautop($clean_content);
return new \WP_REST_Response([
'success' => true,
'code' => 'updated',
'message' => __('评论已更新。', 'aksrt'),
'data' => [
'content' => $rendered,
],
]);
}
/**
* 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);
if (is_user_logged_in()) {
do_action('aksrt_comment_liked', $comment_id, get_current_user_id(), (bool) $target);
}
} else {
$count = aksrt_get_comment_likes($comment_id);
}
return new \WP_REST_Response([
'success' => true,
'code' => 'liked',
'data' => [
'active' => $target,
'count' => $count,
],
]);
}
}
@@ -0,0 +1,52 @@
<?php
/**
* AKSRT DB Migrator
*
* Maintains runtime tables (credits / download tokens / shop / forum support, etc.).
* Schema only via dbDelta — no option/data migration, no legacy option dual-write.
*
* Trigger:
* 1. after_switch_theme
* 2. admin_init when aksrt_db_schema_version !== SCHEMA_VERSION
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_DB_Migrator {
use AKSRT_DB_Migrator_Part_2;
use AKSRT_DB_Migrator_Part_3;
/** Current schema version; changing it re-runs dbDelta. */
const SCHEMA_VERSION = '1.15.0';
const OPT_KEY = 'aksrt_db_schema_version';
/**
* Historical flag still readable by older code paths.
* Plate CPT is now native when registered; prefer post_type_exists().
*/
const OPT_PLATE_CPT_MIGRATED = 'aksrt_plate_cpt_migrated';
/**
* Register hooks.
*/
public static function init(): void {
add_action('after_switch_theme', [self::class, 'maybe_migrate']);
add_action('admin_init', [self::class, 'maybe_migrate']);
}
/**
* Run table schema upgrade when version mismatches.
*/
public static function maybe_migrate(): void {
$current = get_option(self::OPT_KEY, '');
if ($current === self::SCHEMA_VERSION) {
return;
}
self::run();
update_option(self::OPT_KEY, self::SCHEMA_VERSION);
}
}
@@ -0,0 +1,219 @@
<?php
/**
* AKSRT Docs Presenter
*
* Builds document tree structures, breadcrumb chains,
* and chapter navigation for the docs mode.
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_Docs_Presenter {
private static $instance = null;
private function __construct() {}
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Get the document root page ID for a given page.
*
* @param int $page_id Current page ID.
* @return int Root page ID (self if top-level, parent otherwise).
*/
public function get_root_id(int $page_id): int {
$parent_id = wp_get_post_parent_id($page_id);
return $parent_id ?: $page_id;
}
/**
* Build a flat ordered list of all pages under a document root.
*
* @param int $root_id Root page ID.
* @param int $max_depth Maximum depth (default 4).
* @return array Array of page objects with added '_depth' property.
*/
public function get_flat_tree(int $root_id, int $max_depth = 4): array {
$result = [];
$this->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 {
return false;
}
}
@@ -0,0 +1,391 @@
<?php
/**
* AKSRT Front Submit — 前台投稿系统骨架
*
* 提供前台投稿、草稿保存、编辑回退能力。
* 当前为骨架实现,后续 Phase 补充完整投稿产品线。
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_Front_Submit {
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) self::$instance = new self();
return self::$instance;
}
public function init(): void {
add_action('wp_ajax_aksrt_submit_post', [$this, 'ajax_submit_post']);
add_action('wp_ajax_aksrt_save_draft', [$this, 'ajax_save_draft']);
add_action('wp_ajax_aksrt_update_post', [$this, 'ajax_update_post']);
add_action('wp_ajax_aksrt_delete_draft', [$this, 'ajax_delete_draft']);
}
/**
* 检查用户是否可以投稿
*
* @param int|null $user_id User ID (null = current user).
* @return bool
*/
public function can_submit(?int $user_id = null): bool {
if ($user_id === null) {
$user_id = get_current_user_id();
}
if ($user_id <= 0) return false;
// Read submission settings
$submit_settings = [];
if (class_exists('AKSRT_Settings')) {
$submit_settings = AKSRT_Settings::get_value('post_list.submit', []);
}
// Check if front submission is enabled (fix: was checking !empty && empty which is always false)
if (isset($submit_settings['submit_enabled']) && !$submit_settings['submit_enabled']) {
return false;
}
// Role whitelist: if configured, user must have one of the allowed roles
$allowed_roles = $submit_settings['submit_roles'] ?? [];
if (!empty($allowed_roles)) {
$user = get_userdata($user_id);
if (!$user) return false;
$user_roles = $user->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);
}
}
}
}
@@ -0,0 +1,249 @@
<?php
/**
* AKSRT Go Redirect — External link redirect with auth confirmation
*
* Replaces external links in post content with /go/?url=... redirect links.
* Shows a confirmation page before redirecting (anti-phishing).
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_Go_Redirect {
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, 'register_rewrite']);
add_action('template_redirect', [$this, 'handle_go_page']);
add_filter('the_content', [$this, 'replace_external_links'], 99);
}
/**
* Register rewrite rule for /go/ endpoint
*/
public function register_rewrite(): void {
add_rewrite_rule('^go/?$', 'index.php?aksrt_go_redirect=1', 'top');
add_rewrite_tag('%aksrt_go_redirect%', '([0-1])');
}
/**
* Check if go redirect feature is enabled
*/
private function is_enabled(): bool {
if (!class_exists('AKSRT_Settings')) return false;
// schema key = seo_go_link; runtime alias = go_redirect_enabled
return (bool) (
AKSRT_Settings::get_value('go_redirect_enabled', false)
?: AKSRT_Settings::get_value('seo_go_link', false)
);
}
/**
* Get excluded domains list
*/
private function get_excluded_domains(): array {
if (!class_exists('AKSRT_Settings')) return [];
// schema key = seo_go_link_exclude; runtime alias = go_redirect_exclude_domains
$domains = AKSRT_Settings::get_value('go_redirect_exclude_domains', '')
?: AKSRT_Settings::get_value('seo_go_link_exclude', '');
if (empty($domains)) return [];
return array_filter(array_map('trim', explode("\n", $domains)));
}
/**
* Check if URL is external
*/
private function is_external(string $url): bool {
$site_host = wp_parse_url(home_url('/'), PHP_URL_HOST);
$url_host = wp_parse_url($url, PHP_URL_HOST);
if (!$url_host || !$site_host) return false;
// Same domain
if ($url_host === $site_host) return false;
// Check excluded domains
$excluded = $this->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();
?>
<!DOCTYPE html>
<html <?php language_attributes(); ?>>
<head>
<meta charset="<?php bloginfo('charset'); ?>">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?php esc_html_e('外部链接跳转', 'aksrt'); ?></title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f5f6f7; margin: 0; padding: 0; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.go-card { background: #fff; border-radius: 12px; padding: 40px; max-width: 500px; width: 90%; box-shadow: 0 4px 20px rgba(0,0,0,0.08); text-align: center; }
.go-card h1 { font-size: 20px; margin: 0 0 12px; color: #333; }
.go-card .go-domain { font-size: 16px; font-weight: 600; color: #f04494; margin-bottom: 8px; }
.go-card .go-url { font-size: 13px; color: #999; word-break: break-all; margin-bottom: 24px; }
.go-card .go-warn { font-size: 13px; color: #999; margin-bottom: 20px; }
.go-card .go-btns { display: flex; gap: 12px; justify-content: center; }
.go-card .go-btn { padding: 10px 28px; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; border: none; transition: all 0.2s; text-decoration: none; }
.go-card .go-btn--primary { background: #f04494; color: #fff; }
.go-card .go-btn--primary:hover { background: #e0307e; }
.go-card .go-btn--ghost { background: #f5f6f7; color: #333; border: 1px solid #ddd; }
.go-card .go-btn--ghost:hover { background: #eee; }
</style>
</head>
<body>
<div class="go-card">
<h1><?php esc_html_e('即将离开本站', 'aksrt'); ?></h1>
<div class="go-domain"><?php echo $safe_protocol; ?>://<?php echo $safe_domain; ?></div>
<div class="go-url"><?php echo $safe_url; ?></div>
<div class="go-warn"><?php esc_html_e('请注意:外部链接内容不受本站控制,请谨慎访问。', 'aksrt'); ?></div>
<div class="go-btns">
<a href="<?php echo $safe_url; ?>" class="go-btn go-btn--primary" rel="nofollow noopener noreferrer" target="_blank"><?php esc_html_e('继续访问', 'aksrt'); ?></a>
<a href="javascript:history.back()" class="go-btn go-btn--ghost"><?php esc_html_e('返回', 'aksrt'); ?></a>
</div>
</div>
</body>
</html>
<?php
exit;
}
/**
* Replace external links in post content with go links
*/
public function replace_external_links(string $content): string {
if (!$this->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(
'/<a\s+([^>]*?)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 '<a ' . $before . 'href="' . esc_url($go_url) . '"' . $after . ' rel="nofollow noopener noreferrer">';
},
$content
);
}
}
@@ -0,0 +1,233 @@
<?php
/**
* AKSRT Home Modules
*
* CMS 形态首页模块系统:
* - 注册可用模块清单(9 类)
* - 渲染调度:按用户编排顺序依次 get_template_part
* - 预览会话:?aksrt_preview={id} → 读 transient 中的草稿模块
*
* 模块类型:
* hero / banner / category / tabs / ranking
* recent / tag_cloud / friend_links / html
*
* 默认模板:3 套预设(resource_standard / news / mixed)。
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_Home_Modules {
const PREVIEW_QUERY_VAR = 'aksrt_preview';
const PREVIEW_TRANSIENT_PREFIX = 'aksrt_home_preview_';
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_filter('query_vars', [$this, 'add_query_var']);
add_action('rest_api_init', [$this, 'register_routes']);
}
public function add_query_var(array $vars): array {
$vars[] = self::PREVIEW_QUERY_VAR;
return $vars;
}
public function register_routes(): void {
register_rest_route('aksrt/v1', '/preview/homepage', [
'methods' => '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 '<div class="home-modules">';
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 '</div>';
}
/**
* 全屏模块类型(不受侧栏约束)。
*/
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 '<div class="' . esc_attr($wrapper_class) . '">';
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 '</div>';
}
/**
* 创建预览会话。
*/
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' => ['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' => ['count' => 8, 'layout' => 'grid-cms']],
['type' => 'ranking', 'config' => ['metric' => 'downloads', 'count' => 6]],
];
}
}
}
@@ -0,0 +1,866 @@
<?php
/**
* AKSRT Post Meta Box
*
* 为文章(post)添加局部配置字段:
* - 附加封面图组(用于列表多图模式)
* - 自定义摘要
* - 列表布局覆盖
* - 文章头图模式
* - 目录树开关
* - 版权声明覆盖
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_Post_Meta_Box {
private static $instance = null;
const META_KEY = AKSRT_Domain_Model::POST_META_KEY;
public static function get_instance(): self {
if (null === self::$instance) self::$instance = new self();
return self::$instance;
}
public function init(): void {
add_action('add_meta_boxes', [$this, 'register_meta_box']);
add_action('save_post', [$this, 'save_meta_box']);
// §3.8 Quick / Bulk edit
add_filter('manage_post_posts_columns', [$this, 'add_columns']);
add_action('manage_post_posts_custom_column', [$this, 'render_column'], 10, 2);
add_action('quick_edit_custom_box', [$this, 'render_quick_edit'], 10, 2);
add_action('bulk_edit_custom_box', [$this, 'render_quick_edit'], 10, 2);
add_action('admin_footer-edit.php', [$this, 'admin_quick_edit_script']);
// Pay-type dropdown on posts list (zibll zibpay_type filter).
add_action('restrict_manage_posts', [$this, 'render_pay_type_filter']);
add_action('pre_get_posts', [$this, 'filter_posts_by_pay_type']);
}
/**
* Get post meta with defaults.
*/
public static function get_meta(int $post_id): array {
$defaults = AKSRT_Domain_Model::default_post_meta();
$saved = get_post_meta($post_id, self::META_KEY, true);
if (!is_array($saved)) {
$saved = [];
}
$meta = array_merge($defaults, $saved);
// New route only: episodes is always a list of {url,title}.
$meta['featured_video_episode'] = self::sanitize_video_episodes($meta['featured_video_episode'] ?? []);
if (!in_array((string) ($meta['show_toc'] ?? ''), ['', '0', '1'], true)) {
$meta['show_toc'] = '';
}
return $meta;
}
/**
* Sanitize video episode list (new route: array of url/title rows).
*
* @param mixed $raw Raw form / meta value.
* @return array<int, array{url:string,title:string}>
*/
public static function sanitize_video_episodes($raw): array {
if (!is_array($raw)) {
return [];
}
$out = [];
foreach ($raw as $row) {
if (!is_array($row)) {
continue;
}
$url = esc_url_raw((string) ($row['url'] ?? ''));
if ($url === '') {
continue;
}
$out[] = [
'url' => $url,
'title' => sanitize_text_field((string) ($row['title'] ?? '')),
];
if (count($out) >= 50) {
break;
}
}
return $out;
}
/**
* Get a single post meta value.
*/
public static function get_meta_value(int $post_id, string $key, $default = '') {
$meta = self::get_meta($post_id);
return $meta[$key] ?? $default;
}
/**
* Register the meta box.
*/
public function register_meta_box(): void {
add_meta_box(
'aksrt_post_meta',
__('AKSRT 文章设置', 'aksrt'),
[$this, 'render_meta_box'],
'post',
'side',
'default'
);
add_meta_box(
'aksrt_page_meta',
__('AKSRT 页面设置', 'aksrt'),
[$this, 'render_page_meta_box'],
'page',
'side',
'default'
);
// §3.8 custom filter metabox (side)
$filter_settings = class_exists('AKSRT_Settings')
? AKSRT_Settings::get_post_filter_settings()
: ['custom_filter' => []];
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');
?>
<p><strong><?php esc_html_e('页面标题样式覆盖', 'aksrt'); ?></strong></p>
<select name="page_header_style" class="widefat">
<option value="" <?php selected($header_style, ''); ?>><?php esc_html_e('跟随全局', 'aksrt'); ?></option>
<option value="not" <?php selected($header_style, 'not'); ?>><?php esc_html_e('不显示', 'aksrt'); ?></option>
<option value="1" <?php selected($header_style, '1'); ?>><?php esc_html_e('简单', 'aksrt'); ?></option>
<option value="2" <?php selected($header_style, '2'); ?>><?php esc_html_e('卡片', 'aksrt'); ?></option>
<option value="3" <?php selected($header_style, '3'); ?>><?php esc_html_e('封面', 'aksrt'); ?></option>
</select>
<small class="description"><?php esc_html_e('覆盖全局「其它页面 → 统一页头」设置。', 'aksrt'); ?></small>
<p><strong><?php esc_html_e('页面封面图覆盖', 'aksrt'); ?></strong></p>
<input type="text" name="page_header_cover_img" value="<?php echo esc_attr($cover_img); ?>" class="widefat" placeholder="<?php esc_attr_e('图片 URL', 'aksrt'); ?>">
<small class="description"><?php esc_html_e('覆盖全局默认封面图,仅封面样式(样式3)生效。', 'aksrt'); ?></small>
<?php
}
/**
* Render the meta box.
*/
public function render_meta_box(WP_Post $post): void {
$meta = self::get_meta($post->ID);
wp_nonce_field('aksrt_post_meta', 'aksrt_post_meta_nonce');
?>
<p><strong><?php esc_html_e('附加封面图', 'aksrt'); ?></strong></p>
<p>
<input type="text" name="aksrt_post_meta[cover_images]" value="<?php echo esc_attr($meta['cover_images']); ?>" class="widefat" placeholder="<?php esc_attr_e('附件ID,逗号分隔', 'aksrt'); ?>">
<small class="description"><?php esc_html_e('用于列表多图模式,填写附件 ID(逗号分隔)。', 'aksrt'); ?></small>
</p>
<p><strong><?php esc_html_e('列表布局覆盖', 'aksrt'); ?></strong></p>
<select name="aksrt_post_meta[layout_override]" class="widefat">
<option value="" <?php selected($meta['layout_override'], ''); ?>><?php esc_html_e('跟随全局', 'aksrt'); ?></option>
<option value="card" <?php selected($meta['layout_override'], 'card'); ?>><?php esc_html_e('卡片', 'aksrt'); ?></option>
<option value="list" <?php selected($meta['layout_override'], 'list'); ?>><?php esc_html_e('列表', 'aksrt'); ?></option>
<option value="mini" <?php selected($meta['layout_override'], 'mini'); ?>><?php esc_html_e('迷你', 'aksrt'); ?></option>
</select>
<p><strong><?php esc_html_e('侧栏布局(Zibll show_layout)', 'aksrt'); ?></strong></p>
<?php
$show_layout = (string) get_post_meta($post->ID, '_aksrt_show_layout', true);
if (!in_array($show_layout, ['no_sidebar', 'sidebar_left', 'sidebar_right'], true)) {
$show_layout = '';
}
?>
<select name="aksrt_show_layout" class="widefat">
<option value="" <?php selected($show_layout, ''); ?>><?php esc_html_e('跟随全局', 'aksrt'); ?></option>
<option value="no_sidebar" <?php selected($show_layout, 'no_sidebar'); ?>><?php esc_html_e('无侧栏', 'aksrt'); ?></option>
<option value="sidebar_right" <?php selected($show_layout, 'sidebar_right'); ?>><?php esc_html_e('右侧栏', 'aksrt'); ?></option>
<option value="sidebar_left" <?php selected($show_layout, 'sidebar_left'); ?>><?php esc_html_e('左侧栏', 'aksrt'); ?></option>
</select>
<p class="description"><?php esc_html_e('覆盖全局侧栏开关与左右位置,仅影响本文/本页。', 'aksrt'); ?></p>
<p><strong><?php esc_html_e('头图模式', 'aksrt'); ?></strong></p>
<select name="aksrt_post_meta[header_image_mode]" class="widefat">
<option value="" <?php selected($meta['header_image_mode'], ''); ?>><?php esc_html_e('跟随全局', 'aksrt'); ?></option>
<option value="featured" <?php selected($meta['header_image_mode'], 'featured'); ?>><?php esc_html_e('特色图片', 'aksrt'); ?></option>
<option value="auto" <?php selected($meta['header_image_mode'], 'auto'); ?>><?php esc_html_e('自动(内容首图)', 'aksrt'); ?></option>
<option value="none" <?php selected($meta['header_image_mode'], 'none'); ?>><?php esc_html_e('不显示', 'aksrt'); ?></option>
</select>
<p>
<label for="aksrt_show_toc"><strong><?php esc_html_e('目录树', 'aksrt'); ?></strong></label>
<select name="aksrt_post_meta[show_toc]" id="aksrt_show_toc" class="widefat">
<option value="" <?php selected((string) ($meta['show_toc'] ?? ''), ''); ?>><?php esc_html_e('跟随全局', 'aksrt'); ?></option>
<option value="1" <?php selected((string) ($meta['show_toc'] ?? ''), '1'); ?>><?php esc_html_e('强制开启', 'aksrt'); ?></option>
<option value="0" <?php selected((string) ($meta['show_toc'] ?? ''), '0'); ?>><?php esc_html_e('强制关闭', 'aksrt'); ?></option>
</select>
</p>
<p><strong><?php esc_html_e('单篇交互覆盖', 'aksrt'); ?></strong></p>
<?php
$toggle_fields = [
'show_reading_progress' => __('阅读进度条', '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) :
?>
<p>
<label for="aksrt_post_meta_<?php echo esc_attr($field_key); ?>" class="screen-reader-text"><?php echo esc_html($field_label); ?></label>
<select name="aksrt_post_meta[<?php echo esc_attr($field_key); ?>]" id="aksrt_post_meta_<?php echo esc_attr($field_key); ?>" class="widefat">
<option value="" <?php selected($meta[$field_key], ''); ?>><?php esc_html_e('跟随全局', 'aksrt'); ?></option>
<option value="1" <?php selected($meta[$field_key], '1'); ?>><?php esc_html_e('强制开启', 'aksrt'); ?></option>
<option value="0" <?php selected($meta[$field_key], '0'); ?>><?php esc_html_e('强制关闭', 'aksrt'); ?></option>
</select>
<small class="description"><?php echo esc_html($field_label); ?></small>
</p>
<?php endforeach; ?>
<p><strong><?php esc_html_e('相关推荐数量覆盖', 'aksrt'); ?></strong></p>
<p>
<input type="number" name="aksrt_post_meta[related_count]" value="<?php echo esc_attr((string) (int) $meta['related_count']); ?>" class="widefat" min="0" max="20" step="1">
<small class="description"><?php esc_html_e('0 表示跟随全局设置。', 'aksrt'); ?></small>
</p>
<p>
<label>
<input type="checkbox" name="aksrt_post_meta[is_featured]" value="1" <?php checked(!empty($meta['is_featured'])); ?>>
<?php esc_html_e('标记为推荐文章', 'aksrt'); ?>
</label>
</p>
<p><strong><?php esc_html_e('自定义摘要', 'aksrt'); ?></strong></p>
<textarea name="aksrt_post_meta[custom_excerpt]" rows="3" class="widefat" placeholder="<?php esc_attr_e('留空使用自动摘要', 'aksrt'); ?>"><?php echo esc_textarea($meta['custom_excerpt']); ?></textarea>
<p><strong><?php esc_html_e('版权声明覆盖', 'aksrt'); ?></strong></p>
<textarea name="aksrt_post_meta[copyright_text]" rows="3" class="widefat" placeholder="<?php esc_attr_e('留空使用默认版权文案', 'aksrt'); ?>"><?php echo esc_textarea($meta['copyright_text']); ?></textarea>
<hr>
<p><strong><?php esc_html_e('副标题', 'aksrt'); ?></strong></p>
<input type="text" name="aksrt_post_meta[subtitle]" value="<?php echo esc_attr($meta['subtitle']); ?>" class="widefat" placeholder="<?php esc_attr_e('可选,显示在标题下方', 'aksrt'); ?>">
<p><strong><?php esc_html_e('外链特色图', 'aksrt'); ?></strong></p>
<input type="url" name="aksrt_post_meta[thumbnail_url]" value="<?php echo esc_url($meta['thumbnail_url']); ?>" class="widefat" placeholder="<?php esc_attr_e('外部图片 URL(覆盖特色图片)', 'aksrt'); ?>">
<small class="description"><?php esc_html_e('填入外部图片 URL 将替代特色图片。', 'aksrt'); ?></small>
<p><strong><?php esc_html_e('幻灯片封面', 'aksrt'); ?></strong></p>
<input type="text" name="aksrt_post_meta[featured_slide]" value="<?php echo esc_attr($meta['featured_slide']); ?>" class="widefat" placeholder="<?php esc_attr_e('图片 URL,逗号分隔', 'aksrt'); ?>">
<small class="description"><?php esc_html_e('多张图片 URL 用逗号分隔,用于封面轮播。', 'aksrt'); ?></small>
<p><strong><?php esc_html_e('特色视频', 'aksrt'); ?></strong></p>
<input type="url" name="aksrt_post_meta[featured_video]" value="<?php echo esc_url($meta['featured_video']); ?>" class="widefat" placeholder="<?php esc_attr_e('第 1 集视频 URL(直链)', 'aksrt'); ?>">
<p><strong><?php esc_html_e('视频剧集列表', 'aksrt'); ?></strong></p>
<?php
$episodes = is_array($meta['featured_video_episode'] ?? null) ? $meta['featured_video_episode'] : [];
// Always show at least 3 editable rows for new-route authoring.
$rows = max(3, count($episodes));
for ($i = 0; $i < $rows; $i++) :
$ep = is_array($episodes[$i] ?? null) ? $episodes[$i] : [];
$ep_url = (string) ($ep['url'] ?? '');
$ep_title = (string) ($ep['title'] ?? '');
?>
<p class="aksrt-episode-row">
<input type="text" name="aksrt_post_meta[featured_video_episode][<?php echo (int) $i; ?>][title]" value="<?php echo esc_attr($ep_title); ?>" class="widefat" placeholder="<?php echo esc_attr(sprintf(__('第 %d 集标题(可选)', 'aksrt'), $i + 1)); ?>" style="margin-bottom:4px;">
<input type="url" name="aksrt_post_meta[featured_video_episode][<?php echo (int) $i; ?>][url]" value="<?php echo esc_url($ep_url); ?>" class="widefat" placeholder="<?php echo esc_attr(sprintf(__('第 %d 集视频 URL', 'aksrt'), $i + 1)); ?>">
</p>
<?php endfor; ?>
<small class="description"><?php esc_html_e('新路线:每行一集(标题 + URL)。留空 URL 的行会被忽略。第 1 集可与上方「特色视频」二选一填写。', 'aksrt'); ?></small>
<hr>
<p><strong><?php esc_html_e('手动计数覆盖', 'aksrt'); ?></strong></p>
<p>
<label><?php esc_html_e('阅读量', 'aksrt'); ?></label>
<input type="number" name="aksrt_post_meta[views]" value="<?php echo esc_attr((string) (int) $meta['views']); ?>" class="widefat" min="0" step="1">
</p>
<p>
<label><?php esc_html_e('点赞数', 'aksrt'); ?></label>
<input type="number" name="aksrt_post_meta[likes]" value="<?php echo esc_attr((string) (int) $meta['likes']); ?>" class="widefat" min="0" step="1">
</p>
<small class="description"><?php esc_html_e('0 表示不覆盖,使用系统统计值。', 'aksrt'); ?></small>
<?php
}
/**
* Render the custom filter metabox (per-dimension value selectors).
* §3.8 — stores values as individual post meta keys (_aksrt_cf_{dim_key}).
*/
public function render_custom_filter_meta_box(WP_Post $post): void {
wp_nonce_field('aksrt_custom_filter_meta', 'aksrt_custom_filter_meta_nonce');
$filter_settings = class_exists('AKSRT_Settings')
? AKSRT_Settings::get_post_filter_settings()
: ['custom_filter' => []];
$dimensions = $filter_settings['custom_filter'];
if (empty($dimensions)) {
echo '<p class="description">' . esc_html__('尚未定义筛选维度,请前往 文章设置 → 高级筛选 添加。', 'aksrt') . '</p>';
return;
}
foreach ($dimensions as $dim) :
$meta_key = '_aksrt_cf_' . $dim['key'];
$current = get_post_meta($post->ID, $meta_key, true);
?>
<p>
<label for="<?php echo esc_attr($meta_key); ?>"><strong><?php echo esc_html($dim['name']); ?></strong></label>
<select name="<?php echo esc_attr($meta_key); ?>" id="<?php echo esc_attr($meta_key); ?>" class="widefat">
<option value=""><?php esc_html_e('未选择', 'aksrt'); ?></option>
<?php foreach ($dim['vals'] as $val) : ?>
<option value="<?php echo esc_attr($val['key']); ?>" <?php selected($current, $val['key']); ?>><?php echo esc_html($val['name']); ?></option>
<?php endforeach; ?>
</select>
</p>
<?php
endforeach;
}
/**
* Save meta box data.
*/
public function save_meta_box(int $post_id): void {
// Save page meta (page_header_style / page_header_cover_img)
if (isset($_POST['aksrt_page_meta_nonce']) && wp_verify_nonce($_POST['aksrt_page_meta_nonce'], 'aksrt_page_meta')) {
if (!defined('DOING_AUTOSAVE') && current_user_can('edit_post', $post_id)) {
$style = isset($_POST['page_header_style']) ? sanitize_key((string) $_POST['page_header_style']) : '';
if (!in_array($style, ['', 'not', '1', '2', '3'], true)) {
$style = '';
}
if ($style !== '') {
update_post_meta($post_id, 'page_header_style', $style);
} else {
delete_post_meta($post_id, 'page_header_style');
}
$cover = isset($_POST['page_header_cover_img']) ? esc_url_raw((string) $_POST['page_header_cover_img']) : '';
if ($cover !== '') {
update_post_meta($post_id, 'page_header_cover_img', $cover);
} else {
delete_post_meta($post_id, 'page_header_cover_img');
}
}
}
// Save post meta (aksrt_post_meta group)
if (!isset($_POST['aksrt_post_meta_nonce'])) return;
if (!wp_verify_nonce($_POST['aksrt_post_meta_nonce'], 'aksrt_post_meta')) return;
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return;
if (!current_user_can('edit_post', $post_id)) return;
// Zibll show_layout (independent of list layout_override).
$show_layout = isset($_POST['aksrt_show_layout']) ? sanitize_key((string) wp_unslash($_POST['aksrt_show_layout'])) : '';
if (!in_array($show_layout, ['no_sidebar', 'sidebar_left', 'sidebar_right'], true)) {
$show_layout = '';
}
if ($show_layout !== '') {
update_post_meta($post_id, '_aksrt_show_layout', $show_layout);
} else {
delete_post_meta($post_id, '_aksrt_show_layout');
}
// §3.8 Quick / Bulk edit — separate code path (merge mode)
if (!empty($_POST['aksrt_qe_flag'])) {
$this->save_quick_edit($post_id);
// Bulk pay fields may be submitted together with QE flag on bulk form.
$this->save_bulk_pay_fields($post_id);
return;
}
// Bulk-only pay fields without aksrt_qe_flag (when only pay row used).
if (!empty($_POST['aksrt_bulk_pay_flag'])) {
$this->save_bulk_pay_fields($post_id);
// Continue only if full meta also posted; otherwise done.
if (!isset($_POST['aksrt_post_meta'])) {
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']) : '',
// New route: '' follow | '1' force on | '0' force off.
'show_toc' => isset($raw['show_toc']) && in_array((string) $raw['show_toc'], ['', '0', '1'], true) ? (string) $raw['show_toc'] : '',
'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' => self::sanitize_video_episodes($raw['featured_video_episode'] ?? []),
'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');
$columns['aksrt_sales'] = __('销售数据', '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 '<span class="aksrt-col-data" data-views="' . (int) ($meta['views'] ?? 0) . '" data-likes="' . $likes . '" data-layout="' . $layout . '" data-toc="' . $toc . '" data-maxheight="' . $mh . '">' . esc_html($likes > 0 ? (string) $likes : '—') . '</span>';
} elseif ($column === 'aksrt_sales') {
$this->render_sales_column($post_id);
}
}
/**
* Content-pay sales summary for posts list (zibll 销售数据 column parity).
*/
private function render_sales_column(int $post_id): void {
if (!function_exists('aksrt_get_pay_meta') || !class_exists('AKSRT_Domain_Model')) {
echo '—';
return;
}
$pay = aksrt_get_pay_meta($post_id);
$pay_type = AKSRT_Domain_Model::normalize_pay_type((string) ($pay['pay_type'] ?? AKSRT_Domain_Model::PAY_TYPE_NO));
if ($pay_type === AKSRT_Domain_Model::PAY_TYPE_NO) {
echo '<span class="description">' . esc_html__('免费', 'aksrt') . '</span>';
return;
}
$labels = AKSRT_Domain_Model::pay_type_labels();
$type_label = $labels[$pay_type] ?? $pay_type;
$modo = (($pay['pay_modo'] ?? '0') === 'points') ? 'points' : '0';
if ($modo === 'points') {
$price_text = sprintf(__('%d 积分', 'aksrt'), (int) ceil((float) ($pay['points_price'] ?? $pay['price'] ?? 0)));
} else {
$price_text = '¥' . number_format((float) ($pay['price'] ?? 0), 2);
}
$meta_sales = (int) get_post_meta($post_id, AKSRT_Domain_Model::RESOURCE_SALES_VOLUME_KEY, true);
$db_sales = 0;
$revenue = 0.0;
if (class_exists('AKSRT_Order_Repository')) {
$map = AKSRT_Order_Repository::get_instance()->get_content_sales_map(
[$post_id],
AKSRT_Domain_Model::ORDER_PRODUCT_RESOURCE
);
if (isset($map[$post_id])) {
$db_sales = (int) ($map[$post_id]['sales_count'] ?? 0);
$revenue = (float) ($map[$post_id]['revenue'] ?? 0);
}
}
$orders_url = admin_url('admin.php?page=aksrt_orders&product_type=resource&product_id=' . $post_id);
$products_url = admin_url('admin.php?page=aksrt_pay_products&s=' . $post_id);
echo '<div style="font-size:12px;line-height:1.5;">';
echo '<strong style="color:#0c9cc8;">' . esc_html((string) $type_label) . '</strong>';
echo '<br>' . esc_html($price_text);
echo '<br>' . esc_html(sprintf(__('Meta销量 %d', 'aksrt'), max(0, $meta_sales)));
if ($db_sales > 0) {
printf(
'<br><a href="%s" title="%s">%s</a>',
esc_url($orders_url),
esc_attr__('查看销售明细', 'aksrt'),
esc_html(sprintf(__('真实销量 %d · ¥%s', 'aksrt'), $db_sales, number_format($revenue, 2)))
);
} else {
echo '<br><span class="description">' . esc_html__('暂无订单', 'aksrt') . '</span>';
}
echo '<br><a href="' . esc_url($products_url) . '">' . esc_html__('付费文章', 'aksrt') . '</a>';
if ($pay_type === AKSRT_Domain_Model::PAY_TYPE_DOWNLOAD) {
$dl_url = admin_url('admin.php?page=aksrt_download_logs&post_id=' . $post_id);
echo ' · <a href="' . esc_url($dl_url) . '">' . esc_html__('下载记录', 'aksrt') . '</a>';
}
echo '</div>';
}
/**
* Dropdown filter for content pay_type on edit.php?post_type=post.
*/
public function render_pay_type_filter(string $post_type): void {
if ($post_type !== 'post' || !class_exists('AKSRT_Domain_Model')) {
return;
}
$current = isset($_GET['aksrt_pay_type']) ? sanitize_key((string) wp_unslash($_GET['aksrt_pay_type'])) : '';
$labels = AKSRT_Domain_Model::pay_type_labels();
echo '<select name="aksrt_pay_type">';
echo '<option value="">' . esc_html__('全部付费类型', 'aksrt') . '</option>';
foreach ($labels as $key => $label) {
printf(
'<option value="%s"%s>%s</option>',
esc_attr($key),
selected($current, $key, false),
esc_html($label)
);
}
echo '</select>';
}
/**
* Apply aksrt_pay_type filter via denormalized _aksrt_pay_type meta.
*/
public function filter_posts_by_pay_type(\WP_Query $query): void {
if (!is_admin() || !$query->is_main_query()) {
return;
}
$screen_post_type = $query->get('post_type');
if ($screen_post_type !== 'post') {
return;
}
if (empty($_GET['aksrt_pay_type'])) {
return;
}
$pay_type = sanitize_key((string) wp_unslash($_GET['aksrt_pay_type']));
if ($pay_type === '' || !class_exists('AKSRT_Domain_Model')) {
return;
}
$pay_type = AKSRT_Domain_Model::normalize_pay_type($pay_type);
$meta_query = (array) $query->get('meta_query');
if ($pay_type === AKSRT_Domain_Model::PAY_TYPE_NO) {
$meta_query[] = [
'relation' => 'OR',
[
'key' => AKSRT_Domain_Model::PAY_TYPE_KEY,
'value' => AKSRT_Domain_Model::PAY_TYPE_NO,
'compare' => '=',
],
[
'key' => AKSRT_Domain_Model::PAY_TYPE_KEY,
'compare' => 'NOT EXISTS',
],
];
} else {
$meta_query[] = [
'key' => AKSRT_Domain_Model::PAY_TYPE_KEY,
'value' => $pay_type,
'compare' => '=',
];
}
$query->set('meta_query', $meta_query);
}
/**
* §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');
?>
<fieldset class="inline-edit-col-right" id="aksrt-quick-edit">
<div class="inline-edit-col">
<h4><?php esc_html_e('AKSRT 文章设置', 'aksrt'); ?></h4>
<input type="hidden" name="aksrt_qe_flag" value="1">
<label class="alignleft">
<span class="title"><?php esc_html_e('阅读量', 'aksrt'); ?></span>
<input type="number" name="aksrt_qe_views" min="0" step="1" value="" class="aksrt-qe-views">
</label>
<label class="alignleft">
<span class="title"><?php esc_html_e('点赞数', 'aksrt'); ?></span>
<input type="number" name="aksrt_qe_likes" min="0" step="1" value="" class="aksrt-qe-likes">
</label>
<label class="alignleft">
<span class="title"><?php esc_html_e('布局', 'aksrt'); ?></span>
<select name="aksrt_qe_layout" class="aksrt-qe-layout">
<option value="-1"><?php esc_html_e('— 不更改 —', 'aksrt'); ?></option>
<option value=""><?php esc_html_e('跟随全局', 'aksrt'); ?></option>
<option value="card"><?php esc_html_e('卡片', 'aksrt'); ?></option>
<option value="list"><?php esc_html_e('列表', 'aksrt'); ?></option>
<option value="mini"><?php esc_html_e('迷你', 'aksrt'); ?></option>
<option value="grid-cms"><?php esc_html_e('CMS网格', 'aksrt'); ?></option>
</select>
</label>
<label class="alignleft">
<span class="title"><?php esc_html_e('目录树', 'aksrt'); ?></span>
<select name="aksrt_qe_toc" class="aksrt-qe-toc">
<option value="-1"><?php esc_html_e('— 不更改 —', 'aksrt'); ?></option>
<option value=""><?php esc_html_e('跟随全局', 'aksrt'); ?></option>
<option value="1"><?php esc_html_e('显示', 'aksrt'); ?></option>
<option value="0"><?php esc_html_e('隐藏', 'aksrt'); ?></option>
</select>
</label>
<label class="alignleft">
<span class="title"><?php esc_html_e('限高(px)', 'aksrt'); ?></span>
<input type="number" name="aksrt_qe_maxheight" min="0" step="100" value="" class="aksrt-qe-maxheight" placeholder="<?php esc_attr_e('0=不限', 'aksrt'); ?>">
</label>
<br class="clear">
<h4 style="margin-top:10px;"><?php esc_html_e('内容付费(批量)', 'aksrt'); ?></h4>
<input type="hidden" name="aksrt_bulk_pay_flag" value="1">
<label class="alignleft">
<span class="title"><?php esc_html_e('付费类型', 'aksrt'); ?></span>
<select name="aksrt_bulk_pay_type" class="aksrt-bulk-pay-type">
<option value="-1"><?php esc_html_e('— 不更改 —', 'aksrt'); ?></option>
<?php
$pay_labels = class_exists('AKSRT_Domain_Model') ? AKSRT_Domain_Model::pay_type_labels() : [];
foreach ($pay_labels as $k => $lab) :
?>
<option value="<?php echo esc_attr($k); ?>"><?php echo esc_html($lab); ?></option>
<?php endforeach; ?>
</select>
</label>
<label class="alignleft">
<span class="title"><?php esc_html_e('支付方式', 'aksrt'); ?></span>
<select name="aksrt_bulk_pay_modo" class="aksrt-bulk-pay-modo">
<option value="-1"><?php esc_html_e('— 不更改 —', 'aksrt'); ?></option>
<option value="0"><?php esc_html_e('现金', 'aksrt'); ?></option>
<option value="points"><?php esc_html_e('积分', 'aksrt'); ?></option>
</select>
</label>
<label class="alignleft">
<span class="title"><?php esc_html_e('现金价', 'aksrt'); ?></span>
<input type="number" min="0" step="0.01" name="aksrt_bulk_pay_price" value="" class="aksrt-bulk-pay-price" placeholder="<?php esc_attr_e('留空=不改', 'aksrt'); ?>">
</label>
<label class="alignleft">
<span class="title"><?php esc_html_e('积分价', 'aksrt'); ?></span>
<input type="number" min="0" step="1" name="aksrt_bulk_points_price" value="" class="aksrt-bulk-points-price" placeholder="<?php esc_attr_e('留空=不改', 'aksrt'); ?>">
</label>
<label class="alignleft">
<span class="title"><?php esc_html_e('虚拟销量', 'aksrt'); ?></span>
<input type="number" min="0" step="1" name="aksrt_bulk_virtual_sales" value="" class="aksrt-bulk-virtual-sales" placeholder="<?php esc_attr_e('留空=不改', 'aksrt'); ?>">
</label>
<br class="clear">
</div>
</fieldset>
<?php
}
/**
* Bulk/quick merge update for content pay meta (_aksrt_pay + denormalized keys).
* Only non-sentinel fields are written; omit to keep existing.
*/
private function save_bulk_pay_fields(int $post_id): void {
if (!class_exists('AKSRT_Domain_Model') || !function_exists('aksrt_get_pay_meta')) {
return;
}
if (!current_user_can('edit_post', $post_id)) {
return;
}
$type_raw = isset($_POST['aksrt_bulk_pay_type']) ? (string) wp_unslash($_POST['aksrt_bulk_pay_type']) : '-1';
$modo_raw = isset($_POST['aksrt_bulk_pay_modo']) ? (string) wp_unslash($_POST['aksrt_bulk_pay_modo']) : '-1';
$price_raw = isset($_POST['aksrt_bulk_pay_price']) ? trim((string) wp_unslash($_POST['aksrt_bulk_pay_price'])) : '';
$points_raw = isset($_POST['aksrt_bulk_points_price']) ? trim((string) wp_unslash($_POST['aksrt_bulk_points_price'])) : '';
$virtual_raw = isset($_POST['aksrt_bulk_virtual_sales']) ? trim((string) wp_unslash($_POST['aksrt_bulk_virtual_sales'])) : '';
$has_change = ($type_raw !== '' && $type_raw !== '-1')
|| ($modo_raw !== '' && $modo_raw !== '-1')
|| $price_raw !== ''
|| $points_raw !== ''
|| $virtual_raw !== '';
if (!$has_change) {
return;
}
$meta = aksrt_get_pay_meta($post_id);
if ($type_raw !== '' && $type_raw !== '-1') {
$meta['pay_type'] = AKSRT_Domain_Model::normalize_pay_type(sanitize_key($type_raw));
}
if ($modo_raw !== '' && $modo_raw !== '-1') {
$modo = sanitize_key($modo_raw);
$meta['pay_modo'] = ($modo === 'points') ? 'points' : '0';
}
if ($price_raw !== '' && is_numeric($price_raw)) {
$meta['price'] = max(0, (float) $price_raw);
}
if ($points_raw !== '' && is_numeric($points_raw)) {
$meta['points_price'] = max(0, (int) $points_raw);
}
if ($virtual_raw !== '' && is_numeric($virtual_raw)) {
$meta['virtual_sales'] = max(0, (int) $virtual_raw);
}
$meta = AKSRT_Domain_Model::sanitize_pay_meta($meta);
update_post_meta($post_id, AKSRT_Domain_Model::PAY_META_KEY, $meta);
update_post_meta($post_id, AKSRT_Domain_Model::PAY_TYPE_KEY, (string) ($meta['pay_type'] ?? AKSRT_Domain_Model::PAY_TYPE_NO));
update_post_meta($post_id, AKSRT_Domain_Model::PAY_PRICE_KEY, (float) ($meta['price'] ?? 0));
if (class_exists('AKSRT_Domain_Model') && method_exists('AKSRT_Domain_Model', 'is_resource_pay_type')) {
update_post_meta(
$post_id,
AKSRT_Domain_Model::RESOURCE_MARKER_KEY,
AKSRT_Domain_Model::is_resource_pay_type((string) ($meta['pay_type'] ?? 'no')) ? 1 : 0
);
}
}
/**
* §3.8 Quick / Bulk edit merge-mode save.
* Only updates fields that were actually submitted (non-sentinel, non-empty).
* Existing meta values for omitted fields are preserved.
*/
private function save_quick_edit(int $post_id): void {
$existing = self::get_meta($post_id);
$views = isset($_POST['aksrt_qe_views']) && $_POST['aksrt_qe_views'] !== ''
? max(0, (int) $_POST['aksrt_qe_views'])
: (int) ($existing['views'] ?? 0);
$likes = isset($_POST['aksrt_qe_likes']) && $_POST['aksrt_qe_likes'] !== ''
? max(0, (int) $_POST['aksrt_qe_likes'])
: (int) ($existing['likes'] ?? 0);
$layout = $existing['layout_override'] ?? '';
if (isset($_POST['aksrt_qe_layout']) && $_POST['aksrt_qe_layout'] !== '-1') {
$layout = sanitize_key((string) $_POST['aksrt_qe_layout']);
if (!in_array($layout, ['', 'card', 'list', 'mini', 'grid-cms'], true)) {
$layout = $existing['layout_override'] ?? '';
}
}
$toc = $existing['show_toc'] ?? '';
if (isset($_POST['aksrt_qe_toc']) && $_POST['aksrt_qe_toc'] !== '-1') {
$toc_v = sanitize_key((string) $_POST['aksrt_qe_toc']);
$toc = in_array($toc_v, ['', '0', '1'], true) ? $toc_v : ($existing['show_toc'] ?? '');
}
$mh = isset($_POST['aksrt_qe_maxheight']) && $_POST['aksrt_qe_maxheight'] !== ''
? max(0, (int) $_POST['aksrt_qe_maxheight'])
: (int) ($existing['maxheight'] ?? 0);
$existing['views'] = $views;
$existing['likes'] = $likes;
$existing['layout_override'] = $layout;
$existing['show_toc'] = $toc;
$existing['maxheight'] = $mh;
update_post_meta($post_id, self::META_KEY, $existing);
}
/**
* §3.8 Output inline JS on edit.php to populate quick edit fields from column data attributes.
*/
public function admin_quick_edit_script(): void {
$screen = function_exists('get_current_screen') ? get_current_screen() : null;
if (!$screen || $screen->base !== 'edit' || $screen->post_type !== 'post') {
return;
}
?>
<script>
(function () {
var wp_inline_edit = window.inlineEditPost ? window.inlineEditPost.edit : null;
if (!wp_inline_edit) return;
window.inlineEditPost.edit = function (id) {
wp_inline_edit.apply(this, arguments);
var postId = 0;
if (typeof id === 'object') {
postId = parseInt(this.getId(id), 10);
}
if (!postId) return;
var row = document.getElementById('post-' + postId);
if (!row) return;
var viewsEl = row.querySelector('.column-aksrt_views');
var likesEl = row.querySelector('.column-aksrt_likes');
var data = (likesEl && likesEl.querySelector('.aksrt-col-data'))
|| (viewsEl && viewsEl.querySelector('.aksrt-col-data'));
if (!data) return;
var form = document.getElementById('edit-' + postId);
if (!form) return;
var v = data.getAttribute('data-views') || '';
var l = data.getAttribute('data-likes') || '';
var layout = data.getAttribute('data-layout') || '';
var toc = data.getAttribute('data-toc') || '';
var mh = data.getAttribute('data-maxheight') || '';
var qv = form.querySelector('.aksrt-qe-views');
var ql = form.querySelector('.aksrt-qe-likes');
if (qv) qv.value = v;
if (ql) ql.value = l;
var qLayout = form.querySelector('.aksrt-qe-layout');
if (qLayout) qLayout.value = layout;
var qToc = form.querySelector('.aksrt-qe-toc');
if (qToc) qToc.value = toc;
var qMh = form.querySelector('.aksrt-qe-maxheight');
if (qMh) qMh.value = mh;
};
})();
</script>
<?php
}
}
@@ -0,0 +1,563 @@
<?php
/**
* AKSRT Pay Meta Box — 统一付费内容设置
*
* 模仿 zibll 的 posts_zibpay 机制:
* - 任何 post 都可以通过设置付费类型成为付费文章
* - pay_type 驱动行为(reading/download/gallery/video/vip/no)
* - 统一存储为 _aksrt_pay 序列化数组
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_Resource_Meta_Box {
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('add_meta_boxes', [$this, 'register_meta_box']);
add_action('save_post_post', [$this, 'save'], 10, 2);
}
public function register_meta_box(): void {
add_meta_box(
'aksrt_pay_meta',
__('付费内容设置', 'aksrt'),
[$this, 'render'],
'post',
'normal',
'high'
);
}
public function render(WP_Post $post): void {
wp_nonce_field('aksrt_pay_meta_save', 'aksrt_pay_meta_nonce');
$meta = function_exists('aksrt_get_pay_meta')
? aksrt_get_pay_meta($post)
: AKSRT_Domain_Model::default_pay_meta();
$pay_types = aksrt_get_pay_types();
$is_paid = ($meta['pay_type'] ?? 'no') !== 'no';
$downloads_json = !empty($meta['downloads'])
? wp_json_encode($meta['downloads'], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
: '';
$attributes_json = !empty($meta['attributes'])
? wp_json_encode($meta['attributes'], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
: '';
$gallery_json = !empty($meta['gallery'])
? wp_json_encode($meta['gallery'], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)
: '';
?>
<style>
.aksrt-pmb__row { display: grid; grid-template-columns: 140px 1fr; gap: 12px 16px; align-items: start; margin-bottom: 12px; }
.aksrt-pmb__row label { font-weight: 600; color: #1f2937; padding-top: 6px; font-size: 13px; }
.aksrt-pmb__row input[type="text"], .aksrt-pmb__row input[type="number"], .aksrt-pmb__row input[type="url"], .aksrt-pmb__row textarea, .aksrt-pmb__row select { width: 100%; max-width: 560px; }
.aksrt-pmb__row textarea { min-height: 120px; font-family: Consolas, "Liberation Mono", monospace; font-size: 12px; }
.aksrt-pmb__hint { color: #6b7280; font-size: 12px; margin-top: 4px; line-height: 1.5; }
.aksrt-pmb__section { margin-top: 16px; padding-top: 16px; border-top: 1px solid #e5e7eb; }
.aksrt-pmb__section-title { font-size: 14px; font-weight: 700; color: #111827; margin-bottom: 12px; }
.aksrt-pmb__toggle { display: flex; align-items: center; gap: 8px; padding: 12px; background: #f3f4f6; border-radius: 6px; margin-bottom: 16px; }
.aksrt-pmb__toggle label { font-weight: 600; }
.aksrt-pmb__vip-row { display: flex; gap: 12px; flex-wrap: wrap; }
.aksrt-pmb__vip-row > div { flex: 1; min-width: 120px; }
.aksrt-pmb__vip-row label { font-weight: 400; font-size: 12px; color: #6b7280; }
.aksrt-pmb__tools { margin-top: 6px; display: flex; gap: 6px; flex-wrap: wrap; }
.aksrt-pmb__tools button { font-size: 12px; }
.aksrt-pmb__error { color: #dc2626; font-size: 12px; display: none; margin-top: 4px; }
.aksrt-pmb__fields[data-disabled="1"] { opacity: 0.45; pointer-events: none; }
</style>
<div class="aksrt-pmb__toggle">
<label for="aksrt_pay_type"><?php esc_html_e('付费类型', 'aksrt'); ?></label>
<select id="aksrt_pay_type" name="aksrt_pay_type" style="min-width:160px;">
<?php foreach ($pay_types as $key => $label) : ?>
<option value="<?php echo esc_attr($key); ?>" <?php selected($meta['pay_type'], $key); ?>>
<?php echo esc_html($label); ?>
</option>
<?php endforeach; ?>
</select>
<span class="aksrt-pmb__hint">
<?php esc_html_e('选择「不付费」则为普通文章;选择其他类型将启用对应的付费功能。', 'aksrt'); ?>
</span>
</div>
<div class="aksrt-pmb__fields" id="aksrt-pmb-fields" data-disabled="<?php echo $is_paid ? '0' : '1'; ?>">
<!-- 基础价格 -->
<div class="aksrt-pmb__section">
<div class="aksrt-pmb__section-title"><?php esc_html_e('价格设置', 'aksrt'); ?></div>
<div class="aksrt-pmb__row">
<label for="aksrt_pay_modo"><?php esc_html_e('支付方式', 'aksrt'); ?></label>
<div>
<select id="aksrt_pay_modo" name="aksrt_pay_modo" style="min-width:160px;">
<option value="0" <?php selected((string) ($meta['pay_modo'] ?? '0'), '0'); ?>><?php esc_html_e('现金', 'aksrt'); ?></option>
<option value="points" <?php selected((string) ($meta['pay_modo'] ?? '0'), 'points'); ?>><?php esc_html_e('积分', 'aksrt'); ?></option>
</select>
<p class="aksrt-pmb__hint"><?php esc_html_e('对齐 zibll pay_modo:现金价或积分价结算。', 'aksrt'); ?></p>
</div>
</div>
<div class="aksrt-pmb__row">
<label for="aksrt_pay_price"><?php esc_html_e('现金价格', 'aksrt'); ?></label>
<div>
<input type="number" id="aksrt_pay_price" name="aksrt_pay_price" value="<?php echo esc_attr((string) $meta['price']); ?>" min="0" step="0.01">
<p class="aksrt-pmb__hint"><?php esc_html_e('支付方式为「现金」时使用。0 = 免费。', 'aksrt'); ?></p>
</div>
</div>
<div class="aksrt-pmb__row">
<label for="aksrt_pay_points_price"><?php esc_html_e('积分价格', 'aksrt'); ?></label>
<div>
<input type="number" id="aksrt_pay_points_price" name="aksrt_pay_points_price" value="<?php echo esc_attr((string) ($meta['points_price'] ?? 0)); ?>" min="0" step="1">
<p class="aksrt-pmb__hint"><?php esc_html_e('支付方式为「积分」时使用。0 = 免费。', 'aksrt'); ?></p>
</div>
</div>
<div class="aksrt-pmb__row">
<label for="aksrt_pay_original_price"><?php esc_html_e('原价(划线价)', 'aksrt'); ?></label>
<div>
<input type="number" id="aksrt_pay_original_price" name="aksrt_pay_original_price" value="<?php echo esc_attr((string) ($meta['original_price'] ?? '')); ?>" min="0" step="0.01" placeholder="<?php esc_attr_e('可选,显示划线效果', 'aksrt'); ?>">
</div>
</div>
<div class="aksrt-pmb__row">
<label for="aksrt_pay_limit"><?php esc_html_e('购买限制', 'aksrt'); ?></label>
<div>
<select id="aksrt_pay_limit" name="aksrt_pay_limit" style="min-width:200px;">
<option value="0" <?php selected((int) ($meta['pay_limit'] ?? 0), 0); ?>><?php esc_html_e('所有用户可购买', 'aksrt'); ?></option>
<option value="1" <?php selected((int) ($meta['pay_limit'] ?? 0), 1); ?>><?php esc_html_e('仅 VIP1 及以上', 'aksrt'); ?></option>
<option value="2" <?php selected((int) ($meta['pay_limit'] ?? 0), 2); ?>><?php esc_html_e('仅 VIP2', 'aksrt'); ?></option>
</select>
<p class="aksrt-pmb__hint"><?php esc_html_e('对齐 zibll pay_limit:限制购买人群(会员专属资源)。', 'aksrt'); ?></p>
</div>
</div>
<div class="aksrt-pmb__row">
<label><?php esc_html_e('VIP 专属价', 'aksrt'); ?></label>
<div>
<div class="aksrt-pmb__vip-row">
<div>
<label for="aksrt_pay_vip1_price"><?php esc_html_e('VIP1 现金价', 'aksrt'); ?></label>
<input type="number" id="aksrt_pay_vip1_price" name="aksrt_pay_vip1_price" value="<?php echo esc_attr((string) ($meta['vip1_price'] ?? '')); ?>" min="0" step="0.01" placeholder="0">
</div>
<div>
<label for="aksrt_pay_vip2_price"><?php esc_html_e('VIP2 现金价', 'aksrt'); ?></label>
<input type="number" id="aksrt_pay_vip2_price" name="aksrt_pay_vip2_price" value="<?php echo esc_attr((string) ($meta['vip2_price'] ?? '')); ?>" min="0" step="0.01" placeholder="0">
</div>
<div>
<label for="aksrt_pay_vip1_points"><?php esc_html_e('VIP1 积分价', 'aksrt'); ?></label>
<input type="number" id="aksrt_pay_vip1_points" name="aksrt_pay_vip1_points" value="<?php echo esc_attr((string) ($meta['vip1_points'] ?? '')); ?>" min="0" step="1" placeholder="0">
</div>
<div>
<label for="aksrt_pay_vip2_points"><?php esc_html_e('VIP2 积分价', 'aksrt'); ?></label>
<input type="number" id="aksrt_pay_vip2_points" name="aksrt_pay_vip2_points" value="<?php echo esc_attr((string) ($meta['vip2_points'] ?? '')); ?>" min="0" step="1" placeholder="0">
</div>
</div>
<p class="aksrt-pmb__hint"><?php esc_html_e('VIP 用户专属价。留空或 0 表示不启用该档位优惠。', 'aksrt'); ?></p>
</div>
</div>
</div>
<!-- 资源信息(download/gallery/video 类型显示) -->
<div class="aksrt-pmb__section" id="aksrt-pmb-resource-fields">
<div class="aksrt-pmb__section-title"><?php esc_html_e('资源信息', 'aksrt'); ?></div>
<div class="aksrt-pmb__row">
<label for="aksrt_pay_version"><?php esc_html_e('版本', 'aksrt'); ?></label>
<input type="text" id="aksrt_pay_version" name="aksrt_pay_version" value="<?php echo esc_attr((string) $meta['version']); ?>" placeholder="v1.0.0">
</div>
<div class="aksrt-pmb__row">
<label for="aksrt_pay_size"><?php esc_html_e('文件大小', 'aksrt'); ?></label>
<input type="text" id="aksrt_pay_size" name="aksrt_pay_size" value="<?php echo esc_attr((string) $meta['size']); ?>" placeholder="210 MB">
</div>
<div class="aksrt-pmb__row">
<label for="aksrt_pay_compat"><?php esc_html_e('兼容性', 'aksrt'); ?></label>
<input type="text" id="aksrt_pay_compat" name="aksrt_pay_compat" value="<?php echo esc_attr((string) $meta['compat']); ?>" placeholder="WordPress 6.4+">
</div>
<div class="aksrt-pmb__row">
<label for="aksrt_pay_license"><?php esc_html_e('授权', 'aksrt'); ?></label>
<input type="text" id="aksrt_pay_license" name="aksrt_pay_license" value="<?php echo esc_attr((string) $meta['license']); ?>" placeholder="MIT / GPL / 自用">
</div>
<div class="aksrt-pmb__row">
<label for="aksrt_pay_demo_url"><?php esc_html_e('演示地址', 'aksrt'); ?></label>
<input type="url" id="aksrt_pay_demo_url" name="aksrt_pay_demo_url" value="<?php echo esc_attr((string) $meta['demo_url']); ?>" placeholder="https://">
</div>
</div>
<!-- 自定义属性 -->
<div class="aksrt-pmb__section">
<div class="aksrt-pmb__section-title"><?php esc_html_e('自定义属性', 'aksrt'); ?></div>
<div class="aksrt-pmb__row">
<label for="aksrt_pay_attributes"><?php esc_html_e('属性列表', 'aksrt'); ?></label>
<div>
<textarea id="aksrt_pay_attributes" name="aksrt_pay_attributes" placeholder='[
{"key": "格式", "value": "PSD"},
{"key": "分辨率", "value": "4K"}
]'><?php echo esc_textarea($attributes_json); ?></textarea>
<p class="aksrt-pmb__hint"><?php esc_html_e('JSON 数组。每项:key(属性名)/ value(属性值)。', 'aksrt'); ?></p>
</div>
</div>
</div>
<!-- 下载源(download 类型显示) -->
<div class="aksrt-pmb__section" id="aksrt-pmb-download-fields">
<div class="aksrt-pmb__section-title"><?php esc_html_e('下载源', 'aksrt'); ?></div>
<div class="aksrt-pmb__row">
<label for="aksrt_pay_downloads"><?php esc_html_e('下载链接', 'aksrt'); ?></label>
<div>
<textarea id="aksrt_pay_downloads" name="aksrt_pay_downloads" placeholder='[
{
"label": "百度网盘",
"url": "https://pan.baidu.com/s/xxx",
"type": "net_disk",
"extract_code": "abcd"
}
]'><?php echo esc_textarea($downloads_json); ?></textarea>
<div class="aksrt-pmb__error" id="aksrt-pmb-dl-err"></div>
<div class="aksrt-pmb__tools">
<button type="button" class="button button-small" id="aksrt-pmb-add-dl"><?php esc_html_e('追加示例条目', 'aksrt'); ?></button>
<button type="button" class="button button-small" id="aksrt-pmb-fmt-dl"><?php esc_html_e('格式化 JSON', 'aksrt'); ?></button>
</div>
<p class="aksrt-pmb__hint"><?php esc_html_e('JSON 数组。每项:label / url / type(direct | net_disk | external)/ extract_code。', 'aksrt'); ?></p>
</div>
</div>
</div>
<!-- 图库 URL(gallery 类型显示) -->
<div class="aksrt-pmb__section" id="aksrt-pmb-gallery-fields" style="display:none;">
<div class="aksrt-pmb__section-title"><?php esc_html_e('付费图库', 'aksrt'); ?></div>
<div class="aksrt-pmb__row">
<label for="aksrt_pay_gallery"><?php esc_html_e('图片 URL 列表', 'aksrt'); ?></label>
<div>
<textarea id="aksrt_pay_gallery" name="aksrt_pay_gallery" placeholder='[
"https://example.com/img1.jpg",
"https://example.com/img2.jpg"
]'><?php echo esc_textarea($gallery_json); ?></textarea>
<p class="aksrt-pmb__hint"><?php esc_html_e('JSON 数组。每项为图片 URL。付费前图片将被模糊遮罩。', 'aksrt'); ?></p>
</div>
</div>
</div>
<!-- 视频 URL(video 类型显示) -->
<div class="aksrt-pmb__section" id="aksrt-pmb-video-fields" style="display:none;">
<div class="aksrt-pmb__section-title"><?php esc_html_e('付费视频', 'aksrt'); ?></div>
<div class="aksrt-pmb__row">
<label for="aksrt_pay_video_url"><?php esc_html_e('视频 URL', 'aksrt'); ?></label>
<div>
<input type="url" id="aksrt_pay_video_url" name="aksrt_pay_video_url" value="<?php echo esc_attr((string) ($meta['video_url'] ?? '')); ?>" placeholder="https://example.com/video.mp4">
<p class="aksrt-pmb__hint"><?php esc_html_e('支持 MP4 直链或视频平台嵌入链接。', 'aksrt'); ?></p>
</div>
</div>
</div>
<!-- 选项覆盖 -->
<div class="aksrt-pmb__section">
<div class="aksrt-pmb__section-title"><?php esc_html_e('选项覆盖', 'aksrt'); ?></div>
<div class="aksrt-pmb__row">
<label><?php esc_html_e('单条覆盖', 'aksrt'); ?></label>
<div>
<div style="display:flex; align-items:center; gap:6px; margin-bottom:6px;">
<input type="checkbox" id="aksrt_pay_require_login" name="aksrt_pay_require_login" value="1" <?php checked($meta['require_login']); ?>>
<label for="aksrt_pay_require_login" style="font-weight:400;padding:0;"><?php esc_html_e('强制登录后才可访问', 'aksrt'); ?></label>
</div>
<div style="display:flex; align-items:center; gap:6px;">
<input type="checkbox" id="aksrt_pay_require_captcha" name="aksrt_pay_require_captcha" value="1" <?php checked($meta['require_captcha']); ?>>
<label for="aksrt_pay_require_captcha" style="font-weight:400;padding:0;"><?php esc_html_e('强制需要人机验证', 'aksrt'); ?></label>
</div>
<p class="aksrt-pmb__hint"><?php esc_html_e('全局开关在后台统一控制,这里仅作为单条强制覆盖。', 'aksrt'); ?></p>
</div>
</div>
</div>
<!-- §3.8 电商增强 -->
<div class="aksrt-pmb__section">
<div class="aksrt-pmb__section-title"><?php esc_html_e('电商增强', 'aksrt'); ?></div>
<div class="aksrt-pmb__row">
<label for="aksrt_pay_virtual_sales"><?php esc_html_e('虚拟销量', 'aksrt'); ?></label>
<div>
<input type="number" id="aksrt_pay_virtual_sales" name="aksrt_pay_virtual_sales" value="<?php echo esc_attr((string) ($meta['virtual_sales'] ?? 0)); ?>" min="0" step="1">
<p class="aksrt-pmb__hint"><?php esc_html_e('在前端展示的销量基础上叠加此数值,用于展示效果。', 'aksrt'); ?></p>
</div>
</div>
<div class="aksrt-pmb__row">
<label for="aksrt_pay_limit_over_price"><?php esc_html_e('超额下载补价', 'aksrt'); ?></label>
<div>
<input type="number" id="aksrt_pay_limit_over_price" name="aksrt_pay_limit_over_price" value="<?php echo esc_attr((string) ($meta['download_limit_over_price'] ?? 0)); ?>" min="0" step="0.01">
<p class="aksrt-pmb__hint"><?php esc_html_e('免费日限额用尽后允许购买的补价(现金/积分随支付方式)。0 = 不允许超额购买。对齐 zibll download_limit_over_price。', 'aksrt'); ?></p>
</div>
</div>
<div class="aksrt-pmb__row">
<label><?php esc_html_e('优惠码', 'aksrt'); ?></label>
<div>
<div style="display:flex; align-items:center; gap:6px; margin-bottom:8px;">
<input type="checkbox" id="aksrt_pay_coupon_s" name="aksrt_pay_coupon_s" value="1" <?php checked(!empty($meta['coupon_s'])); ?>>
<label for="aksrt_pay_coupon_s" style="font-weight:400;padding:0;"><?php esc_html_e('允许使用优惠码(全站开关关闭时仍可对本篇启用)', 'aksrt'); ?></label>
</div>
<textarea id="aksrt_pay_coupon_desc" name="aksrt_pay_coupon_desc" style="min-height:60px;" placeholder="<?php esc_attr_e('本篇优惠码说明,可留空', 'aksrt'); ?>"><?php echo esc_textarea((string) ($meta['coupon_desc'] ?? '')); ?></textarea>
<p class="aksrt-pmb__hint"><?php esc_html_e('对齐 zibll coupon_s / coupon_desc。积分支付不使用优惠码。', 'aksrt'); ?></p>
</div>
</div>
<div class="aksrt-pmb__row">
<label for="aksrt_pay_rebate_discount"><?php esc_html_e('推广优惠金额', 'aksrt'); ?></label>
<div>
<input type="number" id="aksrt_pay_rebate_discount" name="aksrt_pay_rebate_discount" value="<?php echo esc_attr((string) ($meta['pay_rebate_discount'] ?? 0)); ?>" min="0" step="0.01">
<p class="aksrt-pmb__hint"><?php esc_html_e('有有效推荐人且开启推广返佣时,现金购买可先减此金额(买家侧推广折扣,对齐 zibll pay_rebate_discount)。0 = 不启用。', 'aksrt'); ?></p>
</div>
</div>
<div class="aksrt-pmb__row">
<label for="aksrt_pay_stock"><?php esc_html_e('库存', 'aksrt'); ?></label>
<div>
<input type="number" id="aksrt_pay_stock" name="aksrt_pay_stock" value="<?php echo esc_attr((string) ($meta['stock'] ?? -1)); ?>" min="-1" step="1">
<p class="aksrt-pmb__hint"><?php esc_html_e('-1 表示无限库存;>0 表示限量,售完即止。', 'aksrt'); ?></p>
</div>
</div>
<div class="aksrt-pmb__row">
<label for="aksrt_pay_purchase_limit"><?php esc_html_e('限购数量', 'aksrt'); ?></label>
<div>
<input type="number" id="aksrt_pay_purchase_limit" name="aksrt_pay_purchase_limit" value="<?php echo esc_attr((string) ($meta['purchase_limit'] ?? 0)); ?>" min="0" step="1">
<p class="aksrt-pmb__hint"><?php esc_html_e('每用户最多可购买次数,0 表示不限购。', 'aksrt'); ?></p>
</div>
</div>
<div class="aksrt-pmb__row">
<label for="aksrt_pay_card_keys"><?php esc_html_e('自动发货卡密', 'aksrt'); ?></label>
<div>
<textarea id="aksrt_pay_card_keys" name="aksrt_pay_card_keys" placeholder="<?php esc_attr_e('每行一个卡密/激活码,购买后自动发放一个', 'aksrt'); ?>"><?php echo esc_textarea(implode("\n", $meta['card_keys'] ?? [])); ?></textarea>
<p class="aksrt-pmb__hint"><?php esc_html_e('每行一个卡密,购买后自动消耗一个并发给用户。留空则不自动发货。', 'aksrt'); ?></p>
</div>
</div>
<div class="aksrt-pmb__row">
<label for="aksrt_pay_free_preview"><?php esc_html_e('免费试读段落', 'aksrt'); ?></label>
<div>
<textarea id="aksrt_pay_free_preview" name="aksrt_pay_free_preview" style="min-height:80px;" placeholder="<?php esc_attr_e('付费内容的免费预览段落(支持 HTML)', 'aksrt'); ?>"><?php echo esc_textarea($meta['free_preview'] ?? ''); ?></textarea>
<p class="aksrt-pmb__hint"><?php esc_html_e('未付费用户可见的预览内容,支持基础 HTML。留空则不显示预览。', 'aksrt'); ?></p>
</div>
</div>
<div class="aksrt-pmb__row">
<label><?php esc_html_e('销量显示', 'aksrt'); ?></label>
<div style="display:flex; align-items:center; gap:6px;">
<input type="checkbox" id="aksrt_pay_show_sales" name="aksrt_pay_show_sales" value="1" <?php checked($meta['show_sales'] ?? true); ?>>
<label for="aksrt_pay_show_sales" style="font-weight:400;padding:0;"><?php esc_html_e('在前端显示销量', 'aksrt'); ?></label>
</div>
</div>
</div>
</div>
<script>
(function () {
var typeSelect = document.getElementById('aksrt_pay_type');
var fields = document.getElementById('aksrt-pmb-fields');
var dlFields = document.getElementById('aksrt-pmb-download-fields');
var galleryFields = document.getElementById('aksrt-pmb-gallery-fields');
var videoFields = document.getElementById('aksrt-pmb-video-fields');
var resourceFields = document.getElementById('aksrt-pmb-resource-fields');
function updateVisibility() {
var t = typeSelect.value;
var isPaid = t !== 'no';
fields.setAttribute('data-disabled', isPaid ? '0' : '1');
// 根据付费类型显示/隐藏相关字段
if (dlFields) dlFields.style.display = (t === 'download') ? '' : 'none';
if (galleryFields) galleryFields.style.display = (t === 'gallery') ? '' : 'none';
if (videoFields) videoFields.style.display = (t === 'video') ? '' : 'none';
if (resourceFields) resourceFields.style.display = (t === 'download' || t === 'gallery' || t === 'video') ? '' : 'none';
}
if (typeSelect) {
typeSelect.addEventListener('change', updateVisibility);
updateVisibility();
}
// 下载源 JSON 工具
var dlTa = document.getElementById('aksrt_pay_downloads');
var dlErr = document.getElementById('aksrt-pmb-dl-err');
var addDlBtn = document.getElementById('aksrt-pmb-add-dl');
var fmtDlBtn = document.getElementById('aksrt-pmb-fmt-dl');
if (addDlBtn && dlTa) {
addDlBtn.addEventListener('click', function () {
var sample = {label: '百度网盘', url: 'https://pan.baidu.com/s/xxx', type: 'net_disk', extract_code: ''};
try {
var arr = dlTa.value.trim() ? JSON.parse(dlTa.value) : [];
arr.push(sample);
dlTa.value = JSON.stringify(arr, null, 2);
if (dlErr) dlErr.style.display = 'none';
} catch (e) {
if (dlErr) { dlErr.textContent = 'JSON 解析错误: ' + e.message; dlErr.style.display = 'block'; }
}
});
}
if (fmtDlBtn && dlTa) {
fmtDlBtn.addEventListener('click', function () {
try {
var arr = JSON.parse(dlTa.value);
dlTa.value = JSON.stringify(arr, null, 2);
if (dlErr) dlErr.style.display = 'none';
} catch (e) {
if (dlErr) { dlErr.textContent = 'JSON 解析错误: ' + e.message; dlErr.style.display = 'block'; }
}
});
}
})();
</script>
<?php
}
public function save(int $post_id, WP_Post $post): void {
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return;
if (wp_is_post_revision($post_id)) return;
if ($post->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';
}
// 价格字段
$pay_modo = sanitize_key((string) ($_POST['aksrt_pay_modo'] ?? '0'));
if ($pay_modo !== 'points') {
$pay_modo = '0';
}
$price = max(0, (float) ($_POST['aksrt_pay_price'] ?? 0));
$points_price = max(0, (int) ($_POST['aksrt_pay_points_price'] ?? 0));
// Keep legacy single-price editors usable: if points field empty and modo points, mirror price.
if ($pay_modo === 'points' && $points_price <= 0 && $price > 0) {
$points_price = (int) ceil($price);
}
if ($pay_modo !== 'points' && $price <= 0 && $points_price > 0) {
$price = (float) $points_price;
}
$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));
$vip1_points = max(0, (int) ($_POST['aksrt_pay_vip1_points'] ?? 0));
$vip2_points = max(0, (int) ($_POST['aksrt_pay_vip2_points'] ?? 0));
$pay_limit = (int) ($_POST['aksrt_pay_limit'] ?? 0);
if (!in_array($pay_limit, [0, 1, 2], true)) {
$pay_limit = 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,
'pay_modo' => $pay_modo,
'price' => $price,
'points_price' => $points_price,
'original_price' => $original_price,
'vip1_price' => $vip1_price,
'vip2_price' => $vip2_price,
'vip1_points' => $vip1_points,
'vip2_points' => $vip2_points,
'pay_limit' => $pay_limit,
'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']),
'download_limit_over_price' => max(0, (float) ($_POST['aksrt_pay_limit_over_price'] ?? 0)),
'coupon_s' => !empty($_POST['aksrt_pay_coupon_s']),
'coupon_desc' => (string) ($_POST['aksrt_pay_coupon_desc'] ?? ''),
'pay_rebate_discount' => max(0, (float) ($_POST['aksrt_pay_rebate_discount'] ?? 0)),
]);
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']);
$query_price = ($pay_meta['pay_modo'] === 'points')
? (float) $pay_meta['points_price']
: (float) $pay_meta['price'];
update_post_meta($post_id, AKSRT_Domain_Model::PAY_PRICE_KEY, $query_price);
update_post_meta($post_id, '_aksrt_pay_modo', (string) $pay_meta['pay_modo']);
update_post_meta($post_id, '_aksrt_pay_limit', (int) $pay_meta['pay_limit']);
// 清理旧的 _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;
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
/**
* AKSRT Scripts & Styles
*
* 前端资源加载:CSS、JS、内联样式变量
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_Scripts {
use AKSRT_Scripts_Part_1b;
use AKSRT_Scripts_Part_2;
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('wp_enqueue_scripts', [$this, 'enqueue_public']);
add_action('admin_enqueue_scripts', [$this, 'enqueue_admin']);
add_action('admin_enqueue_scripts', [$this, 'enqueue_passive_polyfill'], 1);
add_action('wp_head', [$this, 'inline_css_vars']);
add_action('wp_head', [$this, 'output_custom_codes']);
add_action('wp_body_open', [$this, 'output_loading_overlay'], 1);
add_action('wp_footer', [$this, 'output_custom_footer_codes'], 99);
add_action('wp_ajax_aksrt_frontend_debug', [$this, 'handle_frontend_debug']);
add_action('wp_ajax_nopriv_aksrt_frontend_debug', [$this, 'handle_frontend_debug']);
add_action('wp_ajax_aksrt_search_history', [$this, 'handle_search_history']);
add_action('wp_ajax_aksrt_search_suggestions', [$this, 'handle_search_suggestions']);
add_action('wp_ajax_nopriv_aksrt_search_suggestions', [$this, 'handle_search_suggestions']);
add_filter('body_class', [$this, 'body_classes']);
add_filter('wp_resource_hints', [$this, 'resource_hints'], 10, 2);
}
/**
* 字体 CDN 的 preconnect 提示(提前 DNS + TLS 握手,加速首屏字体加载)
*/
public function resource_hints(array $hints, string $relation): array {
if ('preconnect' === $relation) {
$hints[] = [
'href' => '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();
+412
View File
@@ -0,0 +1,412 @@
<?php
/**
* AKSRT SEO Head — 单一 head 输出装配器
*
* Phase 7 收口:站点 SEO、单篇 SEO、OpenGraph/Twitter、JSON-LD、canonical、
* favicon、robots 全部从这里一次性装配输出,替代此前散落在
* inc/helpers/aksrt-template-tags.php 的多个 wp_head 闭包。
*
* 所有取值只经过 AKSRT_Settings + 模板 helper,不直接 raw get_option()。
* 唯一 head 来源,便于追踪。详见 seo-output-matrix.md。
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_SEO_Head {
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
// seo_enabled 主开关:关闭时不挂载任何 wp_head 输出
if (class_exists('AKSRT_Settings')) {
$seo = AKSRT_Settings::get_seo_settings();
if (empty($seo['seo_enabled'])) return;
}
// 单一出口:所有 SEO/OG/JSON-LD 输出集中在 priority 2。
add_action('wp_head', [$this, 'render'], 2);
// robots:dl token 页面 noindex(独立、最早输出)。
add_action('wp_head', [$this, 'render_robots'], 1);
}
/**
* 站点级 SEO 配置(带 per-request memo)。
*
* @return array<string,mixed>
*/
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'] = ($post->post_type === 'post') ? '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);
$meta = $this->term_seo_meta((int) $term->term_id);
if ($meta['title'] !== '') {
$payload['title'] = $meta['title'];
}
if ($meta['description'] !== '') {
$payload['description'] = $meta['description'];
} else {
$term_desc = trim(wp_strip_all_tags((string) term_description($term)));
if ($term_desc !== '') {
$payload['description'] = $term_desc;
}
}
if ($meta['keywords'] !== '') {
$payload['keywords'] = $meta['keywords'];
}
}
}
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'),
];
}
/**
* 读取 term SEO 覆盖。
*
* @return array{title:string,description:string,keywords:string}
*/
private function term_seo_meta(int $term_id): array {
$get = static function (string $key) use ($term_id): string {
return trim((string) get_term_meta($term_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 '<meta name="robots" content="noindex,nofollow">' . "\n";
}
}
/**
* 输出 head(favicon + canonical + description/keywords + OG/Twitter + JSON-LD)。
*/
public function render(): void {
$cfg = $this->config();
$payload = $this->resolve_payload();
echo "\n<!-- AKSRT SEO -->\n";
// Favicon(无站点图标时回退)。
if (function_exists('aksrt_get_favicon_url')) {
$favicon = (string) aksrt_get_favicon_url();
if ($favicon !== '' && !has_site_icon()) {
printf('<link rel="icon" href="%s">' . "\n", esc_url($favicon));
}
}
// Canonical。
if ($cfg['canonical'] && !is_404() && $payload['canonical'] !== '') {
printf('<link rel="canonical" href="%s">' . "\n", esc_url($payload['canonical']));
}
// Description / Keywords。
if ($payload['description'] !== '') {
printf('<meta name="description" content="%s">' . "\n", esc_attr($payload['description']));
}
if ($payload['keywords'] !== '') {
printf('<meta name="keywords" content="%s">' . "\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 "<!-- /AKSRT SEO -->\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('<meta property="%s" content="%s">' . "\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('<meta name="%s" content="%s">' . "\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 '<script type="application/ld+json">'
. wp_json_encode($doc, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
. '</script>' . "\n";
}
}
@@ -0,0 +1,281 @@
<?php
/**
* AKSRT SEO Meta Box — 文章/页面独立 SEO + AI 生成按钮
*
* Meta keys 与 AKSRT_SEO_Head 读取一致:
* _aksrt_seo_title / _aksrt_seo_keywords / _aksrt_seo_description
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_SEO_Meta_Box {
private static $instance = null;
public const META_TITLE = '_aksrt_seo_title';
public const META_KEYWORDS = '_aksrt_seo_keywords';
public const META_DESCRIPTION = '_aksrt_seo_description';
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
add_action('add_meta_boxes', [$this, 'register_meta_box']);
add_action('save_post', [$this, 'save_meta_box'], 10, 2);
add_action('admin_enqueue_scripts', [$this, 'enqueue_assets']);
// Term SEO (category / post_tag)
add_action('category_edit_form_fields', [$this, 'render_term_fields'], 20);
add_action('post_tag_edit_form_fields', [$this, 'render_term_fields'], 20);
add_action('edited_category', [$this, 'save_term_fields']);
add_action('edited_post_tag', [$this, 'save_term_fields']);
}
public function register_meta_box(): void {
foreach (['post', 'page'] as $screen) {
add_meta_box(
'aksrt_seo_meta',
__('独立 SEO', 'aksrt'),
[$this, 'render_meta_box'],
$screen,
'normal',
'high'
);
}
}
public function render_meta_box(WP_Post $post): void {
wp_nonce_field('aksrt_seo_meta_save', 'aksrt_seo_meta_nonce');
$title = (string) get_post_meta($post->ID, self::META_TITLE, true);
$keywords = (string) get_post_meta($post->ID, self::META_KEYWORDS, true);
$description = (string) get_post_meta($post->ID, self::META_DESCRIPTION, true);
$ai_btn = $this->render_ai_button('post', (int) $post->ID);
$summary_btn = $this->render_summary_button((int) $post->ID);
?>
<div class="aksrt-seo-meta-box">
<p>
<label for="aksrt_seo_title"><strong><?php esc_html_e('SEO 标题', 'aksrt'); ?></strong></label>
<input type="text" class="widefat" id="aksrt_seo_title" name="aksrt_seo_title" value="<?php echo esc_attr($title); ?>" />
</p>
<p>
<label for="aksrt_seo_keywords"><strong><?php esc_html_e('SEO 关键词', 'aksrt'); ?></strong></label>
<input type="text" class="widefat" id="aksrt_seo_keywords" name="aksrt_seo_keywords" value="<?php echo esc_attr($keywords); ?>" placeholder="<?php esc_attr_e('多个关键词用英文逗号分隔', 'aksrt'); ?>" />
</p>
<p>
<label for="aksrt_seo_description"><strong><?php esc_html_e('SEO 描述', 'aksrt'); ?></strong></label>
<textarea class="widefat" rows="3" id="aksrt_seo_description" name="aksrt_seo_description"><?php echo esc_textarea($description); ?></textarea>
<span class="description"><?php esc_html_e('建议 50–150 字。', 'aksrt'); ?><?php echo $ai_btn; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span>
</p>
<?php if ($summary_btn !== '') : ?>
<p class="aksrt-ai-summary-row">
<span class="description"><?php esc_html_e('写作辅助:根据正文生成摘要并填入「摘要」字段(不自动保存)。', 'aksrt'); ?></span>
<?php echo $summary_btn; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</p>
<?php endif; ?>
</div>
<?php
}
public function save_meta_box(int $post_id, $post = null): void {
if (!isset($_POST['aksrt_seo_meta_nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['aksrt_seo_meta_nonce'])), 'aksrt_seo_meta_save')) {
return;
}
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
return;
}
if (!current_user_can('edit_post', $post_id)) {
return;
}
if (wp_is_post_revision($post_id)) {
return;
}
$map = [
self::META_TITLE => 'aksrt_seo_title',
self::META_KEYWORDS => 'aksrt_seo_keywords',
self::META_DESCRIPTION => 'aksrt_seo_description',
];
foreach ($map as $meta_key => $field) {
if (!isset($_POST[$field])) {
continue;
}
$value = sanitize_text_field(wp_unslash((string) $_POST[$field]));
if ($value === '') {
delete_post_meta($post_id, $meta_key);
} else {
update_post_meta($post_id, $meta_key, $value);
}
}
}
/**
* @param WP_Term $term
*/
public function render_term_fields($term): void {
if (!$term instanceof WP_Term) {
return;
}
$title = (string) get_term_meta($term->term_id, self::META_TITLE, true);
$keywords = (string) get_term_meta($term->term_id, self::META_KEYWORDS, true);
$description = (string) get_term_meta($term->term_id, self::META_DESCRIPTION, true);
$ai_btn = $this->render_ai_button('term', (int) $term->term_id);
?>
<tr class="form-field">
<th scope="row"><label for="aksrt_seo_title"><?php esc_html_e('SEO 标题', 'aksrt'); ?></label></th>
<td>
<input type="text" name="aksrt_seo_title" id="aksrt_seo_title" class="aksrt-term-seo-title" value="<?php echo esc_attr($title); ?>" />
</td>
</tr>
<tr class="form-field">
<th scope="row"><label for="aksrt_seo_keywords"><?php esc_html_e('SEO 关键词', 'aksrt'); ?></label></th>
<td>
<input type="text" name="aksrt_seo_keywords" id="aksrt_seo_keywords" class="aksrt-term-seo-keywords" value="<?php echo esc_attr($keywords); ?>" />
</td>
</tr>
<tr class="form-field">
<th scope="row"><label for="aksrt_seo_description"><?php esc_html_e('SEO 描述', 'aksrt'); ?></label></th>
<td>
<textarea name="aksrt_seo_description" id="aksrt_seo_description" class="aksrt-term-seo-description" rows="4"><?php echo esc_textarea($description); ?></textarea>
<p class="description"><?php esc_html_e('主题默认会自动设置 SEO 内容,如需单独设置请在此输入。', 'aksrt'); ?><?php echo $ai_btn; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></p>
</td>
</tr>
<?php
}
public function save_term_fields(int $term_id): void {
if (!current_user_can('edit_term', $term_id)) {
return;
}
$map = [
self::META_TITLE => 'aksrt_seo_title',
self::META_KEYWORDS => 'aksrt_seo_keywords',
self::META_DESCRIPTION => 'aksrt_seo_description',
];
foreach ($map as $meta_key => $field) {
if (!isset($_POST[$field])) {
continue;
}
$value = sanitize_text_field(wp_unslash((string) $_POST[$field]));
if ($value === '') {
delete_term_meta($term_id, $meta_key);
} else {
update_term_meta($term_id, $meta_key, $value);
}
}
}
public function enqueue_assets(string $hook): void {
$screen = function_exists('get_current_screen') ? get_current_screen() : null;
if (!$screen) {
return;
}
$ok = in_array($screen->base, ['post', 'term', 'edit-tags'], true);
if (!$ok) {
return;
}
$path = AKSRT_DIR . '/assets/js/admin-ai-seo.js';
if (!is_file($path)) {
return;
}
wp_enqueue_script(
'aksrt-admin-ai-seo',
AKSRT_URI . '/assets/js/admin-ai-seo.js',
[],
(string) filemtime($path),
true
);
$status = class_exists('AKSRT_AI') ? AKSRT_AI::get_instance()->get_status() : [];
$seo = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_seo_settings() : [];
wp_localize_script('aksrt-admin-ai-seo', 'aksrtAiSeo', [
'restUrl' => esc_url_raw(rest_url('aksrt/v1')),
'nonce' => wp_create_nonce('wp_rest'),
'ai' => [
'available' => !empty($status['available']),
'seo_ai_enabled' => !empty($seo['seo_ai_enabled']) && !empty($seo['seo_enabled']),
'write_assist' => !empty($status['available']),
],
'i18n' => [
'generating' => __('生成中…', 'aksrt'),
'generate' => __('AI 生成 SEO', 'aksrt'),
'generateSummary' => __('AI 生成摘要', 'aksrt'),
'success' => __('已填入 SEO 字段,请检查后更新保存', 'aksrt'),
'summarySuccess' => __('已填入摘要字段,请检查后更新保存', 'aksrt'),
'failed' => __('生成失败', 'aksrt'),
'needSave' => __('请先保存后再生成 SEO', 'aksrt'),
'needContent' => __('请先填写正文内容', 'aksrt'),
'notAvailable' => __('AI 未配置或未启用', 'aksrt'),
'noExcerptField' => __('未找到摘要字段,请展开「摘要」面板', 'aksrt'),
],
]);
wp_add_inline_style('wp-admin', '
.aksrt-ai-seo-button{display:inline-block;margin-left:8px;vertical-align:middle}
.aksrt-ai-seo-generate,.aksrt-ai-summary-generate{cursor:pointer}
.aksrt-ai-seo-generate.is-loading,.aksrt-ai-summary-generate.is-loading{opacity:.6;pointer-events:none}
.aksrt-ai-seo-msg{display:block;margin-top:6px;font-size:12px}
.aksrt-ai-seo-msg.is-error{color:#b32d2e}
.aksrt-ai-seo-msg.is-ok{color:#007017}
.aksrt-ai-summary-row{margin-top:8px;padding-top:8px;border-top:1px solid #eee}
');
}
private function render_ai_button(string $type, int $id): string {
$seo = class_exists('AKSRT_Settings') ? AKSRT_Settings::get_seo_settings() : [];
if (empty($seo['seo_enabled']) || empty($seo['seo_ai_enabled'])) {
return '';
}
$available = class_exists('AKSRT_AI') && AKSRT_AI::get_instance()->is_available();
$disabled = (!$available || $id <= 0) ? ' disabled' : '';
$title = !$available
? esc_attr__('请先配置 AI 连接(扩展 → AI 连接配置 或 WP Connectors)', 'aksrt')
: ($id <= 0 ? esc_attr__('请先保存后再生成', 'aksrt') : '');
return sprintf(
'<span class="aksrt-ai-seo-button"><button type="button" class="button aksrt-ai-seo-generate"%s data-type="%s" data-id="%d" title="%s">%s</button></span>',
$disabled,
esc_attr($type),
$id,
$title,
esc_html__('AI 生成 SEO', 'aksrt')
);
}
/**
* 后台「AI 生成摘要」→ 写入 WP 摘要字段(#excerpt / 块编辑器)。
*/
private function render_summary_button(int $post_id): string {
$available = class_exists('AKSRT_AI') && AKSRT_AI::get_instance()->is_available();
if (!$available) {
return '';
}
$disabled = $post_id <= 0 ? ' disabled' : '';
$title = $post_id <= 0
? esc_attr__('请先保存后再生成', 'aksrt')
: esc_attr__('根据正文生成摘要,填入摘要框', 'aksrt');
return sprintf(
'<span class="aksrt-ai-seo-button"><button type="button" class="button aksrt-ai-summary-generate"%s data-post-id="%d" title="%s">%s</button></span>',
$disabled,
$post_id,
$title,
esc_html__('AI 生成摘要', 'aksrt')
);
}
}
+397
View File
@@ -0,0 +1,397 @@
<?php
/**
* AKSRT Theme Setup
*
* 主题初始化:注册菜单、侧边栏、图片尺寸、自定义文章类型等
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_Setup {
use AKSRT_Setup_Part_1;
use AKSRT_Setup_Part_2;
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('after_setup_theme', [$this, 'load_textdomain'], 1);
add_action('after_setup_theme', [$this, 'setup_theme']);
add_action('widgets_init', [$this, 'register_sidebars']);
add_filter('dynamic_sidebar_params', [$this, 'filter_sidebar_widget_params']);
add_action('init', [$this, 'register_nav_menus'], 0);
add_action('init', [$this, 'register_post_types'], 1);
add_action('init', [$this, 'register_meta_fields'], 5);
add_action('init', [$this, 'register_shop_rewrite_rules'], 20);
add_filter('query_vars', [$this, 'register_shop_query_vars']);
add_action('template_redirect', [$this, 'shop_template_redirect']);
// Shop fulfillment hook (§6.5 R68)
if (class_exists('AKSRT_Shop')) {
add_action('aksrt_order_paid', [AKSRT_Shop::get_instance(), 'handle_order_paid'], 10, 2);
}
// Auto-confirm receipt cron (§6.5 R68)
if (!wp_next_scheduled('aksrt_auto_confirm_receipt')) {
wp_schedule_event(time(), 'hourly', 'aksrt_auto_confirm_receipt');
}
if (class_exists('AKSRT_Shop')) {
add_action('aksrt_auto_confirm_receipt', [AKSRT_Shop::get_instance(), 'run_auto_confirm_cron']);
}
// 文章浏览量统计(单次访问)
add_action('wp', 'aksrt_set_post_views');
// 隐藏默认WordPress管理菜单
add_action('admin_menu', [$this, 'hide_default_menus'], 999);
}
public function load_textdomain(): void {
load_theme_textdomain('aksrt', AKSRT_DIR . '/languages');
}
public function setup_theme(): void {
// HTML5 支持
add_theme_support('html5', [
'search-form', 'comment-form', 'comment-list',
'gallery', 'caption', 'style', 'script',
'navigation-widgets',
]);
add_theme_support('title-tag');
add_theme_support('post-thumbnails');
add_theme_support('custom-logo', [
'height' => 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'),
]);
}
/**
* 注册自定义文章类型
*
* 资源不是 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']);
// Shop admin visibility: align with zibll native sidebar when shop is enabled.
$shop_enabled = true;
if (class_exists('AKSRT_Settings')) {
$shop_settings = AKSRT_Settings::get_shop_settings();
$shop_enabled = !empty($shop_settings['enabled']);
}
// Always register UI in admin so settings/tools remain reachable; hide menu when disabled.
$shop_show_ui = true;
$shop_show_menu = is_admin() ? $shop_enabled : false;
// 商品 CPT — native WP sidebar (zibll: shop_product @ pos 35)
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'),
'all_items' => __('所有商品', 'aksrt'),
'menu_name' => __('商城', 'aksrt'),
],
'public' => true,
'publicly_queryable' => true,
'show_ui' => $shop_show_ui,
'show_in_menu' => $shop_show_menu,
'show_in_rest' => true,
'has_archive' => true,
'hierarchical' => false,
'menu_position' => 35,
'menu_icon' => 'dashicons-store',
'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' => $shop_show_ui,
'show_in_menu' => $shop_show_menu,
'show_in_rest' => true,
'show_admin_column' => true,
'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' => $shop_show_ui,
'show_in_menu' => $shop_show_menu,
'show_in_rest' => true,
'show_admin_column' => true,
'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'] = '<div class="customize-preview-widget-box"><div class="customize-preview-widget-edit"></div>' . $params[0]['before_widget'];
$params[0]['after_widget'] = $params[0]['after_widget'] . '</div>';
return $params;
}
/**
* 注册侧边栏
*/
}
return AKSRT_Setup::get_instance();
@@ -0,0 +1,146 @@
<?php
/**
* AKSRT 访问量统计系统
*
* 追踪每日访客数和浏览量,为网站统计小工具提供数据支持。
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_Stats_Tracker {
private static $instance = null;
/**
* 单例模式
*/
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
/**
* 初始化:挂钩到 wp_head
*/
public static function init(): void {
add_action('wp_head', [self::get_instance(), 'track_visit'], 1);
}
/**
* 追踪访问
*/
public function track_visit(): void {
// 排除管理员、爬虫、AJAX请求
if (is_admin() || wp_doing_ajax()) {
return;
}
if (is_user_logged_in() && current_user_can('manage_options')) {
return;
}
$today = current_time('Y-m-d');
$option_key = 'aksrt_daily_stats_' . $today;
// 检查是否已统计过该访客
$visitor_cookie = 'aksrt_visitor_' . $today;
$is_unique = empty($_COOKIE[$visitor_cookie]);
// 获取或初始化今日统计
$stats = get_option($option_key, ['visitors' => 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);
}
}
+144
View File
@@ -0,0 +1,144 @@
<?php
/**
* AKSRT Page Templates
*
* @package AKSRT
* @since 1.0.0
*/
defined('ABSPATH') || exit;
class AKSRT_Templates {
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_filter('theme_page_templates', [$this, 'register_page_templates']);
add_filter('template_include', [$this, 'load_page_template']);
add_filter('template_include', [$this, 'load_taxonomy_template']);
add_filter('page_template_hierarchy', [$this, 'template_hierarchy']);
add_filter('aksrt_page_template_hierarchy', [$this, 'template_hierarchy']);
// 短代码注册统一在 AKSRT_Shortcodes(registry);本类只提供 archive/profile 渲染实现。
}
/**
* Selectable page templates shown in wp-admin for the rebuilt mainline.
*/
private function page_template_map(): array {
return [
'templates/pages/page-sign.php' => __('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-friends.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-messages.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();
+293
View File
@@ -0,0 +1,293 @@
<?php
/**
* AKSRT Term Meta
*
* 为分类(category)和标签(post_tag)添加局部配置字段:
* - 头图/封面 URL
* - 自定义简介
* - 列表布局模式覆盖
* - 侧栏开关
* - 推荐位开关
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_Term_Meta {
private static $instance = null;
/** Supported taxonomies */
private $taxonomies = ['category', 'post_tag'];
/** Meta key for storing term-level settings */
const META_KEY = AKSRT_Domain_Model::TERM_META_KEY;
public static function get_instance(): self {
if (null === self::$instance) self::$instance = new self();
return self::$instance;
}
public function init(): void {
// Register meta fields
foreach ($this->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<string,mixed>
*/
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 = [];
}
// Empty term layout_mode means follow global post-list settings (Zibll).
// Do not fall back to page-display list_mode=grid which forced card everywhere.
$layout_preset = isset($saved['layout_mode']) ? sanitize_key((string) $saved['layout_mode']) : '';
if ($layout_preset === '') {
$resolved_layout = ['mode' => '', 'card_style' => ''];
$layout_mode = '';
} else {
$layout_mode = $layout_preset;
$layout_map = [
'grid' => ['mode' => 'card', 'card_style' => 'thumb'],
'card' => ['mode' => 'card', 'card_style' => 'thumb'],
'list' => ['mode' => 'list', 'card_style' => 'thumb'],
'compact' => ['mode' => 'list', 'card_style' => 'compact'],
'mini' => ['mode' => 'mini', 'card_style' => 'thumb'],
'grid-cms' => ['mode' => 'grid-cms', 'card_style' => 'thumb'],
];
$resolved_layout = $layout_map[$layout_mode] ?? ['mode' => 'list', 'card_style' => 'thumb'];
}
$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 {
?>
<div class="form-field">
<label for="aksrt_term_cover_url"><?php esc_html_e('封面图 URL', 'aksrt'); ?></label>
<input type="url" name="aksrt_term_meta[cover_url]" id="aksrt_term_cover_url" value="" placeholder="https://...">
<p class="description"><?php esc_html_e('分类/标签页顶部封面图,留空则不显示。', 'aksrt'); ?></p>
</div>
<div class="form-field">
<label for="aksrt_term_layout"><?php esc_html_e('列表布局', 'aksrt'); ?></label>
<select name="aksrt_term_meta[layout_mode]" id="aksrt_term_layout">
<option value=""><?php esc_html_e('跟随全局设置', 'aksrt'); ?></option>
<option value="card"><?php esc_html_e('卡片模式', 'aksrt'); ?></option>
<option value="list"><?php esc_html_e('列表模式', 'aksrt'); ?></option>
<option value="mini"><?php esc_html_e('迷你模式', 'aksrt'); ?></option>
</select>
</div>
<?php
}
/**
* Render fields for "Edit term" form.
*/
public function render_edit_fields(WP_Term $term): void {
$meta = self::get_meta($term->term_id);
?>
<tr class="form-field">
<th scope="row"><label for="aksrt_term_cover_url"><?php esc_html_e('封面图 URL', 'aksrt'); ?></label></th>
<td>
<input type="url" name="aksrt_term_meta[cover_url]" id="aksrt_term_cover_url" value="<?php echo esc_url($meta['cover_url']); ?>" class="regular-text" placeholder="https://...">
<p class="description"><?php esc_html_e('分类/标签页顶部封面图,留空则不显示。', 'aksrt'); ?></p>
</td>
</tr>
<tr class="form-field">
<th scope="row"><label for="aksrt_term_desc"><?php esc_html_e('自定义简介', 'aksrt'); ?></label></th>
<td>
<textarea name="aksrt_term_meta[description]" id="aksrt_term_desc" rows="3" class="large-text"><?php echo esc_textarea($meta['description']); ?></textarea>
<p class="description"><?php esc_html_e('覆盖默认的分类/标签描述。', 'aksrt'); ?></p>
</td>
</tr>
<tr class="form-field">
<th scope="row"><label for="aksrt_term_layout"><?php esc_html_e('列表布局', 'aksrt'); ?></label></th>
<td>
<select name="aksrt_term_meta[layout_mode]" id="aksrt_term_layout">
<option value="" <?php selected($meta['layout_mode'], ''); ?>><?php esc_html_e('跟随全局设置', 'aksrt'); ?></option>
<option value="card" <?php selected($meta['layout_mode'], 'card'); ?>><?php esc_html_e('卡片模式', 'aksrt'); ?></option>
<option value="list" <?php selected($meta['layout_mode'], 'list'); ?>><?php esc_html_e('列表模式', 'aksrt'); ?></option>
<option value="mini" <?php selected($meta['layout_mode'], 'mini'); ?>><?php esc_html_e('迷你模式', 'aksrt'); ?></option>
</select>
</td>
</tr>
<tr class="form-field">
<th scope="row"><?php esc_html_e('显示设置', 'aksrt'); ?></th>
<td>
<label>
<input type="checkbox" name="aksrt_term_meta[show_header]" value="1" <?php checked(!empty($meta['show_header'])); ?>>
<?php esc_html_e('显示页面头部(标题、描述)', 'aksrt'); ?>
</label><br>
<label>
<input type="checkbox" name="aksrt_term_meta[show_sidebar]" value="1" <?php checked($meta['show_sidebar'] === '1'); ?>>
<?php esc_html_e('强制显示侧栏', 'aksrt'); ?>
</label><br>
<label>
<input type="checkbox" name="aksrt_term_meta[show_featured]" value="1" <?php checked(!empty($meta['show_featured'])); ?>>
<?php esc_html_e('显示推荐文章位', 'aksrt'); ?>
</label>
</td>
</tr>
<?php
}
/**
* Save term meta from form submission.
*/
public function save_term_meta(int $term_id): void {
if (!isset($_POST['aksrt_term_meta']) || !is_array($_POST['aksrt_term_meta'])) {
return;
}
if (!current_user_can('manage_categories')) {
return;
}
$raw = $_POST['aksrt_term_meta'];
$sanitized = [
'cover_url' => 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);
}
}
@@ -0,0 +1,182 @@
<?php
/**
* AKSRT Update Checker — 版本检测与更新提示
*
* Checks for new theme versions from a remote endpoint.
* Shows admin notice when update is available.
* Does NOT implement license verification or anti-piracy (per §0.1).
*
* @package AKSRT
*/
defined('ABSPATH') || exit;
class AKSRT_Update_Checker {
private static $instance = null;
private const OPTION_KEY = 'aksrt_update_check';
private const CHECK_INTERVAL = DAY_IN_SECONDS * 1; // Check daily
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('admin_init', [$this, 'maybe_check']);
add_action('admin_notices', [$this, 'show_update_notice']);
}
/**
* Get current theme version
*/
private function get_current_version(): string {
$theme = wp_get_theme();
return $theme->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 '<div class="notice notice-info is-dismissible">';
echo '<p><strong>' . esc_html__('AKSRT 主题更新', 'aksrt') . '</strong></p>';
echo '<p>' . sprintf(
esc_html__('新版本 %s 可用(当前版本 %s)。', 'aksrt'),
'<strong>' . esc_html($remote) . '</strong>',
esc_html($current)
) . '</p>';
if (!empty($changelog)) {
echo '<p>' . esc_html($changelog) . '</p>';
}
echo '<p>';
if (!empty($download_url)) {
echo '<a href="' . esc_url($download_url) . '" class="button button-primary" target="_blank">'
. esc_html__('下载更新', 'aksrt') . '</a> ';
}
echo '<a href="' . esc_url($dismiss_url) . '" class="button">'
. esc_html__('忽略此版本', 'aksrt') . '</a>';
echo '</p>';
echo '</div>';
}
/**
* 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();
}
});
@@ -0,0 +1,463 @@
<?php
defined('ABSPATH') || exit;
trait AKSRT_DB_Migrator_Part_2 {
/**
* 立即运行(供激活钩子直接调用)。
*/
public static function run(): void {
global $wpdb;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
$charset_collate = $wpdb->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);
// ---------- 1b. 现金余额流水(站内钱包,与积分分离) ----------
$sql_balance_log = "CREATE TABLE {$prefix}aksrt_balance_log (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
delta DECIMAL(18,2) NOT NULL,
balance_after DECIMAL(18,2) 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_balance_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 (zibll-style typed card_pass) ──
$sql_charge_card = "CREATE TABLE {$prefix}aksrt_charge_card (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
card VARCHAR(60) NOT NULL DEFAULT '',
password VARCHAR(80) NOT NULL DEFAULT '',
type VARCHAR(20) NOT NULL DEFAULT 'balance',
amount DECIMAL(10,2) NOT NULL DEFAULT 0,
balance DECIMAL(10,2) NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'active',
meta LONGTEXT DEFAULT NULL,
order_number VARCHAR(32) NOT NULL DEFAULT '',
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_password (password),
KEY idx_card (card),
KEY idx_type (type),
KEY idx_status (status),
KEY idx_used_by (used_by),
KEY idx_order_number (order_number),
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);
}
}
@@ -0,0 +1,40 @@
<?php
defined('ABSPATH') || exit;
trait AKSRT_DB_Migrator_Part_3 {
/**
* 表名访问器(其它模块通过此函数取,避免散落硬编码)。
*/
public static function table(string $name): string {
global $wpdb;
$allowed = [
'credit_log' => 'aksrt_credit_log',
'balance_log' => 'aksrt_balance_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];
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,279 @@
<?php
defined('ABSPATH') || exit;
trait AKSRT_Scripts_Part_2 {
/**
* 前端 debug 上报入口(仅在 WP_DEBUG 模式下写日志)
*/
public function handle_frontend_debug(): void {
check_ajax_referer('aksrt_public_nonce', 'nonce');
if (!defined('WP_DEBUG') || !WP_DEBUG) {
wp_send_json_success(['ignored' => true]);
}
$ctx = [
'source' => isset($_POST['source']) ? sanitize_text_field(wp_unslash($_POST['source'])) : 'frontend',
'event' => isset($_POST['event']) ? sanitize_text_field(wp_unslash($_POST['event'])) : 'unknown',
'page' => isset($_POST['page']) ? esc_url_raw(wp_unslash($_POST['page'])) : '',
'text' => isset($_POST['text']) ? sanitize_text_field(wp_unslash($_POST['text'])) : '',
'size' => isset($_POST['size']) ? (int) wp_unslash($_POST['size']) : 0,
'error' => isset($_POST['error']) ? sanitize_text_field(wp_unslash($_POST['error'])) : '',
];
$this->debug_log('frontend debug event', $ctx);
wp_send_json_success(['logged' => true]);
}
/**
* Passive event listener polyfill — 全局加载,消除 Chrome Violation 警告
*/
public function enqueue_passive_polyfill(): void {
wp_enqueue_script(
'aksrt-passive-events',
get_template_directory_uri() . '/assets/js/passive-events.js',
[],
AKSRT_VERSION,
false
);
}
/**
* 加载后台资源 — 仅在 AKSRT 设置页面加载(与前端样式完全隔离)
*/
public function enqueue_admin($hook): void {
// 只在 AKSRT 设置页面 (SPA 容器) 加载
$screen = get_current_screen();
if (!$screen || $screen->base !== 'toplevel_page_aksrt-settings') {
return;
}
// Admin 专用面板样式 — 独立于前端,不受前端 CSS 影响
wp_enqueue_style(
'aksrt-admin-panel',
AKSRT_URI . '/assets/css/admin/admin-panel.css',
[], // 无依赖,不加载任何前端样式
AKSRT_VERSION
);
// 注意: SPA 主资源 (Vue3 build) 由 AKSRT_Admin::enqueue_admin_assets() 负责加载
}
/**
* 输出运行时 CSS 变量覆盖
*
* 仅注入用户可在后台配置的覆盖值(logo / favicon / primary),
* 主体宽度统一使用固定 token:1280 / 1080 / 300。
*/
public function inline_css_vars(): void {
(new AKSRT_Inline_Style_Builder())->output();
}
/**
* 输出自定义头部/底部代码
*/
public function output_custom_codes(): void {
(new AKSRT_Custom_Code_Renderer())->output_header();
}
public function output_loading_overlay(): void {
(new AKSRT_Loading_Overlay_Renderer())->output();
}
/**
* 输出自定义底部代码
*/
public function output_custom_footer_codes(): void {
(new AKSRT_Custom_Code_Renderer())->output_footer();
}
/**
* Body 类增强
*/
public function body_classes(array $classes): array {
if (is_front_page()) {
$classes[] = 'front-page';
}
if (function_exists('aksrt_is_fixed_homepage_request') && aksrt_is_fixed_homepage_request()) {
$classes[] = 'aksrt-homepage-layout';
$classes[] = 'aksrt-homepage-active';
}
if (is_singular()) {
$classes[] = 'single-post-' . get_the_ID();
}
if (is_archive()) {
$classes[] = 'archive-page';
}
if ((function_exists('aksrt_is_user_center_page') && aksrt_is_user_center_page())
|| (function_exists('aksrt_is_user_home_page') && aksrt_is_user_home_page())
|| get_query_var('user_nicename')) {
$classes[] = 'aksrt-profile';
}
// 区分本人中心与公开主页的 body class
if (function_exists('aksrt_is_user_center_page') && aksrt_is_user_center_page()) {
$classes[] = 'aksrt-user-center';
}
if (function_exists('aksrt_is_user_home_page') && aksrt_is_user_home_page()) {
$classes[] = 'aksrt-user-home';
}
if (function_exists('aksrt_is_auth_page_request') && aksrt_is_auth_page_request()) {
$classes[] = 'auth-page';
}
// Zibll site-layout-1/2/3 (no sidebar / right / left).
if (function_exists('aksrt_body_layout_classes')) {
foreach (aksrt_body_layout_classes() as $layout_class) {
$classes[] = $layout_class;
}
}
return $classes;
}
/**
* 搜索历史同步(登录用户)
*/
public function handle_search_history(): void {
check_ajax_referer('aksrt_public_nonce', 'nonce');
$user_id = get_current_user_id();
if (!$user_id) {
wp_send_json_error(['message' => '未登录'], 401);
}
$mode = isset($_POST['mode']) ? sanitize_key(wp_unslash($_POST['mode'])) : 'get';
$meta_key = 'aksrt_search_history';
if ($mode === 'save') {
$raw = isset($_POST['items']) ? wp_unslash($_POST['items']) : '[]';
$items = json_decode($raw, true);
if (!is_array($items)) {
$items = [];
}
$clean = [];
foreach (array_slice($items, 0, 20) as $item) {
if (!is_array($item) || empty($item['keyword'])) {
continue;
}
$clean[] = [
'keyword' => sanitize_text_field($item['keyword']),
'type' => sanitize_key($item['type'] ?? 'post'),
];
}
update_user_meta($user_id, $meta_key, $clean);
wp_send_json_success(['items' => $clean]);
}
$saved = get_user_meta($user_id, $meta_key, true);
wp_send_json_success(['items' => is_array($saved) ? $saved : []]);
}
/**
* 搜索建议(实时搜索提示)
*/
public function handle_search_suggestions(): void {
check_ajax_referer('aksrt_public_nonce', 'nonce');
$q = isset($_POST['q']) ? sanitize_text_field(wp_unslash($_POST['q'])) : '';
$type = isset($_POST['type']) ? sanitize_key(wp_unslash($_POST['type'])) : 'post';
if (mb_strlen($q) < 2) {
wp_send_json_success(['items' => []]);
}
$items = [];
if ($type === 'post' || $type === '') {
$posts = get_posts([
'post_type' => 'post',
'post_status' => 'publish',
's' => $q,
'posts_per_page' => 6,
'no_found_rows' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
]);
foreach ($posts as $p) {
$items[] = [
'title' => $p->post_title,
'url' => get_permalink($p),
'meta' => get_the_date('', $p),
];
}
} elseif ($type === 'category') {
$terms = get_terms([
'taxonomy' => 'category',
'search' => $q,
'number' => 6,
'hide_empty' => true,
]);
if (!is_wp_error($terms)) {
foreach ($terms as $t) {
$items[] = [
'title' => $t->name,
'url' => get_term_link($t),
'meta' => sprintf(__('%d 篇文章', 'aksrt'), $t->count),
];
}
}
} elseif ($type === 'tag') {
$terms = get_terms([
'taxonomy' => 'post_tag',
'search' => $q,
'number' => 6,
'hide_empty' => true,
]);
if (!is_wp_error($terms)) {
foreach ($terms as $t) {
$items[] = [
'title' => $t->name,
'url' => get_term_link($t),
'meta' => sprintf(__('%d 篇文章', 'aksrt'), $t->count),
];
}
}
}
wp_send_json_success(['items' => $items]);
}
private function debug_log(string $message, array $context = []): void {
if (!defined('WP_DEBUG') || !WP_DEBUG) {
return;
}
$payload = '[AKSRT Scripts] ' . $message;
if (!empty($context)) {
$json = wp_json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (false !== $json) {
$payload .= ' ' . $json;
}
}
error_log($payload);
}
/**
* Resolve public route URLs from the rebuilt mainline ownership.
*
* @return array<string, string>
*/
private function resolve_frontend_routes(): array {
return [
'cart' => $this->resolve_template_page_url('templates/pages/page-cart.php', '/cart/'),
'orders' => $this->resolve_template_page_url('templates/pages/page-orders.php', '/orders/'),
'resources' => home_url('/resources/'),
];
}
/**
* Resolve the permalink for a template-owned page with a stable fallback.
*/
private function resolve_template_page_url(string $template_file, string $fallback): string {
if (function_exists('aksrt_find_template_page')) {
$page = aksrt_find_template_page($template_file);
if ($page instanceof WP_Post) {
return get_permalink($page);
}
}
return home_url($fallback);
}
}
@@ -0,0 +1,399 @@
<?php
defined('ABSPATH') || exit;
trait AKSRT_Setup_Part_1 {
public function register_sidebars(): void {
$fluid_wrapper_before = '<div id="%1$s" class="zib-widget %2$s">';
$card_wrapper_before = '<div id="%1$s" class="zib-widget %2$s">';
$title_before = '<h3>';
$title_after = '</h3>';
// ===== 全站 / 首页挂载位(对齐 Zibll 首页骨架) =====
register_sidebar([
'id' => 'all_top_fluid',
'name' => __('全站 - 顶部全宽', 'aksrt'),
'description' => __('显示在所有页面内容区上方的全宽挂载位。', 'aksrt'),
'before_widget' => $fluid_wrapper_before,
'after_widget' => '</div>',
'before_title' => $title_before,
'after_title' => $title_after,
]);
register_sidebar([
'id' => 'all_bottom_fluid',
'name' => __('全站 - 底部全宽', 'aksrt'),
'description' => __('显示在所有页面页脚上方的全宽挂载位。', 'aksrt'),
'before_widget' => $fluid_wrapper_before,
'after_widget' => '</div>',
'before_title' => $title_before,
'after_title' => $title_after,
]);
register_sidebar([
'id' => 'all_sidebar_top',
'name' => __('全站 - 侧栏顶部', 'aksrt'),
'description' => __('显示在全站侧栏顶部。', 'aksrt'),
'before_widget' => $card_wrapper_before,
'after_widget' => '</div>',
'before_title' => $title_before,
'after_title' => $title_after,
]);
register_sidebar([
'id' => 'all_sidebar_bottom',
'name' => __('全站 - 侧栏底部', 'aksrt'),
'description' => __('显示在全站侧栏底部。', 'aksrt'),
'before_widget' => $card_wrapper_before,
'after_widget' => '</div>',
'before_title' => $title_before,
'after_title' => $title_after,
]);
register_sidebar([
'id' => 'all_footer',
'name' => __('全站页脚小工具', 'aksrt'),
'description' => __('显示在页脚内部的统一小工具区域。', 'aksrt'),
'before_widget' => $card_wrapper_before,
'after_widget' => '</div>',
'before_title' => $title_before,
'after_title' => $title_after,
]);
register_sidebar([
'id' => 'mobile_nav_fluid',
'name' => __('移动端菜单底部', 'aksrt'),
'description' => __('显示在移动端抽屉菜单底部。', 'aksrt'),
'before_widget' => $card_wrapper_before,
'after_widget' => '</div>',
'before_title' => $title_before,
'after_title' => $title_after,
]);
register_sidebar([
'id' => 'home_top_fluid',
'name' => __('首页 - 顶部全宽', 'aksrt'),
'description' => __('显示在首页主内容上方的全宽区域。', 'aksrt'),
'before_widget' => $fluid_wrapper_before,
'after_widget' => '</div>',
'before_title' => $title_before,
'after_title' => $title_after,
]);
register_sidebar([
'id' => 'home_top_content',
'name' => __('首页 - 内容上方', 'aksrt'),
'description' => __('显示在首页主内容区顶部。', 'aksrt'),
'before_widget' => $card_wrapper_before,
'after_widget' => '</div>',
'before_title' => $title_before,
'after_title' => $title_after,
]);
register_sidebar([
'id' => 'home_bottom_content',
'name' => __('首页 - 内容下方', 'aksrt'),
'description' => __('显示在首页主内容区底部。', 'aksrt'),
'before_widget' => $card_wrapper_before,
'after_widget' => '</div>',
'before_title' => $title_before,
'after_title' => $title_after,
]);
register_sidebar([
'id' => 'home_bottom_fluid',
'name' => __('首页 - 底部全宽', 'aksrt'),
'description' => __('显示在首页页脚上方的全宽区域。', 'aksrt'),
'before_widget' => $fluid_wrapper_before,
'after_widget' => '</div>',
'before_title' => $title_before,
'after_title' => $title_after,
]);
register_sidebar([
'id' => 'home_sidebar',
'name' => __('首页侧栏', 'aksrt'),
'description' => __('显示在首页右侧栏。', 'aksrt'),
'before_widget' => $card_wrapper_before,
'after_widget' => '</div>',
'before_title' => $title_before,
'after_title' => $title_after,
]);
// 新前端使用的侧边栏 (sidebar.php)
register_sidebar([
'id' => 'sidebar-blog',
'name' => __('博客侧边栏', 'aksrt'),
'description' => __('显示在首页、归档等列表页右侧(新前端)。', 'aksrt'),
'before_widget' => $card_wrapper_before,
'after_widget' => '</div>',
'before_title' => $title_before,
'after_title' => $title_after,
]);
// 文章详情页侧栏
register_sidebar([
'id' => 'sidebar-article',
'name' => __('文章侧边栏', 'aksrt'),
'description' => __('显示在文章详情页右侧。', 'aksrt'),
'before_widget' => $card_wrapper_before,
'after_widget' => '</div>',
'before_title' => $title_before,
'after_title' => $title_after,
]);
// ===== 文章页 Widget 区域(模板zibll 多位置) =====
register_sidebar([
'id' => 'single_top_fluid',
'name' => __('文章页 · 顶部全宽', 'aksrt'),
'description' => __('文章页顶部全宽区域(在封面/面包屑之上)。', 'aksrt'),
'before_widget' => $fluid_wrapper_before,
'after_widget' => '</div>',
'before_title' => $title_before,
'after_title' => $title_after,
]);
register_sidebar([
'id' => 'single_top_content',
'name' => __('文章页 · 内容上方', 'aksrt'),
'description' => __('文章主体卡片上方(在内容列内)。', 'aksrt'),
'before_widget' => $card_wrapper_before,
'after_widget' => '</div>',
'before_title' => $title_before,
'after_title' => $title_after,
]);
register_sidebar([
'id' => 'single_bottom_content',
'name' => __('文章页 · 内容下方', 'aksrt'),
'description' => __('评论区下方(在内容列内)。', 'aksrt'),
'before_widget' => $card_wrapper_before,
'after_widget' => '</div>',
'before_title' => $title_before,
'after_title' => $title_after,
]);
register_sidebar([
'id' => 'single_bottom_fluid',
'name' => __('文章页 · 底部全宽', 'aksrt'),
'description' => __('文章页底部全宽区域(在页脚之上)。', 'aksrt'),
'before_widget' => $fluid_wrapper_before,
'after_widget' => '</div>',
'before_title' => $title_before,
'after_title' => $title_after,
]);
register_sidebar([
'id' => 'sidebar-footer-1',
'name' => __('页脚栏 1', 'aksrt'),
'description' => '',
'before_widget' => '<div class="footer-widget %2$s">',
'after_widget' => '</div>',
'before_title' => '<h4 class="footer-widget-title">',
'after_title' => '</h4>',
]);
register_sidebar([
'id' => 'sidebar-footer-2',
'name' => __('页脚栏 2', 'aksrt'),
'description' => '',
'before_widget' => '<div class="footer-widget %2$s">',
'after_widget' => '</div>',
'before_title' => '<h4 class="footer-widget-title">',
'after_title' => '</h4>',
]);
// ===== 分类页 Widget 区域 =====
register_sidebar([
'id' => 'cat_top_fluid',
'name' => __('分类页 · 顶部全宽', 'aksrt'),
'description' => __('分类归档页顶部全宽区域。', 'aksrt'),
'before_widget' => $fluid_wrapper_before,
'after_widget' => '</div>',
'before_title' => $title_before,
'after_title' => $title_after,
]);
register_sidebar([
'id' => 'cat_top_content',
'name' => __('分类页 · 内容上方', 'aksrt'),
'description' => __('分类归档页内容区上方。', 'aksrt'),
'before_widget' => $card_wrapper_before,
'after_widget' => '</div>',
'before_title' => $title_before,
'after_title' => $title_after,
]);
register_sidebar([
'id' => 'cat_bottom_content',
'name' => __('分类页 · 内容下方', 'aksrt'),
'description' => __('分类归档页内容区下方。', 'aksrt'),
'before_widget' => $card_wrapper_before,
'after_widget' => '</div>',
'before_title' => $title_before,
'after_title' => $title_after,
]);
register_sidebar([
'id' => 'cat_bottom_fluid',
'name' => __('分类页 · 底部全宽', 'aksrt'),
'description' => __('分类归档页底部全宽区域。', 'aksrt'),
'before_widget' => $fluid_wrapper_before,
'after_widget' => '</div>',
'before_title' => $title_before,
'after_title' => $title_after,
]);
register_sidebar([
'id' => 'cat_sidebar',
'name' => __('分类页 · 侧栏', 'aksrt'),
'description' => __('分类归档页侧栏区域。', 'aksrt'),
'before_widget' => $card_wrapper_before,
'after_widget' => '</div>',
'before_title' => $title_before,
'after_title' => $title_after,
]);
// ===== 标签页 Widget 区域 =====
register_sidebar([
'id' => 'tag_top_fluid',
'name' => __('标签页 · 顶部全宽', 'aksrt'),
'description' => __('标签归档页顶部全宽区域。', 'aksrt'),
'before_widget' => $fluid_wrapper_before,
'after_widget' => '</div>',
'before_title' => $title_before,
'after_title' => $title_after,
]);
register_sidebar([
'id' => 'tag_top_content',
'name' => __('标签页 · 内容上方', 'aksrt'),
'description' => __('标签归档页内容区上方。', 'aksrt'),
'before_widget' => $card_wrapper_before,
'after_widget' => '</div>',
'before_title' => $title_before,
'after_title' => $title_after,
]);
register_sidebar([
'id' => 'tag_bottom_content',
'name' => __('标签页 · 内容下方', 'aksrt'),
'description' => __('标签归档页内容区下方。', 'aksrt'),
'before_widget' => $card_wrapper_before,
'after_widget' => '</div>',
'before_title' => $title_before,
'after_title' => $title_after,
]);
register_sidebar([
'id' => 'tag_bottom_fluid',
'name' => __('标签页 · 底部全宽', 'aksrt'),
'description' => __('标签归档页底部全宽区域。', 'aksrt'),
'before_widget' => $fluid_wrapper_before,
'after_widget' => '</div>',
'before_title' => $title_before,
'after_title' => $title_after,
]);
register_sidebar([
'id' => 'tag_sidebar',
'name' => __('标签页 · 侧栏', 'aksrt'),
'description' => __('标签归档页侧栏区域。', 'aksrt'),
'before_widget' => $card_wrapper_before,
'after_widget' => '</div>',
'before_title' => $title_before,
'after_title' => $title_after,
]);
// ===== 搜索页 Widget 区域 =====
register_sidebar([
'id' => 'search_top_fluid',
'name' => __('搜索页 · 顶部全宽', 'aksrt'),
'description' => __('搜索结果页顶部全宽区域。', 'aksrt'),
'before_widget' => $fluid_wrapper_before,
'after_widget' => '</div>',
'before_title' => $title_before,
'after_title' => $title_after,
]);
register_sidebar([
'id' => 'search_top_content',
'name' => __('搜索页 · 内容上方', 'aksrt'),
'description' => __('搜索结果页内容区上方。', 'aksrt'),
'before_widget' => $card_wrapper_before,
'after_widget' => '</div>',
'before_title' => $title_before,
'after_title' => $title_after,
]);
register_sidebar([
'id' => 'search_bottom_content',
'name' => __('搜索页 · 内容下方', 'aksrt'),
'description' => __('搜索结果页内容区下方。', 'aksrt'),
'before_widget' => $card_wrapper_before,
'after_widget' => '</div>',
'before_title' => $title_before,
'after_title' => $title_after,
]);
register_sidebar([
'id' => 'search_bottom_fluid',
'name' => __('搜索页 · 底部全宽', 'aksrt'),
'description' => __('搜索结果页底部全宽区域。', 'aksrt'),
'before_widget' => $fluid_wrapper_before,
'after_widget' => '</div>',
'before_title' => $title_before,
'after_title' => $title_after,
]);
register_sidebar([
'id' => 'search_sidebar',
'name' => __('搜索页 · 侧栏', 'aksrt'),
'description' => __('搜索结果页侧栏区域。', 'aksrt'),
'before_widget' => $card_wrapper_before,
'after_widget' => '</div>',
'before_title' => $title_before,
'after_title' => $title_after,
]);
register_sidebar([
'id' => 'archive_bottom_fluid',
'name' => __('归档页 · 底部全宽', 'aksrt'),
'description' => __('分类/标签/归档页底部全宽区域。', 'aksrt'),
'before_widget' => $fluid_wrapper_before,
'after_widget' => '</div>',
'before_title' => $title_before,
'after_title' => $title_after,
]);
register_sidebar([
'id' => 'author-sidebar',
'name' => __('作者页 · 侧栏', 'aksrt'),
'description' => __('作者归档页侧栏区域。', 'aksrt'),
'before_widget' => $card_wrapper_before,
'after_widget' => '</div>',
'before_title' => $title_before,
'after_title' => $title_after,
]);
// ── Shop widget areas (§6.9) ──
$shop_widgets = [
'shop_product_top_fluid' => ['商品详情页 · 顶部通栏', '商品详情页顶部全宽区域。'],
'shop_product_sidebar' => ['商品详情页 · 侧栏', '商品详情页侧栏区域(PC 端)。'],
'shop_product_bottom_fluid' => ['商品详情页 · 底部通栏', '商品详情页底部全宽区域。'],
'shop_cart_top_fluid' => ['购物车页 · 顶部通栏', '购物车页顶部全宽区域。'],
'shop_cart_bottom_fluid' => ['购物车页 · 底部通栏', '购物车页底部全宽区域。'],
];
foreach ($shop_widgets as $sid => $meta) {
register_sidebar([
'id' => $sid,
'name' => $meta[0],
'description' => $meta[1],
'before_widget' => $card_wrapper_before,
'after_widget' => '</div>',
'before_title' => $title_before,
'after_title' => $title_after,
]);
}
}
}
@@ -0,0 +1,116 @@
<?php
defined('ABSPATH') || exit;
trait AKSRT_Setup_Part_2 {
/**
* 隐藏默认WordPress菜单项目 + zibll 风格运营菜单分隔线
*/
public function hide_default_menus(): void {
// 移除评论默认菜单(已移除该操作以恢复 Comments 菜单)
// 之前的实现:remove_menu_page('edit-comments.php');
// 移除链接管理(已废弃但仍可能存在)
//remove_menu_page('link-manager.php');
// 移除仪表盘中的不必要组件
remove_meta_box('dashboard_primary', 'dashboard', 'normal');
remove_meta_box('dashboard_quick_press', 'dashboard', 'side');
// Separators around shop (35) and forum (40) — match zibll admin_menu_separator.
global $menu;
if (!is_array($menu)) {
return;
}
if (!isset($menu[34])) {
$menu[34] = ['', 'read', 'separator-aksrt-shop', '', 'wp-menu-separator'];
}
if (!isset($menu[39])) {
$menu[39] = ['', 'read', 'separator-aksrt-forum', '', 'wp-menu-separator'];
}
}
/**
* Register shop rewrite rules for:
* - /shop/{ID}.html — product detail (ID-based, .html suffix)
* - /shop/{ID}.html/comment-{page} — product comment pagination
* - /{cart_slug}/ — cart page (fixed alias)
*
* Runs at init priority 20 (after CPT registration at priority 1).
*/
public function register_shop_rewrite_rules(): void {
if (!class_exists('AKSRT_Settings')) {
return;
}
$shop = AKSRT_Settings::get_shop_settings();
if (empty($shop['enabled'])) {
return;
}
$rw = $shop['rewrite'] ?? [];
$product_slug = sanitize_key((string) ($rw['product_slug'] ?? 'product'));
$cart_slug = sanitize_key((string) ($rw['cart_slug'] ?? 'cart'));
$suffix_html = !empty($rw['suffix_html']);
// Product ID-based .html routes: /shop/{ID}.html
if ($suffix_html) {
add_rewrite_rule(
'^shop/(\d+)\.html/comment-page-([0-9]{1,})/?$',
'index.php?post_type=aksrt_product&p=$matches[1]&cpage=$matches[2]',
'top'
);
add_rewrite_rule(
'^shop/(\d+)\.html/?$',
'index.php?post_type=aksrt_product&p=$matches[1]',
'top'
);
// Slug-based .html: /{product_slug}/{slug}.html
add_rewrite_rule(
'^' . preg_quote($product_slug) . '/([^/]+)\.html/comment-page-([0-9]{1,})/?$',
'index.php?post_type=aksrt_product&aksrt_product=$matches[1]&cpage=$matches[3]',
'top'
);
add_rewrite_rule(
'^' . preg_quote($product_slug) . '/([^/]+)\.html/?$',
'index.php?post_type=aksrt_product&aksrt_product=$matches[1]',
'top'
);
}
// Cart page: /{cart_slug}/
add_rewrite_rule(
'^' . preg_quote($cart_slug) . '/?$',
'index.php?pagename=' . $cart_slug,
'top'
);
}
/**
* Register shop-specific query vars.
*/
public function register_shop_query_vars(array $vars): array {
$vars[] = 'product_id';
$vars[] = 'shop_action';
return $vars;
}
/**
* Template redirect for shop pages.
*
* - Single aksrt_product: serve page-product.php template
* - Cart page: serve page-cart.php template
*/
public function shop_template_redirect(): void {
// Single product: if the main query resolves to aksrt_product, use our template
if (is_singular('aksrt_product')) {
$tpl = AKSRT_DIR . '/templates/pages/page-product.php';
if (file_exists($tpl)) {
// Set product_id query var for template consumption
set_query_var('product_id', get_queried_object_id());
include $tpl;
exit;
}
}
}
}