feat(creator-studio): 前台 Gutenberg 创作工作台与受保护付费附件

前台投稿页改为沉浸式创作工作台:Gutenberg 负责区块解析与序列化,
React + TypeScript 负责工作台外壳与发布抽屉,Jio 服务端继续作为权限、
审核与付费的唯一权威。

- 新增 assets/creator-studio 独立构建(wp-scripts,锁定 WP 6.4 时代包),
  仅在投稿模板加载,构建缺失或游客访问时回退原有 TinyMCE 表单
- 自动保存(4s 防抖 + rev 冲突 409)、本地快照恢复、发布配置抽屉、
  移动端全屏单列与安全区适配
- 图片插入走现有 front-submit 上传管线,保留能力校验与待认领生命周期
- 已发布文章的改稿写入私有版本,审核通过前线上内容/链接/付费条款不变;
  原生后台「改稿审核」一次性应用或退回,并校验源文章并发指纹
- 私有附件新表 jio_private_asset + jio_dl_token.asset_id,schema 升到 3.4.0;
  文件存放 ABSPATH 之外,未配置私有存储时 fail closed
- 下载令牌绑定文章/附件/用户/IP/UA/次数,每次交付复核 Visibility 的
  订单、VIP 与配额判定;中断传输归还次数
- 付费字段改为写入前校验,非法价格不再留下半保存文章
- 新增 creator-studio 契约测试;更新受影响的 schema/下载/投稿契约

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kate522andClaude Opus 5 committed 2026-11-06 14:53:23 +08:00
1 parent 7a8279f8e7
commit fac4a714e0
45 files changed
+28495 -56

No files matched your search

+32
View File
@@ -32,6 +32,38 @@ if (!empty($_GET['edit']) && empty($ctx['edit_post'])) {
exit;
}
// Jio Creator Studio:登录用户 + 构建产物存在时使用沉浸式 Gutenberg 工作台。
// 否则保留原有 TinyMCE 表单作为降级路径(游客投稿、未构建、无权限)。
$studio = class_exists('\Jio\Core\CreatorStudio') ? \Jio\Core\CreatorStudio::get_instance() : null;
$use_studio = $studio
&& $studio::is_built()
&& get_current_user_id() > 0
&& !empty($ctx['is_can_new']);
if ($use_studio) {
get_header();
?>
<main id="main-content" role="main" tabindex="-1">
<div id="jio-creator-studio">
<div class="jio-studio-skeleton" aria-live="polite">
<?php esc_html_e('正在加载创作工作台…', 'jio'); ?>
</div>
</div>
<?php
// 人机验证挂载点:由发布抽屉在提交时移入并展示,脚本扫描只执行一次。
if (function_exists('jio_render_captcha') && function_exists('jio_is_captcha_enabled_for') && jio_is_captcha_enabled_for('front_submit')) :
?>
<div id="jio-creator-captcha" hidden>
<?php jio_render_captcha('front_submit'); ?>
</div>
<?php
endif;
?>
</main>
<?php
get_footer();
return;
}
// wp_editor() is rendered after get_header(); queue its runtime before wp_head.
wp_enqueue_editor();
+34 -4
View File
@@ -336,10 +336,22 @@ if ($is_need_purchase && $user_amount > 0) {
<?php foreach ($downloads as $idx => $row) :
$label = isset($row['label']) ? trim((string) $row['label']) : '';
$type = isset($row['type']) ? (string) $row['type'] : 'direct';
$asset_key = class_exists('\Jio\Content\PrivateAssets')
? \Jio\Content\PrivateAssets::sanitize_asset_key((string) ($row['asset_key'] ?? ''))
: '';
$is_protected = $asset_key !== '';
$url = isset($row['url']) ? (string) $row['url'] : '';
if ($url === '') {
// 直连模式没有可用的原始地址,空源不渲染。
continue;
if (!$is_protected) {
if ($url === '') {
// 直连模式没有可用的原始地址,空源不渲染。
continue;
}
} else {
// 受保护附件按设计没有公开 URL,只带 asset_key。
$url = '';
}
if ($is_protected && $label === '') {
$label = trim((string) ($row['file_name'] ?? ''));
}
$extract_code = isset($row['extract_code']) ? trim((string) $row['extract_code']) : '';
$has_extract = $extract_code !== '';
@@ -349,7 +361,9 @@ if ($is_need_purchase && $user_amount > 0) {
$button_class = isset($row['class']) ? trim((string) $row['class']) : '';
$has_copy = $copy_val !== '';
$service = function_exists('jio_detect_cloud_service') ? jio_detect_cloud_service($url) : ['key' => 'other', 'name' => $label, 'color' => '#6b7280'];
$service = $is_protected
? ['key' => 'protected', 'name' => __('受保护下载', 'jio'), 'color' => '#267665']
: (function_exists('jio_detect_cloud_service') ? jio_detect_cloud_service($url) : ['key' => 'other', 'name' => $label, 'color' => '#6b7280']);
if ($label === '') {
$label = strpos($service['name'], '本地') === 0 ? sprintf(__('下载源 %d', 'jio'), $idx + 1) : (string) $service['name'];
}
@@ -376,6 +390,21 @@ if ($is_need_purchase && $user_amount > 0) {
?>
<li class="resource-download-box__item">
<div class="resource-download-box__row">
<?php if ($is_protected && !$is_need_login) : ?>
<button
type="button"
class="resource-download-box__btn btn-primary resource-download-box__btn--service-protected<?php echo esc_attr($btn_modifier); ?><?php echo esc_attr($btn_extra_class); ?>"
data-jio-protected-asset="<?php echo esc_attr($asset_key); ?>"
data-jio-protected-post="<?php echo esc_attr((string) $post_id); ?>"
style="--service-color: <?php echo esc_attr($service['color']); ?>"
>
<?php echo $btn_icon; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
<span><?php echo esc_html($btn_text); ?></span>
<span class="resource-download-box__service-badge" style="background: <?php echo esc_attr($service['color']); ?>">
<?php echo esc_html($service['name']); ?>
</span>
</button>
<?php else : ?>
<a
class="resource-download-box__btn btn-primary resource-download-box__btn--service-<?php echo esc_attr($service['key']); ?><?php echo esc_attr($btn_modifier); ?><?php echo esc_attr($btn_extra_class); ?>"
href="<?php echo esc_url($btn_href); ?>"<?php echo $btn_target; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?><?php echo $btn_rel; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
@@ -389,6 +418,7 @@ if ($is_need_purchase && $user_amount > 0) {
<?php echo esc_html($service['name']); ?>
</span>
</a>
<?php endif; ?>
<?php if ($has_extract || $has_copy) : ?>
<button type="button" class="resource-download-box__btn resource-download-box__btn--extract"
<?php if ($has_copy && $copy_key !== '') : ?>data-toggle="tooltip" title="<?php echo esc_attr(sprintf(__('点击复制%s', 'jio'), $copy_key)); ?>"<?php endif; ?>
+10 -2
View File
@@ -15,7 +15,15 @@ $status_labels = ['publish' => __('已发布', 'jio'), 'draft' => __('草稿', '
<?php while ($q->have_posts()) : $q->the_post();
$post_id = get_the_ID();
$post_status = get_post_status();
$edit_url = $front_submit && $front_submit->can_edit_post($post_id, (int) $user_id) ? jio_get_new_post_url($post_id) : '';
$review_note = '';
if ($front_submit && class_exists('\\Jio\\Core\\SubmissionVersion') && \Jio\Core\SubmissionVersion::requires_version((int) $post_id)) {
$working = \Jio\Core\SubmissionVersion::get_working((int) $post_id, (int) $user_id);
if ($working instanceof WP_Post) {
$review_note = \Jio\Core\SubmissionVersion::status_label(\Jio\Core\SubmissionVersion::status((int) $working->ID));
}
}
// 编辑入口始终指向源文章;创作工作台会自行解析作者当前的工作版本。
$edit_url = $front_submit && $front_submit->can_edit_post($post_id, (int) $user_id) ? jio_get_new_post_url((int) $post_id) : '';
$can_delete = $front_submit && $front_submit->can_delete_post($post_id, (int) $user_id);
$price = (float) get_post_meta($post_id, '_jio_pay_price', true);
?>
@@ -23,7 +31,7 @@ $status_labels = ['publish' => __('已发布', 'jio'), 'draft' => __('草稿', '
<a class="creator-post-row__thumb" href="<?php echo esc_url(get_permalink($post_id)); ?>" aria-label="<?php echo esc_attr(get_the_title()); ?>">
<?php if (has_post_thumbnail($post_id)) : the_post_thumbnail('medium'); else : ?><span><i class="fa fas fa-pen" aria-hidden="true"></i></span><?php endif; ?>
</a>
<div class="creator-post-row__body"><div class="creator-post-row__heading"><h4><a href="<?php echo esc_url(get_permalink($post_id)); ?>"><?php echo esc_html(get_the_title()); ?></a></h4><span class="creator-post-row__status creator-post-row__status--<?php echo esc_attr($post_status); ?>"><?php echo esc_html($status_labels[$post_status] ?? $post_status); ?></span></div><p><?php echo esc_html(wp_trim_words(get_the_excerpt(), 20, '…')); ?></p><div class="creator-post-row__meta"><time datetime="<?php echo esc_attr(get_the_date('c')); ?>"><?php echo esc_html(get_the_date()); ?></time><span><?php echo $price > 0 ? esc_html__('付费内容', 'jio') : esc_html__('免费内容', 'jio'); ?></span></div></div>
<div class="creator-post-row__body"><div class="creator-post-row__heading"><h4><a href="<?php echo esc_url(get_permalink($post_id)); ?>"><?php echo esc_html(get_the_title($post_id)); ?></a></h4><span class="creator-post-row__status creator-post-row__status--<?php echo esc_attr($post_status); ?>"><?php echo esc_html($status_labels[$post_status] ?? $post_status); ?></span><?php if ($review_note !== '') : ?><span class="creator-post-row__status creator-post-row__status--review"><?php echo esc_html(sprintf(__('改稿:%s', 'jio'), $review_note)); ?></span><?php endif; ?></div><p><?php echo esc_html(wp_trim_words(get_the_excerpt(), 20, '…')); ?></p><div class="creator-post-row__meta"><time datetime="<?php echo esc_attr(get_the_date('c')); ?>"><?php echo esc_html(get_the_date()); ?></time><span><?php echo $price > 0 ? esc_html__('付费内容', 'jio') : esc_html__('免费内容', 'jio'); ?></span></div></div>
<div class="creator-post-row__actions"><?php if ($edit_url) : ?><a href="<?php echo esc_url($edit_url); ?>" class="creator-post-row__action"><?php esc_html_e('编辑', 'jio'); ?></a><?php endif; ?><a href="<?php echo esc_url(get_permalink($post_id)); ?>" class="creator-post-row__action"><?php esc_html_e('查看', 'jio'); ?></a><?php if ($can_delete) : ?><button type="button" class="creator-post-row__action creator-post-row__action--danger" data-action="delete-post" data-post-id="<?php echo esc_attr((string) $post_id); ?>" data-nonce="<?php echo esc_attr(wp_create_nonce('jio_submit')); ?>"><?php esc_html_e('删除', 'jio'); ?></button><?php endif; ?></div>
</article>
<?php endwhile; ?>