Merge pull request 'fix(pay): render paid reading as fragments' (#11) from worktree/fix-posts-pay into main

Reviewed-on: #11
This commit is contained in:
2026-08-13 11:02:18 +00:00
9 changed files with 316 additions and 65 deletions
@@ -119,3 +119,117 @@
opacity: .85;
}
/* ---- Paid fragment purchase card ---- */
.aksrt-paywall {
position: relative;
margin: 24px 0;
}
.aksrt-paywall__card {
position: relative;
z-index: 0;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 18px;
padding: 22px 24px;
background: linear-gradient(135deg, var(--surface-1, #fff) 0%, var(--surface-2, #f8fafc) 100%);
border: 1px solid var(--color-line, #e5e7eb);
border-radius: var(--radius-lg, 14px);
box-shadow: 0 10px 28px rgba(15, 23, 42, .08);
}
.aksrt-paywall--fragment .aksrt-paywall__card {
border-style: dashed;
border-color: color-mix(in srgb, var(--color-primary, #f04494) 42%, var(--color-line, #e5e7eb));
}
.aksrt-paywall__icon {
display: grid;
place-items: center;
width: 44px;
height: 44px;
flex: 0 0 auto;
border-radius: 50%;
color: var(--color-primary, #f04494);
background: color-mix(in srgb, var(--color-primary, #f04494) 12%, transparent);
font-size: 20px;
}
.aksrt-paywall__body { min-width: 0; }
.aksrt-paywall__title {
margin: 0 0 4px;
color: var(--color-ink, #15171a);
font-size: 17px;
font-weight: 700;
}
.aksrt-paywall__desc {
margin: 0;
color: var(--color-ink-3, #525866);
font-size: 14px;
line-height: 1.6;
}
.aksrt-paywall__price { margin-top: 8px; }
.aksrt-paywall__price-amount { color: var(--color-error, #ef4444); font-size: 20px; font-weight: 700; }
.aksrt-paywall__price-member { color: var(--color-primary, #f04494); font-size: 16px; font-weight: 700; }
.aksrt-paywall__price-free { color: var(--color-success, #10b981); font-size: 16px; font-weight: 700; }
.aksrt-paywall__price-original { margin-left: 8px; color: var(--color-ink-4, #8a8f98); font-size: 13px; text-decoration: line-through; }
.aksrt-paywall__actions {
position: relative;
z-index: 1;
display: flex;
flex: 0 0 auto;
flex-direction: column;
align-items: center;
gap: 8px;
text-align: center;
}
.aksrt-paywall__btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 40px;
white-space: nowrap;
cursor: pointer;
}
.aksrt-paywall__btn:focus-visible,
.aksrt-paywall__login-link:focus-visible {
outline: 3px solid color-mix(in srgb, var(--color-primary, #f04494) 35%, transparent);
outline-offset: 3px;
}
.aksrt-paywall__hint,
.aksrt-paywall__login-link {
margin: 0;
color: var(--color-ink-4, #8a8f98);
font-size: 12px;
line-height: 1.5;
}
.aksrt-paywall__login-link { text-decoration: underline; }
.aksrt-gallery-locked { filter: blur(8px); pointer-events: none; user-select: none; }
.aksrt-paywall-preview { margin: 0 0 16px; padding: 14px 16px; border-left: 3px solid var(--color-primary, #f04494); background: var(--surface-2, #f8fafc); border-radius: 0 10px 10px 0; color: var(--color-ink-2, #374151); line-height: 1.7; }
.aksrt-paywall-preview p:last-child { margin-bottom: 0; }
.aksrt-paid-gallery__overlay .but, .aksrt-paid-video__placeholder .but { margin-top: 10px; }
@media (max-width: 680px) {
.aksrt-paywall__card {
grid-template-columns: auto minmax(0, 1fr);
padding: 18px;
}
.aksrt-paywall__actions {
grid-column: 1 / -1;
align-items: stretch;
width: 100%;
}
.aksrt-paywall__btn { width: 100%; }
}
+31
View File
@@ -363,6 +363,37 @@
background: var(--surface-2, #f8fafc);
}
/* ---- Paid Fragment ---- */
.aksrt-paid-fragment-block {
margin: 16px 0;
border: 1px dashed #ec4899;
border-radius: 12px;
overflow: hidden;
background: #fff7fb;
}
.aksrt-paid-fragment-block .components-notice {
margin: 12px;
}
.aksrt-paid-fragment-block__label {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 14px;
color: #9d174d;
background: #fce7f3;
font-size: 13px;
font-weight: 700;
}
.aksrt-paid-fragment-block__content {
min-height: 72px;
padding: 16px;
background: #fff;
}
/* ── Responsive ── */
@media (max-width: 767px) {
+57
View File
@@ -16,6 +16,7 @@
var el = wp.element.createElement;
var Fragment = wp.element.Fragment;
var RawHTML = wp.element.RawHTML;
var registerBlockTypeRaw = wp.blocks.registerBlockType;
var blockEditor = wp.blockEditor || wp.editor || {};
var InspectorControls = blockEditor.InspectorControls || null;
@@ -28,6 +29,8 @@
var TextControl = wp.components.TextControl;
var ToggleControl = wp.components.ToggleControl;
var RangeControl = wp.components.RangeControl;
var Notice = wp.components.Notice;
var useSelect = window.wp.data && window.wp.data.useSelect;
if (!registerBlockTypeRaw || !el) return;
@@ -750,4 +753,58 @@
});
}
// ================================================================
// 9. Paid Fragment Block
// ================================================================
function usePaidReadingConfigured() {
if (!useSelect) return true;
return useSelect(function (select) {
var editor = select('core/editor');
if (!editor || !editor.getEditedPostAttribute) return true;
var meta = editor.getEditedPostAttribute('meta') || {};
var pay = meta._aksrt_pay || {};
if (pay.pay_type !== 'reading') return false;
return pay.pay_modo === 'points'
? Number(pay.points_price || 0) > 0
: Number(pay.price || 0) > 0;
}, []);
}
registerBlockType('aksrt/paid-fragment', {
title: '付费可见',
icon: 'lock',
category: CATEGORY,
description: '仅购买文章付费阅读后可见的片段。',
attributes: {
content: { type: 'string', default: '' },
},
edit: function (props) {
var configured = usePaidReadingConfigured();
return h('div', { className: 'aksrt-paid-fragment-block' },
!configured && Notice ? h(Notice, { status: 'warning', isDismissible: false },
'请在“付费内容设置”中选择“付费阅读”并设置有效价格;未配置时此片段会公开显示。'
) : null,
h('div', { className: 'aksrt-paid-fragment-block__label' },
h('span', { className: 'dashicons dashicons-lock', 'aria-hidden': true }),
'付费可见片段'
),
h(RichText, {
tagName: 'div',
className: 'aksrt-paid-fragment-block__content',
value: props.attributes.content,
onChange: function (value) { props.setAttributes({ content: value }); },
placeholder: '输入购买后可见的内容…',
})
);
},
save: function (props) {
// Save a single canonical shortcode so hand-written and block-authored
// content use the identical server-side rendering path.
var shortcode = '[hidecontent type="payshow"]' + props.attributes.content + '[/hidecontent]';
return RawHTML ? h(RawHTML, null, shortcode) : shortcode;
},
});
})();
+1 -1
View File
@@ -139,7 +139,7 @@ add_action('enqueue_block_editor_assets', function () {
'aksrt-gutenberg-blocks',
$base_uri . '/assets/js/gutenberg-blocks.js',
// wp-media-utils: MediaUpload for carousel block
['wp-blocks', 'wp-element', 'wp-components', 'wp-block-editor', 'wp-hooks', 'wp-media-utils'],
['wp-blocks', 'wp-element', 'wp-components', 'wp-block-editor', 'wp-hooks', 'wp-media-utils', 'wp-data'],
(string) filemtime($blocks_js),
true
);
+72 -51
View File
@@ -675,6 +675,53 @@ if (!function_exists('aksrt_is_paid_reading')) {
}
}
/**
* Detect the canonical paid-fragment shortcode in a post's raw content.
*
* The editor writes this exact shortcode format, so this is the single
* source of truth for whether a reading price has any protected fragment to
* apply to. It deliberately does not inspect rendered content.
*/
if (!function_exists('aksrt_post_has_payshow_shortcode')) {
function aksrt_post_has_payshow_shortcode($post = null): bool {
$p = get_post($post);
if (!($p instanceof WP_Post)) {
return false;
}
return preg_match(
'/\[hidecontent\b[^\]]*\btype\s*=\s*(?:"payshow"|\'payshow\'|payshow)[^\]]*\]/i',
(string) $p->post_content
) === 1;
}
}
/**
* A paid fragment is active only for an explicitly priced paid-reading post.
*
* A zero-price reading configuration is intentionally public: authors can
* save content before setting a price without accidentally hiding it.
*/
if (!function_exists('aksrt_is_payshow_purchase_enabled')) {
function aksrt_is_payshow_purchase_enabled($post = null): bool {
if (!aksrt_post_has_payshow_shortcode($post)) {
return false;
}
$meta = aksrt_get_pay_meta($post);
if (($meta['pay_type'] ?? AKSRT_PAY_TYPE_NO) !== AKSRT_PAY_TYPE_READING) {
return false;
}
$pay_modo = ($meta['pay_modo'] ?? '0') === 'points' ? 'points' : '0';
$price = $pay_modo === 'points'
? (float) ($meta['points_price'] ?? 0)
: (float) ($meta['price'] ?? 0);
return $price > 0;
}
}
if (!function_exists('aksrt_is_paid_download')) {
function aksrt_is_paid_download($post = null): bool {
return aksrt_get_pay_meta($post)['pay_type'] === AKSRT_PAY_TYPE_DOWNLOAD;
@@ -1093,7 +1140,7 @@ if (!function_exists('aksrt_get_pay_attributes_html')) {
// ============================================================
if (!function_exists('aksrt_get_paywall_html')) {
function aksrt_get_paywall_html(WP_Post $post): string {
function aksrt_get_paywall_html(WP_Post $post, bool $is_fragment = false): string {
$meta = aksrt_get_pay_meta($post);
$pay_type = $meta['pay_type'];
$priced = function_exists('aksrt_get_user_pay_price') ? aksrt_get_user_pay_price($post) : null;
@@ -1117,6 +1164,9 @@ if (!function_exists('aksrt_get_paywall_html')) {
];
$type_info = $type_labels[$pay_type] ?? $type_labels[AKSRT_PAY_TYPE_READING];
if ($is_fragment && $pay_type === AKSRT_PAY_TYPE_READING) {
$type_info['desc'] = __('付费后即可查看此片段内容', 'aksrt');
}
$login_url = function_exists('aksrt_get_login_url') ? aksrt_get_login_url() : wp_login_url(get_permalink($post));
$is_logged_in = is_user_logged_in();
$original_price = (float) ($meta['original_price'] ?? 0);
@@ -1129,13 +1179,12 @@ if (!function_exists('aksrt_get_paywall_html')) {
ob_start();
?>
<div class="aksrt-paywall" data-post-id="<?php echo esc_attr((string) $post->ID); ?>" data-pay-type="<?php echo esc_attr($pay_type); ?>" data-pay-modo="<?php echo esc_attr($pay_modo); ?>">
<div class="aksrt-paywall__overlay">
<div class="aksrt-paywall__blur"></div>
<div class="aksrt-paywall__card">
<div class="aksrt-paywall__icon">
<i class="fas <?php echo esc_attr($type_info['icon']); ?>" aria-hidden="true"></i>
</div>
<div class="aksrt-paywall<?php echo $is_fragment ? ' aksrt-paywall--fragment' : ''; ?>" data-post-id="<?php echo esc_attr((string) $post->ID); ?>" data-pay-type="<?php echo esc_attr($pay_type); ?>" data-pay-modo="<?php echo esc_attr($pay_modo); ?>">
<div class="aksrt-paywall__card" role="group" aria-label="<?php esc_attr_e('付费内容购买', 'aksrt'); ?>">
<div class="aksrt-paywall__icon">
<i class="fas <?php echo esc_attr($type_info['icon']); ?>" aria-hidden="true"></i>
</div>
<div class="aksrt-paywall__body">
<h3 class="aksrt-paywall__title"><?php echo esc_html($type_info['label']); ?></h3>
<p class="aksrt-paywall__desc"><?php echo esc_html($type_info['desc']); ?></p>
<div class="aksrt-paywall__price">
@@ -1150,18 +1199,18 @@ if (!function_exists('aksrt_get_paywall_html')) {
<span class="aksrt-paywall__price-free"><?php esc_html_e('免费', 'aksrt'); ?></span>
<?php endif; ?>
</div>
</div>
<div class="aksrt-paywall__actions">
<?php if (!$is_logged_in) : ?>
<?php if ($pay_limit <= 0 && $price > 0 && $pay_modo !== 'points' && function_exists('aksrt_allow_guest_content_pay') && aksrt_allow_guest_content_pay($post)) : ?>
<button type="button" class="but <?php echo $pay_modo === 'points' ? 'jb-yellow' : 'jb-red'; ?> padding-lg cashier-link aksrt-paywall__btn" data-aksrt-pay-content="<?php echo esc_attr((string) $post->ID); ?>">
<i class="fa fa-shopping-cart" aria-hidden="true"></i> <?php printf(esc_html__('游客购买(%s)', 'aksrt'), $price_text); ?>
</button>
<p class="aksrt-paywall__hint" style="margin-top:10px;">
<a href="<?php echo esc_url($login_url); ?>"><?php esc_html_e('已有账号?登录后购买', 'aksrt'); ?></a>
</p>
<a href="<?php echo esc_url($login_url); ?>" class="aksrt-paywall__login-link"><?php esc_html_e('已有账号?登录后购买', 'aksrt'); ?></a>
<?php else : ?>
<a href="<?php echo esc_url($login_url); ?>" class="but jb-red padding-lg aksrt-paywall__btn">
<i class="fa fa-sign-in" aria-hidden="true"></i> <?php esc_html_e('登录后购买', 'aksrt'); ?>
</a>
<a href="<?php echo esc_url($login_url); ?>" class="but jb-red padding-lg aksrt-paywall__btn">
<i class="fa fa-sign-in" aria-hidden="true"></i> <?php esc_html_e('登录后购买', 'aksrt'); ?>
</a>
<?php endif; ?>
<?php elseif ($member_blocked) : ?>
<a href="<?php echo esc_url($vip_url); ?>" class="but jb-vip padding-lg aksrt-paywall__btn">
@@ -1173,7 +1222,7 @@ if (!function_exists('aksrt_get_paywall_html')) {
</button>
<?php endif; ?>
<p class="aksrt-paywall__hint">
<i class="fas fa-shield-alt"></i> <?php esc_html_e('购买后永久有效,支持重复查看', 'aksrt'); ?>
<i class="fas fa-shield-alt" aria-hidden="true"></i> <?php esc_html_e('购买后永久有效,支持重复查看', 'aksrt'); ?>
</p>
</div>
</div>
@@ -1183,38 +1232,6 @@ if (!function_exists('aksrt_get_paywall_html')) {
}
}
// ============================================================
// 付费墙样式注入
// ============================================================
add_action('wp_head', function (): void {
if (is_admin() || !is_singular()) return;
global $post;
if (!($post instanceof WP_Post)) return;
if (!function_exists('aksrt_can_access_content') || aksrt_can_access_content($post)) return;
?>
<style>
.aksrt-paywall{position:relative;margin:24px 0}
.aksrt-paywall__overlay{position:relative}
.aksrt-paywall__blur{filter:blur(12px);pointer-events:none;user-select:none;max-height:200px;overflow:hidden;opacity:.3}
.aksrt-paywall__card{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:var(--surface-1,#fff);border:1px solid var(--color-line,#e5e7eb);border-radius:var(--radius-lg,14px);padding:40px;text-align:center;box-shadow:0 12px 40px rgba(0,0,0,.12);min-width:320px;max-width:420px}
.aksrt-paywall__icon{font-size:48px;color:var(--color-primary,#f04494);margin-bottom:16px}
.aksrt-paywall__title{font-size:20px;font-weight:700;margin:0 0 8px;color:var(--color-ink,#15171A)}
.aksrt-paywall__desc{font-size:14px;color:var(--color-ink-3,#525866);margin:0 0 20px}
.aksrt-paywall__price{margin-bottom:20px}
.aksrt-paywall__price-amount{font-size:28px;font-weight:700;color:var(--color-error,#EF4444)}
.aksrt-paywall__price-original{font-size:14px;color:var(--color-ink-4,#8A8F98);text-decoration:line-through;margin-left:8px}
.aksrt-paywall__price-free{font-size:20px;font-weight:600;color:var(--color-success,#10B981)}
.aksrt-paywall__btn{display:inline-flex;align-items:center;gap:8px}
.aksrt-paywall__hint{font-size:12px;color:var(--color-ink-4,#8A8F98);margin:16px 0 0}
.aksrt-gallery-locked{filter:blur(8px);pointer-events:none;user-select:none}
.aksrt-paywall-preview{margin:0 0 16px;padding:14px 16px;border-left:3px solid var(--color-primary,#f04494);background:var(--surface-2,#f8fafc);border-radius:0 10px 10px 0;color:var(--color-ink-2,#374151);line-height:1.7}
.aksrt-paywall-preview p:last-child{margin-bottom:0}
.aksrt-paid-gallery__overlay .but,.aksrt-paid-video__placeholder .but{margin-top:10px}
</style>
<?php
});
// ============================================================
// 付费图库轮播(Swiper)
// ============================================================
@@ -1362,13 +1379,17 @@ if (!function_exists('aksrt_paid_content_filter')) {
if (!($post instanceof WP_Post)) {
return $content;
}
$meta = aksrt_get_pay_meta($post);
$pay_type = (string) ($meta['pay_type'] ?? AKSRT_PAY_TYPE_NO);
// Reading is protected only by the payshow shortcode. Never replace
// the entire article body with a post-level purchase wall.
if ($pay_type === AKSRT_PAY_TYPE_READING) {
return $content;
}
if (aksrt_can_access_content($post)) {
return $content;
}
$meta = aksrt_get_pay_meta($post);
$pay_type = (string) ($meta['pay_type'] ?? AKSRT_PAY_TYPE_NO);
if (!in_array($pay_type, [AKSRT_PAY_TYPE_READING, AKSRT_PAY_TYPE_GALLERY, AKSRT_PAY_TYPE_VIDEO], true)) {
if (!in_array($pay_type, [AKSRT_PAY_TYPE_GALLERY, AKSRT_PAY_TYPE_VIDEO], true)) {
return $content;
}
+3 -2
View File
@@ -74,8 +74,9 @@ if (!function_exists('aksrt_single_build_context')) :
$pay_meta = function_exists('aksrt_get_pay_meta') ? aksrt_get_pay_meta($post_id) : ['pay_type' => 'no'];
$pay_type = (string) ($pay_meta['pay_type'] ?? 'no');
$is_paid_post = function_exists('aksrt_is_paid_post') && aksrt_is_paid_post($post_id);
// Pay module: download box OR non-download paid types (reading/gallery/video/vip).
$show_pay_box = $has_download || ($is_paid_post && $pay_type !== 'no' && $pay_type !== 'download');
// Paid-reading purchase is rendered inline by each payshow shortcode.
// Keep the article-level module for download, gallery, video and VIP only.
$show_pay_box = $has_download || ($is_paid_post && !in_array($pay_type, ['no', 'download', 'reading'], true));
$breadcrumbs_html = '';
if (!empty($settings['breadcrumbs']) && function_exists('aksrt_breadcrumbs')) {
+7
View File
@@ -89,6 +89,10 @@ class ResourceMetaBox {
</span>
</div>
<p class="aksrt-pmb__hint" id="aksrt-pmb-reading-hint" hidden>
<?php esc_html_e('付费阅读价格只作用于正文中的“付费可见”片段;未插入片段时,正文保持公开。', 'aksrt'); ?>
</p>
<div class="aksrt-pmb__fields" id="aksrt-pmb-fields" data-disabled="<?php echo $is_paid ? '0' : '1'; ?>">
<!-- 基础价格 -->
@@ -378,6 +382,9 @@ class ResourceMetaBox {
var isPaid = t !== 'no';
fields.setAttribute('data-disabled', isPaid ? '0' : '1');
var readingHint = document.getElementById('aksrt-pmb-reading-hint');
if (readingHint) readingHint.hidden = t !== 'reading';
// 根据付费类型显示/隐藏相关字段
if (dlFields) dlFields.style.display = (t === 'download') ? '' : 'none';
if (galleryFields) galleryFields.style.display = (t === 'gallery') ? '' : 'none';
+5
View File
@@ -163,10 +163,15 @@ class Setup {
'type' => 'string',
'enum' => array_keys(DomainModel::pay_type_labels()),
],
'pay_modo' => ['type' => 'string', 'enum' => ['0', 'points']],
'price' => ['type' => 'number'],
'points_price' => ['type' => 'integer'],
'original_price' => ['type' => 'number'],
'vip1_price' => ['type' => 'number'],
'vip2_price' => ['type' => 'number'],
'vip1_points' => ['type' => 'integer'],
'vip2_points' => ['type' => 'integer'],
'pay_limit' => ['type' => 'integer', 'enum' => [0, 1, 2]],
'version' => ['type' => 'string'],
'size' => ['type' => 'string'],
'license' => ['type' => 'string'],
+26 -11
View File
@@ -101,6 +101,17 @@ class Shortcodes {
$atts = shortcode_atts(['message' => ''], $atts, 'payshow');
$pid = get_the_ID();
$post = $pid > 0 ? get_post($pid) : null;
// Payshow is a paid-reading fragment, not a generic hide mechanism.
// Until the post has an active reading price, keep its content public
// so authors can compose and publish before completing price setup.
if (!($post instanceof \WP_Post)
|| !function_exists('aksrt_is_payshow_purchase_enabled')
|| !aksrt_is_payshow_purchase_enabled($post)) {
return '<div class="aksrt-hidden-content aksrt-hidden-content--unlocked aksrt-hidden-content--payshow">'
. do_shortcode($content) . '</div>';
}
$can_view = $this->viewer_can_see_paid_block($pid);
if ($can_view) {
@@ -109,18 +120,22 @@ class Shortcodes {
}
$message = $atts['message'] ?: __('此内容需付费后查看', 'aksrt');
$uid = get_current_user_id();
$html = '<div class="aksrt-hidden-content aksrt-hidden-content--locked aksrt-hidden-content--payshow">';
$html .= '<div class="aksrt-hidden-content__message">';
$html .= '<i class="fas fa-lock" aria-hidden="true"></i> ';
$html .= '<span>' . esc_html($message) . '</span>';
$html .= '</div>';
if ($uid <= 0) {
$html .= '<a href="' . esc_url(wp_login_url(get_permalink())) . '" class="aksrt-hidden-content__login-btn btn-primary btn-sm">';
$html .= esc_html__('登录后购买查看', 'aksrt') . '</a>';
if (function_exists('aksrt_get_paywall_html')) {
$paywall = aksrt_get_paywall_html($post, true);
if ($message !== '') {
$paywall = str_replace(
'<p class="aksrt-paywall__desc">',
'<p class="aksrt-paywall__desc">' . esc_html($message) . ' ',
$paywall
);
}
return $paywall;
}
$html .= '</div>';
return $html;
return '<div class="aksrt-hidden-content aksrt-hidden-content--locked aksrt-hidden-content--payshow">'
. '<div class="aksrt-hidden-content__message"><i class="fas fa-lock" aria-hidden="true"></i><span>'
. esc_html($message)
. '</span></div></div>';
}
/**