Mirror the reference theme's universal picker on FA5: a generated full catalog of the bundled FA5 Free 5.15.4 fonts (src/Icons/fa5-icons.php, generated from the shipped CSS + SVG fonts), a zibsvg group parsed from the runtime sprite so it cannot drift, and GET /aksrt/v1/icons/catalog. Shared admin pieces: aksrt_icon_markup()/aksrt_sanitize_icon_value() with an SVG allowlist, the picker field/modal assets, and one canonical aksrt-font-awesome handle registered through aksrt_enqueue_fontawesome().
361 lines
13 KiB
PHP
361 lines
13 KiB
PHP
<?php
|
|
/**
|
|
* @package AKSRT
|
|
*/
|
|
|
|
namespace AKSRT\Admin;
|
|
|
|
defined('ABSPATH') || exit;
|
|
|
|
class Admin {
|
|
|
|
private static $instance = null;
|
|
private const PAGE_SLUG = 'aksrt-settings';
|
|
private const API_NAMESPACE = 'aksrt/v1';
|
|
|
|
public static function get_instance(): self {
|
|
if (null === self::$instance) {
|
|
self::$instance = new self();
|
|
}
|
|
|
|
return self::$instance;
|
|
}
|
|
|
|
private function __construct() {}
|
|
|
|
public function init(): void {
|
|
add_action('admin_menu', [$this, 'add_admin_menu']);
|
|
add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_assets']);
|
|
add_filter('script_loader_tag', [$this, 'add_module_type_attribute'], 10, 2);
|
|
add_action('rest_api_init', [$this, 'register_rest_routes']);
|
|
|
|
add_action('wp_ajax_aksrt_load_more_posts', [$this, 'ajax_load_more_posts']);
|
|
add_action('wp_ajax_nopriv_aksrt_load_more_posts', [$this, 'ajax_load_more_posts']);
|
|
add_action('wp_ajax_aksrt_load_more_comments', [$this, 'ajax_load_more_comments']);
|
|
add_action('wp_ajax_nopriv_aksrt_load_more_comments', [$this, 'ajax_load_more_comments']);
|
|
}
|
|
|
|
public function add_admin_menu(): void {
|
|
add_menu_page(
|
|
'AKSRT 主题设置',
|
|
'AKSRT',
|
|
'manage_options',
|
|
self::PAGE_SLUG,
|
|
[$this, 'render_admin_page'],
|
|
'dashicons-admin-generic',
|
|
58
|
|
);
|
|
}
|
|
|
|
public function render_admin_page(): void {
|
|
echo '<div class="wrap aksrt-admin-wrap">';
|
|
echo '<div id="aksrt-admin-app"></div>';
|
|
echo '</div>';
|
|
}
|
|
|
|
public function enqueue_admin_assets($hook): void {
|
|
$screen = function_exists('get_current_screen') ? get_current_screen() : null;
|
|
if (!$screen || $screen->base !== 'toplevel_page_' . self::PAGE_SLUG) {
|
|
return;
|
|
}
|
|
|
|
$build_dir = trailingslashit(AKSRT_DIR) . 'assets/admin/build/';
|
|
$build_uri = trailingslashit(AKSRT_URI) . 'assets/admin/build/';
|
|
$entry = $build_dir . 'index.js';
|
|
|
|
if (!is_file($entry)) {
|
|
add_action('admin_notices', static function (): void {
|
|
if (!current_user_can('manage_options')) {
|
|
return;
|
|
}
|
|
echo '<div class="notice notice-error"><p>';
|
|
echo esc_html__('AKSRT 管理后台构建缺失:请在 assets/admin 执行 npm run build,并完整部署 build/ 目录。', 'aksrt');
|
|
echo '</p></div>';
|
|
});
|
|
return;
|
|
}
|
|
|
|
$missing_chunks = self::find_missing_admin_chunks($build_dir, $entry);
|
|
if ($missing_chunks !== []) {
|
|
add_action('admin_notices', static function () use ($missing_chunks): void {
|
|
if (!current_user_can('manage_options')) {
|
|
return;
|
|
}
|
|
echo '<div class="notice notice-error"><p>';
|
|
echo esc_html__('AKSRT 管理后台构建不完整(动态模块缺失),请重新完整部署 assets/admin/build/,不要只覆盖 index.js。缺失:', 'aksrt');
|
|
echo ' ' . esc_html(implode(', ', array_slice($missing_chunks, 0, 8)));
|
|
if (count($missing_chunks) > 8) {
|
|
echo '…';
|
|
}
|
|
echo '</p></div>';
|
|
});
|
|
// Still enqueue entry so console shows the real import error if an admin insists;
|
|
// notice makes the deploy mistake explicit.
|
|
}
|
|
|
|
$css_files = [
|
|
'naive-ui.css',
|
|
'index.css',
|
|
'index2.css',
|
|
];
|
|
foreach ($css_files as $css_file) {
|
|
$path = $build_dir . $css_file;
|
|
if (file_exists($path)) {
|
|
wp_enqueue_style(
|
|
'aksrt-admin-' . sanitize_key(str_replace(['.', '/'], '-', $css_file)),
|
|
$build_uri . $css_file,
|
|
[],
|
|
(string) filemtime($path)
|
|
);
|
|
}
|
|
}
|
|
|
|
// Use the same local FA5 pack as the public stack.
|
|
aksrt_enqueue_fontawesome();
|
|
|
|
// Frontend SVG sprite (defines window.aksrt_svgs and injects the symbol
|
|
// sheet) so schema icon fields can preview built-in zibsvg values.
|
|
wp_enqueue_script(
|
|
'aksrt-svg-sprite',
|
|
get_template_directory_uri() . '/assets/js/runtime/svg-icon.js',
|
|
['jquery'],
|
|
(string) filemtime(get_template_directory() . '/assets/js/runtime/svg-icon.js'),
|
|
true
|
|
);
|
|
|
|
wp_enqueue_script(
|
|
'aksrt-admin-app',
|
|
$build_uri . 'index.js',
|
|
[],
|
|
(string) filemtime($entry),
|
|
true
|
|
);
|
|
|
|
wp_localize_script('aksrt-admin-app', 'aksrtAdminData', [
|
|
'apiUrl' => esc_url_raw(rest_url(self::API_NAMESPACE)),
|
|
'nonce' => wp_create_nonce('wp_rest'),
|
|
'siteUrl' => home_url('/'),
|
|
'adminUrl' => admin_url('/'),
|
|
'themeVersion' => wp_get_theme()->get('Version') ?: '3.4.0-dev',
|
|
'themeUri' => defined('AKSRT_URI') ? AKSRT_URI : get_template_directory_uri(),
|
|
'buildOk' => $missing_chunks === [],
|
|
]);
|
|
|
|
wp_enqueue_media();
|
|
}
|
|
|
|
/**
|
|
* Parse index.js dynamic imports and report missing chunk files.
|
|
*
|
|
* @return string[]
|
|
*/
|
|
private static function find_missing_admin_chunks(string $build_dir, string $entry_file): array {
|
|
$src = file_get_contents($entry_file);
|
|
if (!is_string($src) || $src === '') {
|
|
return ['index.js unreadable'];
|
|
}
|
|
|
|
if (!preg_match_all('/\.\/([A-Za-z0-9_.-]+\.js)/', $src, $matches)) {
|
|
return [];
|
|
}
|
|
|
|
$missing = [];
|
|
foreach (array_unique($matches[1]) as $chunk) {
|
|
if (!is_file($build_dir . $chunk)) {
|
|
$missing[] = $chunk;
|
|
}
|
|
}
|
|
|
|
return $missing;
|
|
}
|
|
|
|
public function add_module_type_attribute(string $tag, string $handle): string {
|
|
if ('aksrt-admin-app' === $handle) {
|
|
return str_replace('<script ', '<script type="module" ', $tag);
|
|
}
|
|
return $tag;
|
|
}
|
|
|
|
public function register_rest_routes(): void {
|
|
// FQCN required after PSR-4 move into AKSRT\Admin (unqualified class_exists skips all routes).
|
|
$controllers = [
|
|
GlobalController::class,
|
|
PageDisplayController::class,
|
|
PostListController::class,
|
|
PermissionsController::class,
|
|
UserController::class,
|
|
PaymentController::class,
|
|
ToolsController::class,
|
|
ImportExportController::class,
|
|
ExtensionController::class,
|
|
CommentController::class,
|
|
MessageController::class,
|
|
AIController::class,
|
|
\AKSRT\Icons\IconController::class,
|
|
];
|
|
|
|
foreach ($controllers as $class_name) {
|
|
if (!class_exists($class_name) || !method_exists($class_name, 'get_instance')) {
|
|
continue;
|
|
}
|
|
|
|
$controller = $class_name::get_instance();
|
|
if (method_exists($controller, 'register_routes')) {
|
|
$controller->register_routes();
|
|
}
|
|
}
|
|
}
|
|
|
|
public static function sanitize_public_query_vars(array $vars): array {
|
|
$allowed = [
|
|
'post_type', 'post_status', 'posts_per_page', 'paged', 'orderby', 'order',
|
|
'cat', 'tag', 'tag_id', 'category_name', 's', 'author', 'author_name', 'offset',
|
|
'post__in', 'post__not_in', 'category__in', 'category__not_in', 'tag__in',
|
|
'tag__not_in', 'tag_slug__in', 'ignore_sticky_posts',
|
|
];
|
|
$clean = [];
|
|
foreach ($allowed as $key) {
|
|
if (!array_key_exists($key, $vars)) {
|
|
continue;
|
|
}
|
|
$value = $vars[$key];
|
|
if (is_array($value)) {
|
|
$clean[$key] = array_map('sanitize_text_field', $value);
|
|
} else {
|
|
$clean[$key] = sanitize_text_field((string) $value);
|
|
}
|
|
}
|
|
if (isset($clean['posts_per_page'])) {
|
|
$clean['posts_per_page'] = max(1, min(50, (int) $clean['posts_per_page']));
|
|
}
|
|
if (isset($clean['paged'])) {
|
|
$clean['paged'] = max(1, (int) $clean['paged']);
|
|
}
|
|
foreach (['author', 'cat', 'tag_id', 'offset'] as $int_key) {
|
|
if (isset($clean[$int_key])) {
|
|
$clean[$int_key] = max(0, (int) $clean[$int_key]);
|
|
}
|
|
}
|
|
foreach (['post__in', 'post__not_in', 'category__in', 'category__not_in', 'tag__in', 'tag__not_in'] as $ids_key) {
|
|
if (isset($clean[$ids_key])) {
|
|
$clean[$ids_key] = array_values(array_filter(array_map('absint', (array) $clean[$ids_key])));
|
|
}
|
|
}
|
|
if (isset($clean['ignore_sticky_posts'])) {
|
|
$clean['ignore_sticky_posts'] = !empty($clean['ignore_sticky_posts']);
|
|
}
|
|
return $clean;
|
|
}
|
|
|
|
public static function sanitize_public_render_config(array $config): array {
|
|
$clean = [];
|
|
if (isset($config['type'])) {
|
|
$type = sanitize_key((string) $config['type']);
|
|
if (in_array($type, ['auto', 'list', 'card'], true)) {
|
|
$clean['type'] = $type;
|
|
}
|
|
}
|
|
if (isset($config['context'])) {
|
|
$clean['context'] = sanitize_key((string) $config['context']);
|
|
}
|
|
if (isset($config['query_context'])) {
|
|
$clean['query_context'] = sanitize_key((string) $config['query_context']);
|
|
}
|
|
if (isset($config['term_id'])) {
|
|
$clean['term_id'] = absint($config['term_id']);
|
|
}
|
|
foreach (['no_author', 'no_margin', 'is_card', 'skip_wrapper'] as $bool_key) {
|
|
if (array_key_exists($bool_key, $config)) {
|
|
$clean[$bool_key] = !empty($config[$bool_key]);
|
|
}
|
|
}
|
|
foreach (['is_mult_thumb', 'is_no_thumb'] as $layout_key) {
|
|
if (array_key_exists($layout_key, $config)) {
|
|
$clean[$layout_key] = $config[$layout_key] === 'disable' ? 'disable' : !empty($config[$layout_key]);
|
|
}
|
|
}
|
|
if (isset($config['highlight_query'])) {
|
|
$clean['highlight_query'] = sanitize_text_field((string) $config['highlight_query']);
|
|
}
|
|
return $clean;
|
|
}
|
|
|
|
|
|
public function ajax_load_more_posts(): void {
|
|
$nonce = isset($_POST['nonce']) ? (string) wp_unslash($_POST['nonce']) : '';
|
|
if ($nonce === '' || !wp_verify_nonce($nonce, 'aksrt_public_nonce')) {
|
|
wp_send_json_error(['message' => __('安全验证失败', 'aksrt')], 403);
|
|
}
|
|
|
|
$page = max(1, (int) ($_POST['page'] ?? 1));
|
|
$query_vars = json_decode(wp_unslash((string) ($_POST['query_vars'] ?? '{}')), true);
|
|
$render_config = json_decode(wp_unslash((string) ($_POST['render_config'] ?? '{}')), true);
|
|
$query_vars = is_array($query_vars) ? self::sanitize_public_query_vars($query_vars) : [];
|
|
$render_config = is_array($render_config) ? self::sanitize_public_render_config($render_config) : [];
|
|
$context = sanitize_key((string) ($render_config['context'] ?? 'archive'));
|
|
if ($context === '') {
|
|
$context = 'archive';
|
|
}
|
|
$resolved = aksrt_resolve_post_list_render_config([
|
|
'context' => $context,
|
|
'overrides' => $render_config,
|
|
]);
|
|
$render_config = array_merge($resolved, $render_config, ['skip_wrapper' => true]);
|
|
$query_vars['paged'] = $page;
|
|
if (empty($query_vars['post_status'])) {
|
|
$query_vars['post_status'] = 'publish';
|
|
}
|
|
if (empty($query_vars['post_type'])) {
|
|
$query_vars['post_type'] = 'post';
|
|
}
|
|
|
|
$query = new \WP_Query($query_vars);
|
|
ob_start();
|
|
aksrt_render_post_list($render_config, $query, true);
|
|
$html = (string) ob_get_clean();
|
|
|
|
wp_send_json_success([
|
|
'html' => $html,
|
|
'has_more' => $page < (int) $query->max_num_pages,
|
|
'page' => $page,
|
|
]);
|
|
}
|
|
|
|
public function ajax_load_more_comments(): void {
|
|
$nonce = isset($_POST['nonce']) ? (string) wp_unslash($_POST['nonce']) : '';
|
|
if ($nonce === '' || !wp_verify_nonce($nonce, 'aksrt_public_nonce')) {
|
|
wp_send_json_error(['message' => __('安全验证失败', 'aksrt')], 403);
|
|
}
|
|
|
|
$post_id = absint($_POST['post_id'] ?? 0);
|
|
$page = max(1, (int) ($_POST['page'] ?? 1));
|
|
if ($post_id <= 0) {
|
|
wp_send_json_error(['message' => __('无效的文章 ID', 'aksrt')], 400);
|
|
}
|
|
|
|
$order = sanitize_key((string) ($_POST['comment_order'] ?? $_POST['order'] ?? 'newest'));
|
|
$author_only = !empty($_POST['author_only']) || (isset($_POST['aksrt_author_only']) && (int) $_POST['aksrt_author_only'] === 1);
|
|
|
|
if (!function_exists('aksrt_comments_query_for_post') || !function_exists('aksrt_comments_render_items_html')) {
|
|
wp_send_json_error(['message' => __('评论加载不可用', 'aksrt')]);
|
|
}
|
|
|
|
$result = aksrt_comments_query_for_post($post_id, [
|
|
'page' => $page,
|
|
'order' => $order,
|
|
'author_only' => $author_only,
|
|
]);
|
|
|
|
$html = aksrt_comments_render_items_html($result['comments'], $result['settings'], $result['order']);
|
|
|
|
wp_send_json_success([
|
|
'html' => $html,
|
|
'has_more' => $page < (int) $result['max_pages'],
|
|
'page' => $page,
|
|
'order' => $result['order'],
|
|
'total' => (int) $result['total'],
|
|
'max_pages'=> (int) $result['max_pages'],
|
|
]);
|
|
}
|
|
}
|