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

+174
View File
@@ -0,0 +1,174 @@
<?php
/**
* 原生后台:已发布文章的改稿审核。
*
* 运营在此查看待审改稿、对比原文章,并通过 admin-post 动作一次性应用或退回。
* 不提供直接编辑改稿内容的入口,避免绕过作者侧校验与版本指纹。
*
* @package Jio
*/
namespace Jio\Admin\Native;
use Jio\Core\SubmissionVersion;
defined('ABSPATH') || exit;
final class SubmissionReview {
public const SLUG = 'jio_submission_reviews';
private static $instance = null;
public static function get_instance(): self {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
add_action('admin_menu', [$this, 'register_menu']);
add_action('admin_notices', [$this, 'render_notice']);
}
public function register_menu(): void {
add_submenu_page(
'edit.php',
__('改稿审核', 'jio'),
__('改稿审核', 'jio'),
'edit_others_posts',
self::SLUG,
[$this, 'render_page']
);
}
public function render_notice(): void {
$screen = function_exists('get_current_screen') ? get_current_screen() : null;
if (!$screen || strpos((string) $screen->id, self::SLUG) === false) {
return;
}
$notice = isset($_GET['jio_review_notice']) ? sanitize_key((string) wp_unslash($_GET['jio_review_notice'])) : '';
if ($notice === '') {
return;
}
$message = isset($_GET['jio_review_message']) ? sanitize_text_field(rawurldecode((string) wp_unslash($_GET['jio_review_message']))) : '';
$class = $notice === 'ok' ? 'notice-success' : 'notice-error';
echo '<div class="notice ' . esc_attr($class) . ' is-dismissible"><p>' . esc_html($message) . '</p></div>';
}
public function render_page(): void {
if (!SubmissionVersion::can_review(get_current_user_id())) {
wp_die(esc_html__('没有审核权限。', 'jio'));
}
$status = isset($_GET['review_status']) ? sanitize_key((string) wp_unslash($_GET['review_status'])) : SubmissionVersion::STATUS_PENDING;
$status = in_array($status, [SubmissionVersion::STATUS_PENDING, SubmissionVersion::STATUS_REJECTED, SubmissionVersion::STATUS_APPROVED], true)
? $status
: SubmissionVersion::STATUS_PENDING;
$versions = get_posts([
'post_type' => SubmissionVersion::POST_TYPE,
'post_status' => ['pending', 'draft', 'private'],
'posts_per_page' => 40,
'orderby' => 'modified',
'order' => 'DESC',
'meta_key' => SubmissionVersion::META_STATUS,
'meta_value' => $status,
]);
$tabs = [
SubmissionVersion::STATUS_PENDING => __('待审核', 'jio'),
SubmissionVersion::STATUS_REJECTED => __('已退回', 'jio'),
SubmissionVersion::STATUS_APPROVED => __('已应用', 'jio'),
];
?>
<div class="wrap">
<h1><?php esc_html_e('改稿审核', 'jio'); ?></h1>
<p class="description">
<?php esc_html_e('作者对已发布文章的修改以独立版本保存;通过后才会替换线上文章。', 'jio'); ?>
</p>
<ul class="subsubsub">
<?php foreach ($tabs as $key => $label) : ?>
<li>
<a href="<?php echo esc_url(add_query_arg(['page' => self::SLUG, 'review_status' => $key], admin_url('edit.php'))); ?>"
class="<?php echo $key === $status ? 'current' : ''; ?>">
<?php echo esc_html($label); ?>
</a>
<?php echo $key !== array_key_last($tabs) ? ' |' : ''; ?>
</li>
<?php endforeach; ?>
</ul>
<table class="widefat striped" style="margin-top:16px;">
<thead>
<tr>
<th><?php esc_html_e('文章', 'jio'); ?></th>
<th><?php esc_html_e('作者', 'jio'); ?></th>
<th><?php esc_html_e('提交时间', 'jio'); ?></th>
<th><?php esc_html_e('来源', 'jio'); ?></th>
<th><?php esc_html_e('操作', 'jio'); ?></th>
</tr>
</thead>
<tbody>
<?php if (!$versions) : ?>
<tr><td colspan="5"><?php esc_html_e('暂无记录。', 'jio'); ?></td></tr>
<?php endif; ?>
<?php foreach ($versions as $version) :
$version_id = (int) $version->ID;
$source_id = SubmissionVersion::source_id($version_id);
$author = get_userdata((int) $version->post_author);
$note = (string) get_post_meta($version_id, SubmissionVersion::META_NOTE, true);
?>
<tr>
<td>
<strong><?php echo esc_html($version->post_title !== '' ? $version->post_title : __('(无标题)', 'jio')); ?></strong>
<?php if ($note !== '') : ?>
<p class="description"><?php echo esc_html(sprintf(__('退回原因:%s', 'jio'), $note)); ?></p>
<?php endif; ?>
<?php if ($source_id > 0) : ?>
<p class="description">
<a href="<?php echo esc_url(get_edit_post_link($source_id)); ?>">
<?php esc_html_e('查看线上文章', 'jio'); ?>
</a>
</p>
<?php endif; ?>
</td>
<td>
<?php echo $author ? esc_html($author->display_name) : '—'; ?>
</td>
<td>
<?php
$submitted = (string) get_post_meta($version_id, SubmissionVersion::META_SUBMITTED_AT, true);
echo esc_html($submitted !== '' ? $submitted : $version->post_modified);
?>
</td>
<td>
<?php esc_html_e('作者改稿', 'jio'); ?>
</td>
<td>
<?php if ($status === SubmissionVersion::STATUS_PENDING) : ?>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="display:inline;">
<input type="hidden" name="action" value="jio_submission_approve">
<input type="hidden" name="version_id" value="<?php echo esc_attr((string) $version_id); ?>">
<?php wp_nonce_field('jio_review_approve_' . $version_id); ?>
<button type="submit" class="button button-primary"><?php esc_html_e('应用改稿', 'jio'); ?></button>
</form>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="display:inline;margin-left:8px;">
<input type="hidden" name="action" value="jio_submission_reject">
<input type="hidden" name="version_id" value="<?php echo esc_attr((string) $version_id); ?>">
<?php wp_nonce_field('jio_review_reject_' . $version_id); ?>
<input type="text" name="note" placeholder="<?php esc_attr_e('退回原因(可选)', 'jio'); ?>" class="regular-text" style="width:180px;">
<button type="submit" class="button"><?php esc_html_e('退回', 'jio'); ?></button>
</form>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php
}
}