diff --git a/.zcode/plans/plan-sess_c373fb29-30ab-4cd5-8649-5347223b88e2.md b/.zcode/plans/plan-sess_c373fb29-30ab-4cd5-8649-5347223b88e2.md new file mode 100644 index 00000000..dff45b22 --- /dev/null +++ b/.zcode/plans/plan-sess_c373fb29-30ab-4cd5-8649-5347223b88e2.md @@ -0,0 +1,51 @@ +# 公开用户主页去参数化 + 全链路无刷新加载 + +## 目标 + +`/author/demo/` 的 URL 永远干净(无 `?tab=`、`follow_sub`、`follow_page`、`posts_page` 等),Tab 切换、粉丝/关注子切换、各面板分页全部走 REST AJAX 无刷新加载。单路径实现,不写双路/兼容/迁移代码。 + +现状:主 Tab 已是 DOM 切换,但 `author.js` 会把 `?tab=` 写进 URL;follow 子切换和 comments/follow 分页是带参整页链接;posts/favorites 分页走 `aksrt_render_pagination()`(numbered 模式产出 `?posts_page=N` 链接;load_more/infinite 走 admin-ajax)。 + +## 服务端 + +### 1. 上下文构建 — `inc/helpers/aksrt-user-home.php` +- `aksrt_get_user_home_page_context()` 删除全部 `$_GET` 读取(第 104、134-136 行):`active_tab` 恒等于 `default_tab`,三个 page 恒为 1(保留键,供 REST 覆写);新增 `follow_page => 1`、`follow_sub => 'followers'` 键。 +- 新增 `aksrt_render_user_home_pagination(int $total, int $current)`:输出 `'; + } +} + +if (!function_exists('aksrt_capture_user_home_panel')) { + /** + * 捕获公开用户主页面板 body(templates/parts/user-home/panel-{tab}.php)。 + * + * @param string $tab 面板键名。 + * @param array $ctx aksrt_get_user_home_page_context() 上下文。 + * @return string 捕获的 HTML,模板不存在时为空串。 + */ + function aksrt_capture_user_home_panel(string $tab, array $ctx): string { + $template = locate_template(['templates/parts/user-home/panel-' . sanitize_key($tab) . '.php'], false, false); + if (!$template) { + return ''; + } + + ob_start(); + load_template($template, false, $ctx); + return (string) ob_get_clean(); + } +} diff --git a/src/User/Home.php b/src/User/Home.php index d54e59cc..3e39c506 100644 --- a/src/User/Home.php +++ b/src/User/Home.php @@ -25,6 +25,95 @@ class Home { public function init(): void { $this->register_default_tabs(); + add_action('rest_api_init', [$this, 'register_rest_routes']); + } + + /** + * Register public user-home panel fragment routes. + */ + public function register_rest_routes(): void { + register_rest_route('aksrt/v1', '/user-home/panel/(?P\d+)/(?P[a-z0-9_-]+)', [ + 'methods' => \WP_REST_Server::READABLE, + 'callback' => [$this, 'rest_panel'], + 'permission_callback' => '__return_true', + 'args' => [ + 'user_id' => [ + 'required' => true, + 'sanitize_callback' => 'absint', + 'validate_callback' => static function ($value): bool { + return (int) $value > 0; + }, + ], + 'tab' => [ + 'required' => true, + 'sanitize_callback' => 'sanitize_key', + 'validate_callback' => static function ($value): bool { + return preg_match('/^[a-z0-9_-]+$/', (string) $value) === 1; + }, + ], + 'page' => [ + 'required' => false, + 'default' => 1, + 'sanitize_callback' => 'absint', + ], + 'sub' => [ + 'required' => false, + 'default' => 'followers', + 'sanitize_callback' => 'sanitize_key', + 'validate_callback' => static function ($value): bool { + return in_array((string) $value, ['followers', 'following'], true); + }, + ], + ], + ]); + } + + /** + * Render a public user-home panel body fragment. + */ + public function rest_panel(\WP_REST_Request $request) { + $user = get_userdata((int) $request->get_param('user_id')); + if (!$user) { + return new \WP_Error('aksrt_user_home_user', __('用户不存在', 'aksrt'), ['status' => 404]); + } + + $tab = sanitize_key((string) $request->get_param('tab')); + $page = max(1, (int) $request->get_param('page')); + $sub = (string) $request->get_param('sub') === 'following' ? 'following' : 'followers'; + + if (!function_exists('aksrt_get_user_home_page_context')) { + require_once AKSRT_DIR . '/inc/helpers/aksrt-user-home.php'; + } + $context = aksrt_get_user_home_page_context($user); + + if (!in_array($tab, (array) ($context['enabled_tabs'] ?? []), true)) { + return new \WP_Error('aksrt_user_home_panel', __('用户主页面板不存在', 'aksrt'), ['status' => 404]); + } + + $context['active_tab'] = $tab; + $context['follow_sub'] = $sub; + $page_keys = [ + 'posts' => 'posts_page', + 'comments' => 'comments_page', + 'favorites' => 'favorites_page', + 'follow' => 'follow_page', + ]; + if (isset($page_keys[$tab])) { + $context[$page_keys[$tab]] = $page; + } + + $html = aksrt_capture_user_home_panel($tab, $context); + if ($html === '') { + return new \WP_Error('aksrt_user_home_panel_empty', __('用户主页面板暂不可用', 'aksrt'), ['status' => 404]); + } + + return rest_ensure_response([ + 'success' => true, + 'data' => [ + 'tab' => $tab, + 'html' => $html, + ], + ]); } /** diff --git a/templates/pages/page-user-home.php b/templates/pages/page-user-home.php index a052e963..d582c8db 100644 --- a/templates/pages/page-user-home.php +++ b/templates/pages/page-user-home.php @@ -48,7 +48,7 @@ require_once AKSRT_DIR . '/templates/parts/user-shared-helpers.php'; ]); ?> -
+
+
> + +
+
diff --git a/templates/parts/submit/editor-main.php b/templates/parts/submit/editor-main.php index 1cfc433a..4fcd347d 100644 --- a/templates/parts/submit/editor-main.php +++ b/templates/parts/submit/editor-main.php @@ -103,8 +103,8 @@ $is_can_new = !empty($ctx['is_can_new']); 0) : ?> 'post', 'post_status' => 'draft'], aksrt_get_user_home_url($ctx['user_id'])) + $draft_url = function_exists('aksrt_get_user_center_url') + ? add_query_arg('post_status', 'draft', aksrt_get_user_center_url('posts')) : ''; ?> diff --git a/templates/parts/user-home/panel-comments.php b/templates/parts/user-home/panel-comments.php new file mode 100644 index 00000000..20b84ef2 --- /dev/null +++ b/templates/parts/user-home/panel-comments.php @@ -0,0 +1,39 @@ + $user_id, + 'status' => 'approve', + 'number' => $per_page, + 'offset' => ($comments_page - 1) * $per_page, + 'type' => 'comment', + 'update_comment_meta_cache' => false, +]); + +if ($user_comments) : + ?> +
+ + $c]); + ?> + +
+ +
+

+
+

+
+
diff --git a/templates/parts/user-home/panel-favorites.php b/templates/parts/user-home/panel-favorites.php new file mode 100644 index 00000000..613166e2 --- /dev/null +++ b/templates/parts/user-home/panel-favorites.php @@ -0,0 +1,43 @@ + 'author', + 'overrides' => [ + 'type' => 'list', + 'no_author' => true, + 'no_margin' => true, + ], + ]); + + $favorites_query = new WP_Query([ + 'post_type' => 'post', + 'post_status' => 'publish', + 'post__in' => $favorite_post_ids, + 'orderby' => 'post__in', + 'posts_per_page' => $per_page, + 'paged' => $favorites_page, + 'ignore_sticky_posts' => true, + 'no_found_rows' => false, + 'update_post_meta_cache' => false, + ]); + aksrt_render_post_list(array_merge($user_home_render_config, ['wrapper_class' => 'aksrt-user-home__post-list']), $favorites_query); + aksrt_render_user_home_pagination((int) $favorites_query->max_num_pages, (int) $favorites_page); + wp_reset_postdata(); +else : + aksrt_render_profile_empty($svg_empty_favorite, __('TA 还没有收藏文章', 'aksrt')); +endif; diff --git a/templates/parts/user-home/panel-follow.php b/templates/parts/user-home/panel-follow.php index a1596762..e8784ac1 100644 --- a/templates/parts/user-home/panel-follow.php +++ b/templates/parts/user-home/panel-follow.php @@ -1,11 +1,15 @@ get_following_count((int) $user_id) : 0; +$follower_count = $follow_instance ? (int) $follow_instance->get_follower_count((int) $user_id) : 0; +$list = ['items' => [], 'total' => 0, 'total_pages' => 1]; +if ($follow_instance) { + $list = $follow_sub === 'followers' + ? $follow_instance->get_followers((int) $user_id, $follow_page, (int) $per_page) + : $follow_instance->get_following((int) $user_id, $follow_page, (int) $per_page); +} +$items = is_array($list['items'] ?? null) ? $list['items'] : []; +$total_pages = max(1, (int) ($list['total_pages'] ?? 1)); ?> - -
> - +
  • + +
  • +
  • + +
  • + - $follow_instance = class_exists('\AKSRT\User\Follow') ? \AKSRT\User\Follow::get_instance() : null; - $following_count = $follow_instance ? (int) $follow_instance->get_following_count((int) $user_id) : 0; - $follower_count = $follow_instance ? (int) $follow_instance->get_follower_count((int) $user_id) : 0; - $list = ['items' => [], 'total' => 0, 'total_pages' => 1]; - if ($follow_instance) { - $list = $follow_sub === 'followers' - ? $follow_instance->get_followers((int) $user_id, $follow_page, (int) $per_page) - : $follow_instance->get_following((int) $user_id, $follow_page, (int) $per_page); - } - $items = is_array($list['items'] ?? null) ? $list['items'] : []; - $total_pages = max(1, (int) ($list['total_pages'] ?? 1)); - ?> - - - -
    - -
    - 1) : ?> -
    - add_query_arg(['tab' => 'follow', 'follow_sub' => $follow_sub, 'follow_page' => '%#%'], $home_url), - 'format' => '', - 'current' => $follow_page, - 'total' => $total_pages, - 'add_args' => ['tab' => 'follow', 'follow_sub' => $follow_sub], - ]); - ?> -
    - - - - -
    - + +
    + +
    + + + + diff --git a/templates/parts/user-home/panel-posts.php b/templates/parts/user-home/panel-posts.php new file mode 100644 index 00000000..98344c36 --- /dev/null +++ b/templates/parts/user-home/panel-posts.php @@ -0,0 +1,35 @@ + 'author', + 'overrides' => [ + 'type' => 'list', + 'no_author' => true, + 'no_margin' => true, + ], +]); + +$author_posts = new WP_Query([ + 'author' => $user_id, + 'posts_per_page' => $per_page, + 'post_status' => 'publish', + 'paged' => $posts_page, + 'no_found_rows' => false, +]); +aksrt_render_post_list(array_merge($user_home_render_config, ['wrapper_class' => 'aksrt-user-home__post-list']), $author_posts); +aksrt_render_user_home_pagination((int) $author_posts->max_num_pages, (int) $posts_page); +wp_reset_postdata(); diff --git a/templates/parts/user-home/panels-core.php b/templates/parts/user-home/panels-core.php deleted file mode 100644 index cb460f01..00000000 --- a/templates/parts/user-home/panels-core.php +++ /dev/null @@ -1,118 +0,0 @@ - - - 'author', - 'overrides' => [ - 'type' => 'list', - 'no_author' => true, - 'no_margin' => true, - ], - ]); - ?> - - - -
    > - $user_id, - 'posts_per_page' => $per_page, - 'post_status' => 'publish', - 'paged' => $posts_page, - 'no_found_rows' => false, - ]); - aksrt_render_post_list(array_merge($user_home_render_config, ['wrapper_class' => 'aksrt-user-home__post-list']), $author_posts); - aksrt_render_pagination($author_posts, [ - 'query_context' => 'author', - 'render_config' => $user_home_render_config, - 'page_var' => 'posts_page', - 'current_page' => $posts_page, - ]); - wp_reset_postdata(); - ?> -
    - - - -
    > - $user_id, - 'status' => 'approve', - 'number' => $per_page, - 'offset' => ($comments_page - 1) * $per_page, - 'type' => 'comment', - 'update_comment_meta_cache' => false, - ]); - if ($user_comments) : - ?> -
    - - $c]); - ?> - -
    - -
    - - - -
    > - - 'post', - 'post_status' => 'publish', - 'post__in' => $favorite_post_ids, - 'orderby' => 'post__in', - 'posts_per_page' => $per_page, - 'paged' => $favorites_page, - 'ignore_sticky_posts' => true, - 'no_found_rows' => false, - 'update_post_meta_cache' => false, - ]); - aksrt_render_post_list(array_merge($user_home_render_config, ['wrapper_class' => 'aksrt-user-home__post-list']), $favorites_query); - aksrt_render_pagination($favorites_query, [ - 'query_context' => 'author', - 'render_config' => $user_home_render_config, - 'page_var' => 'favorites_page', - 'current_page' => $favorites_page, - ]); - wp_reset_postdata(); - ?> - - - -
    - - - -
    > -
    -

    -
    -

    - -
    -
    - diff --git a/tests/widget-system/post-visual-contract.test.cjs b/tests/widget-system/post-visual-contract.test.cjs index 04459a60..b4637675 100644 --- a/tests/widget-system/post-visual-contract.test.cjs +++ b/tests/widget-system/post-visual-contract.test.cjs @@ -36,7 +36,8 @@ test('the article stream exposes only the new typed renderer contract', () => { 'src/Core/Shortcodes.php', 'src/WidgetSystem/PostRenderer.php', 'inc/helpers/homepage/content.php', - 'templates/parts/user-home/panels-core.php', + 'templates/parts/user-home/panel-posts.php', + 'templates/parts/user-home/panel-favorites.php', ]); assert.match(markup, /function aksrt_render_post_list\(array \$args = \[\], \?WP_Query \$query = null, bool \$echo = true\): string/); @@ -202,8 +203,7 @@ test('related posts, author page and widgets use their dedicated renderers', () const related = read('inc/helpers/aksrt-single-render.php'); const relatedCard = read('inc/helpers/aksrt-single-components.php'); const resourceRelated = read('templates/parts/resource-related.php'); - const author = read('templates/parts/user-home/panels-core.php'); - const authorPosts = author.slice(author.indexOf("in_array('posts'"), author.indexOf("in_array('comments'")); + const author = read('templates/parts/user-home/panel-posts.php'); const widgets = read('src/WidgetSystem/PostRenderer.php'); assert.match(related, /\['img', 'list', 'text'\]/); @@ -215,14 +215,13 @@ test('related posts, author page and widgets use their dedicated renderers', () assert.match(resourceRelated, /swiper-container swiper-scroll/); assert.match(resourceRelated, /aksrt_render_related_graphic_card/); assert.doesNotMatch(resourceRelated, /aksrt_render_post_list/); - assert.match(authorPosts, /aksrt_render_post_list/); - assert.match(authorPosts, /'type'\s*=>\s*'list'/); - assert.match(authorPosts, /'no_author'\s*=>\s*true/); - assert.match(authorPosts, /'no_margin'\s*=>\s*true/); - assert.match(authorPosts, /'wrapper_class'\s*=>\s*'aksrt-user-home__post-list'/); - assert.match(authorPosts, /aksrt_render_pagination/); - assert.match(authorPosts, /'page_var'\s*=>\s*'posts_page'/); - assert.doesNotMatch(authorPosts, /aksrt_render_profile_pagination/); + assert.match(author, /aksrt_render_post_list/); + assert.match(author, /'type'\s*=>\s*'list'/); + assert.match(author, /'no_author'\s*=>\s*true/); + assert.match(author, /'no_margin'\s*=>\s*true/); + assert.match(author, /'wrapper_class'\s*=>\s*'aksrt-user-home__post-list'/); + assert.match(author, /aksrt_render_user_home_pagination\(\(int\) \$author_posts->max_num_pages, \(int\) \$posts_page\)/); + assert.doesNotMatch(author, /aksrt_render_pagination|aksrt_render_profile_pagination|\$_GET|add_query_arg/); assert.match(widgets, /aksrt_render_mini_list/); assert.match(widgets, /swiper-container swiper-scroll/); }); @@ -325,10 +324,10 @@ test('profile routes load versioned leaf styles and keep dialogs hidden initiall assert.match(userHomeCss, /@media \(min-width: 992px\)\s*\{[\s\S]*?\.aksrt-user-home \.aksrt-user-home__post-list\s*\{[^}]*display:\s*grid;[^}]*grid-template-columns:\s*repeat\(2,\s*minmax\(0,\s*1fr\)\);/); assert.match(userHomeCss, /@media \(min-width: 992px\)\s*\{[\s\S]*?\.aksrt-user-home \.aksrt-user-home__post-list > \.posts-item\s*\{[^}]*width:\s*auto;[^}]*margin:\s*0 !important;/); assert.match(userHomeCss, /@media \(max-width: 767px\)\s*\{[\s\S]*?\.aksrt-user-home \.aksrt-user-home__post-list \.posts-item\s*\{[^}]*padding:\s*10px 0;/); - const panelsCore = read('templates/parts/user-home/panels-core.php'); - assert.match(panelsCore, /'page_var'\s*=>\s*'favorites_page'/); - assert.match(panelsCore, /array_merge\(\$user_home_render_config, \['wrapper_class' => 'aksrt-user-home__post-list'\]\), \$favorites_query\)/); - assert.doesNotMatch(panelsCore, /profile-list-item/); + const panelFavorites = read('templates/parts/user-home/panel-favorites.php'); + assert.match(panelFavorites, /aksrt_render_user_home_pagination\(\(int\) \$favorites_query->max_num_pages, \(int\) \$favorites_page\)/); + assert.match(panelFavorites, /array_merge\(\$user_home_render_config, \['wrapper_class' => 'aksrt-user-home__post-list'\]\), \$favorites_query\)/); + assert.doesNotMatch(panelFavorites, /profile-list-item|\$_GET|add_query_arg/); assert.match(profileLayoutCss, /\.aksrt-cover-menu__panel\[hidden\]\s*\{\s*display:\s*none\s*!important;\s*\}/); assert.match(profileLayoutCss, /#aksrt-bg-file-input\[hidden\],[\s\S]*?#aksrt-avatar-file-input\[hidden\][\s\S]*?display:\s*none\s*!important;/); assert.match(settingsSecurity, /data-modal="email-modal"[\s\S]*?<\/button>\s*<\/div>\s*
    [\s\S]*?data-modal="phone-modal"/); @@ -337,3 +336,44 @@ test('profile routes load versioned leaf styles and keep dialogs hidden initiall assert.doesNotMatch(accountModals, /id="phone-modal"[\s\S]*?aksrt-modal__(?:dialog|body|actions)/); assert.match(profileModalsCss, /\.modal-field input\[type="tel"\]/); }); + +test('public user home stays param-free and swaps panels via REST fragments', () => { + const ctx = read('inc/helpers/aksrt-user-home.php'); + const shell = read('templates/pages/page-user-home.php'); + const follow = read('templates/parts/user-home/panel-follow.php'); + const comments = read('templates/parts/user-home/panel-comments.php'); + const favorites = read('templates/parts/user-home/panel-favorites.php'); + const rest = read('src/User/Home.php'); + const authorJs = read('assets/js/author.js'); + + // Context builder owns no request state; pages default to 1. + assert.doesNotMatch(ctx, /\$_GET/); + assert.match(ctx, /function aksrt_render_user_home_pagination\(int \$total_pages, int \$current\): void/); + assert.match(ctx, /data-uh-page/); + assert.match(ctx, /function aksrt_capture_user_home_panel\(string \$tab, array \$ctx\): string/); + + // Shell mounts panel wrappers from the tab registry with a user id hook. + assert.match(shell, /data-user-id="<\?php echo \(int\) \$user_id; \?>">/); + assert.match(shell, /get_template_part\('templates\/parts\/user-home\/panel', \$panel_tab, \$ctx\)/); + + // Follow panel: button-driven sub-tabs, pagination without URL building. + assert.match(follow, /data-uh-sub="followers"/); + assert.match(follow, /data-uh-sub="following"/); + assert.match(follow, /aksrt_render_user_home_pagination\(\$total_pages, \$follow_page\)/); + assert.doesNotMatch(follow, /add_query_arg|\$_GET|paginate_links/); + + assert.match(comments, /aksrt_render_user_home_pagination\(/); + assert.match(favorites, /aksrt_render_user_home_pagination\(/); + assert.doesNotMatch(comments + favorites, /add_query_arg|\$_GET/); + + // REST fragment endpoint serves public panel bodies. + assert.ok(rest.includes("'/user-home/panel/(?P\\d+)/(?P[a-z0-9_-]+)'")); + assert.match(rest, /'permission_callback'\s*=>\s*'__return_true'/); + assert.match(rest, /aksrt_capture_user_home_panel\(\$tab, \$context\)/); + + // Runtime never reads or writes URL params; fragments replace panel bodies. + assert.doesNotMatch(authorJs, /searchParams|pushState|replaceState/); + assert.match(authorJs, /\/user-home\/panel\/' \+ userId \+ '/); + assert.match(authorJs, /data-uh-sub/); + assert.match(authorJs, /AksrtFrontend\.rehydrate\(panel, 'ajax'\)/); +});