diff --git a/.gitignore b/.gitignore index 502ff67a..1e827085 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,8 @@ !/tests/widget-system/user-badges-contract.test.cjs !/tests/widget-system/user-features-contract.test.cjs !/tests/widget-system/zibll-modern-widgets-contract.test.cjs +!/tests/widget-system/user-card-runtime-contract.test.cjs +!/tests/widget-system/user-card-runtime-harness.php !/tests/widget-system/notification-preferences-contract.test.cjs !/tests/widget-system/message-center-contract.test.cjs !/tests/widget-system/message-center-url-harness.php diff --git a/assets/widgets/frontend.css b/assets/widgets/frontend.css index 853d146f..a731ca35 100644 --- a/assets/widgets/frontend.css +++ b/assets/widgets/frontend.css @@ -69,8 +69,20 @@ .zib-widget.padding-10 { padding:10px; } .zib-widget.nobottom.notop { padding-top:0; padding-bottom:0; } -.user-card__auth-btns { display: inline-flex; flex-wrap: nowrap; align-items: center; justify-content: center; gap: 10px; } -.user-card__auth-btns .but { margin: 0; } +.zib-widget-wrap { padding:var(--aksrt-widget-space-top,0) var(--aksrt-widget-space-right,0) var(--aksrt-widget-space-bottom,0) var(--aksrt-widget-space-left,0); background-color:var(--aksrt-widget-bg-light,transparent); background-image:var(--aksrt-widget-bg-image-light,none); background-position:center; background-size:cover; } +.dark-theme .zib-widget-wrap, [data-theme="dark"] .zib-widget-wrap { background-color:var(--aksrt-widget-bg-dark,var(--aksrt-widget-bg-light,transparent)); background-image:var(--aksrt-widget-bg-image-dark,var(--aksrt-widget-bg-image-light,none)); } +.zib-widget-wrap .widget-container { position:relative; } +.aksrt-user-widget-wrap > .widget-container > div > .widget-content { padding:0; } +.aksrt-user-widget-wrap :where(a, button, input, select, textarea, [tabindex]):focus-visible { outline:3px solid var(--focus-color,#ff5473) !important; outline-offset:3px; } +.dark-theme .aksrt-user-widget-wrap :where(a, button, input, select, textarea, [tabindex]):focus-visible, [data-theme="dark"] .aksrt-user-widget-wrap :where(a, button, input, select, textarea, [tabindex]):focus-visible { outline-color:#ffd166 !important; } + +.user-card.zib-widget { padding:0; overflow:hidden; } +.user-card.widget .user-info > .text-center > p:not(.muted-color) { white-space:nowrap; } +.user-card.widget .user-info > .text-center > p:not(.muted-color) .but { white-space:nowrap; } + +@media (max-width:767px) { + .zib-widget-wrap { padding:var(--aksrt-widget-space-m-top,0) var(--aksrt-widget-space-m-right,0) var(--aksrt-widget-space-m-bottom,0) var(--aksrt-widget-space-m-left,0); } +} .aksrt-widget-video { position: relative; overflow: hidden; border-radius: var(--aksrt-widget-radius); background: #000; aspect-ratio: 16 / 9; } .aksrt-widget-video--4-3 { aspect-ratio: 4 / 3; }.aksrt-widget-video--1-1 { aspect-ratio: 1; }.aksrt-widget-video--auto { aspect-ratio: auto; } diff --git a/src/WidgetSystem/FrontendRenderer.php b/src/WidgetSystem/FrontendRenderer.php index 3334bd4d..4168aabd 100644 --- a/src/WidgetSystem/FrontendRenderer.php +++ b/src/WidgetSystem/FrontendRenderer.php @@ -23,6 +23,10 @@ final class FrontendRenderer { } AssetManager::enqueue_for($definition); + if (in_array($definition->slug(), ['user_card', 'author_card'], true)) { + self::render_user_widget($definition, $instance, $widget, $content); + return; + } if (in_array($definition->slug(), ['post_pay', 'tab_posts', 'slider', 'slider_media_combo', 'icon_cards', 'icon_cover_cards', 'buttons', 'notice'], true)) { echo (string) ($args['before_widget'] ?? ''); if ($definition->slug() !== 'post_pay') echo self::reference_title($instance); @@ -31,7 +35,7 @@ final class FrontendRenderer { return; } $classes = ['aksrt-widget', 'aksrt-panel-wrap', 'aksrt-widget--' . $definition->slug(), 'aksrt-widget--' . $definition->size()]; - if ($definition->size() === 'sidebar' && !in_array($definition->slug(), ['user_card', 'author_card'], true)) { + if ($definition->size() === 'sidebar') { $classes[] = 'aksrt-panel'; } $device_class = WidgetVisibility::device_class($instance); @@ -64,6 +68,33 @@ final class FrontendRenderer { echo (string) ($args['after_widget'] ?? ''); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } + private static function render_user_widget(WidgetDefinition $definition, array $instance, SchemaWidget $widget, string $content): void { + $classes = ['zib-widget-wrap', 'aksrt-user-widget-wrap', 'aksrt-' . str_replace('_', '-', $definition->slug()) . '-wrap']; + $device_class = WidgetVisibility::device_class($instance); + if ($device_class !== '') { + $classes[] = $device_class; + } + if (!empty($instance['sidebar_affix'])) { + $classes[] = 'aksrt-widget--affix'; + } + + $content_classes = ['widget-content']; + if (!empty($instance['animation_in'])) { + $content_classes[] = 'aksrt-widget--animate'; + $content_classes[] = 'aksrt-widget--animate-' . sanitize_html_class((string) $instance['animation_in']); + } + if (!empty($instance['animation_repeat'])) { + $content_classes[] = 'aksrt-widget--animate-repeat'; + } + + $style = self::style($instance); + echo '
'; + echo '
'; + echo self::reference_title($instance); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo '
' . $content . '
'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo '
'; + } + private static function title(array $instance): string { $title = trim((string) ($instance['title'] ?? '')); if ($title === '') { diff --git a/src/WidgetSystem/Renderers/UserRenderer.php b/src/WidgetSystem/Renderers/UserRenderer.php index 02ea2170..0dae20b1 100644 --- a/src/WidgetSystem/Renderers/UserRenderer.php +++ b/src/WidgetSystem/Renderers/UserRenderer.php @@ -20,6 +20,10 @@ final class UserRenderer { } private static function user_card(array $i): string { + if (class_exists('AKSRT\\Settings\\Settings') && \AKSRT\Settings\Settings::close_sign()) { + return ''; + } + $user_id = get_current_user_id(); if ($user_id <= 0) { return self::guest_card($i); @@ -29,11 +33,11 @@ final class UserRenderer { return ''; } - return self::card_box($user_id, [ + return '
' . self::card_box($user_id, [ 'show_posts' => false, 'show_checkin' => true, 'show_img_bg' => !isset($i['show_img_bg']) || !empty($i['show_img_bg']), - ] + self::card_button_args($i)); + ] + self::card_button_args($i)) . '
'; } private static function author_card(array $i): string { @@ -47,13 +51,14 @@ final class UserRenderer { } $post_style = sanitize_key((string) ($i['post_style'] ?? 'mini')); - return self::card_box((int) $user->ID, [ + return '
' . self::card_box((int) $user->ID, [ 'show_img_bg' => !isset($i['show_img_bg']) || !empty($i['show_img_bg']), 'show_posts' => !isset($i['show_posts']) || !empty($i['show_posts']), 'limit' => max(0, min(12, (int) ($i['limit'] ?? 6))), 'orderby' => sanitize_key((string) ($i['orderby'] ?? 'date')), + 'post_type' => 'post', 'post_style' => $post_style === 'card' ? 'card' : 'mini', - ] + self::card_button_args($i)); + ] + self::card_button_args($i)) . '
'; } private static function card_button_args(array $i): array { @@ -77,37 +82,60 @@ final class UserRenderer { $title = __('Hi!请登录', 'aksrt'); } - $cover = ''; - if (!isset($i['show_img_bg']) || !empty($i['show_img_bg'])) { - $cover_url = aksrt_get_user_cover_url(0); - $cover = '
'; - } - - $avatar = '' . aksrt_get_avatar_html(0, 80, ['class' => 'fit-cover avatar']) . ''; + $cover = !isset($i['show_img_bg']) || !empty($i['show_img_bg']) + ? self::cover(0, 'lazy_other') + : ''; + $avatar = '' . esc_attr__('默认头像', 'aksrt') . ''; $buttons = '' . esc_html__('登录', 'aksrt') . ''; - if (get_option('users_can_register')) { + $registration_enabled = class_exists('AKSRT\\Settings\\Settings') + ? \AKSRT\Settings\Settings::registration_enabled() + : (bool) get_option('users_can_register'); + if ($registration_enabled) { $buttons .= '' . aksrt_renderer_svg_icon('signup') . esc_html__('注册', 'aksrt') . ''; } - $oauth_html = ''; - if (class_exists('AKSRT\\Auth\\OAuth')) { - $oauth = \AKSRT\Auth\OAuth::get_instance(); - if (is_callable([$oauth, 'render_social_buttons'])) { - $oauth_html = (string) $oauth->render_social_buttons(__('社交账号登录', 'aksrt')); - } - } - - return '
' . $cover + return '
' . $cover . '
' . '
' . '
' . $avatar . '
' . '
'; + . '

' . $buttons . '

' + . self::social_login() + . '
'; + } + + private static function social_login(): string { + if (!class_exists('AKSRT\\Auth\\OAuth')) { + return ''; + } + $items = \AKSRT\Auth\OAuth::get_instance()->get_auth_providers_data(); + if ($items === []) { + return ''; + } + + $html = '
' . esc_html__('社交账号登录', 'aksrt') . '
'; + foreach ($items as $item) { + $name = sanitize_html_class((string) ($item['name'] ?? '')); + $style_name = str_starts_with($name, 'clogin_') ? substr($name, 7) : $name; + $label = trim((string) ($item['label'] ?? '')); + $url = (string) ($item['url'] ?? ''); + if ($name === '' || $style_name === '' || $label === '' || $url === '') { + continue; + } + $html .= '' . (string) ($item['icon'] ?? '') . ''; + } + return $html . '
'; + } + + private static function cover(int $user_id, string $lazy_type): string { + $cover_url = aksrt_get_user_cover_url($user_id); + if ($cover_url === '') { + return ''; + } + return '
'; } /** @@ -130,6 +158,7 @@ final class UserRenderer { 'button_2_text' => __('用户中心', 'aksrt'), 'limit' => 6, 'orderby' => 'views', + 'post_type' => 'post', 'post_style' => 'card', ]); @@ -158,7 +187,7 @@ final class UserRenderer { 'user_id' => $user_id, 'limit' => (int) $args['limit'], 'orderby' => (string) $args['orderby'], - 'post_type' => 'post', + 'post_type' => (string) $args['post_type'], 'post_style' => (string) $args['post_style'], ]); } @@ -176,13 +205,9 @@ final class UserRenderer { $btns = $btns !== '' ? '
' . $btns . '
' : ''; } - $cover = ''; - if ($args['show_img_bg']) { - $cover_url = aksrt_get_user_cover_url($user_id); - $cover = '
'; - } + $cover = !empty($args['show_img_bg']) ? self::cover($user_id, 'lazy_cover') : ''; - return '
' . $cover + return '
' . $cover . '
' . '
' . $checkin . '
' . $avatar . '
' diff --git a/tests/widget-system/page-display-contract.test.cjs b/tests/widget-system/page-display-contract.test.cjs index f30b1be5..f93408c2 100644 --- a/tests/widget-system/page-display-contract.test.cjs +++ b/tests/widget-system/page-display-contract.test.cjs @@ -262,10 +262,15 @@ test('header multifunction search keeps focus and submit alignment route-indepen ) }) -test('user profile and author widgets render the legacy user-card box', () => { - assert.match(frontendRenderer, /!in_array\(\$definition->slug\(\), \['user_card', 'author_card'\], true\)/) - // 登录卡片与 legacy zib_get_user_card_box 同构:封面、头像框、名字行、统计徽章、简介、按钮。 - assert.match(userRenderer, /user-card zib-widget/) +test('user profile and author widgets render the reference user-card contract', () => { + assert.match(frontendRenderer, /in_array\(\$definition->slug\(\), \['user_card', 'author_card'\], true\)/) + assert.match(frontendRenderer, /self::render_user_widget\(\$definition, \$instance, \$widget, \$content\)/) + assert.match(frontendRenderer, /\$classes = \['zib-widget-wrap', 'aksrt-user-widget-wrap'/) + assert.match(frontendRenderer, /echo '
';/) + assert.match(frontendRenderer, /\$content_classes = \['widget-content'\]/) + // 登录卡片与 reference zib_get_user_card_box 同构:封面、头像框、名字行、统计徽章、简介、按钮。 + assert.match(userRenderer, /
/) + assert.match(userRenderer, /user-card zib-widget widget/) assert.match(userRenderer, /user-cover graphic" style="padding-bottom: 50%;"/) assert.match(userRenderer, /avatar-img avatar-lg/) assert.match(userRenderer, /user-name flex jc/) @@ -278,15 +283,28 @@ test('user profile and author widgets render the legacy user-card box', () => { assert.match(userRenderer, /'show_posts' => false,/) assert.match(userRenderer, /'show_checkin' => true,/) assert.match(userRenderer, /aksrt_header_user_checkin_btn\(\$user_id, true, 'img-badge jb-yellow'\)/) - // 文章作者信息小工具显示文章模块。 + // 文章作者信息小工具显示文章模块,并走同一 widget 变体。 assert.match(userRenderer, /'show_posts' => !isset\(\$i\['show_posts'\]\) \|\| !empty\(\$i\['show_posts'\]\),/) + assert.match(userRenderer, /'post_type' => 'post',/) + assert.match(userRenderer, /self::card_box\(\(int\) \$user->ID,[\s\S]*?\) \. '<\/div>';/) assert.match(userRenderer, /aksrt_render_user_card_posts\(/) - // 未登录卡片与 legacy zib_get_user_singin_page_box 同构。 + assert.match(userRenderer, /'post_type' => \(string\) \$args\['post_type'\],/) + // 未登录卡片使用主题认证真值和 reference 登录/社交 DOM。 + assert.match(userRenderer, /Settings::close_sign\(\)/) + assert.match(userRenderer, /Settings::registration_enabled\(\)/) assert.match(userRenderer, /signin-loader but jb-blue padding-lg/) assert.match(userRenderer, /signup-loader ml10 but jb-yellow padding-lg/) + assert.match(userRenderer, /social-separator separator muted-3-color em09 mt20 mb20/) + assert.match(userRenderer, /social_loginbar/) + assert.match(userRenderer, /social-login-item /) assert.match(userRenderer, /loged_title/) - // 旧的自造卡片样式已清理。 - assert.doesNotMatch(widgetCss, /aksrt-user-card/) + assert.doesNotMatch(userRenderer, /user-card__auth-btns|render_social_buttons\(/) + // 后加载的 widget bundle 明确保留单层卡片并让封面贴边。 + assert.match(widgetCss, /\.user-card\.zib-widget\s*\{[^}]*padding:\s*0;[^}]*overflow:\s*hidden;/s) + assert.match(legacyMainCss, /\.user-card\.widget \.user-cover \+ \.card-content\s*\{[^}]*margin-top:\s*-55px;/s) + assert.match(widgetCss, /\.aksrt-user-widget-wrap > \.widget-container > div > \.widget-content\s*\{\s*padding:\s*0;/) + assert.doesNotMatch(widgetCss, /user-card__auth-btns/) + assert.doesNotMatch(widgetCss, /\.aksrt-user-card\s*\{/) assert.doesNotMatch(userRenderer, /aksrt-widget-button is-ghost/) }) diff --git a/tests/widget-system/user-card-runtime-contract.test.cjs b/tests/widget-system/user-card-runtime-contract.test.cjs new file mode 100644 index 00000000..ae8a7b58 --- /dev/null +++ b/tests/widget-system/user-card-runtime-contract.test.cjs @@ -0,0 +1,46 @@ +'use strict' + +const assert = require('node:assert/strict') +const path = require('node:path') +const { spawnSync } = require('node:child_process') +const test = require('node:test') + +const root = path.resolve(__dirname, '../..') + +test('user and author widgets render the reference state contract', () => { + const result = spawnSync(process.env.PHP_BINARY || 'php', [path.join(__dirname, 'user-card-runtime-harness.php')], { + cwd: root, + encoding: 'utf8', + }) + assert.equal(result.status, 0, result.stderr || result.stdout) + const data = JSON.parse(result.stdout) + + assert.match(data.guest, /^
/) + assert.match(data.guest, /data-lazy-type="lazy_other"/) + assert.match(data.guest, /alt="默认头像"/) + assert.match(data.guest, /