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

@@ -0,0 +1,512 @@
/**
* 发布配置抽屉。
*
* 封面、分类、摘要、付费与资料附件都只在这里出现,正文画布保持干净;
* 手机端以全屏面板呈现。所有字段与权限来自服务端 bootstrap,前端不自行放大权限。
*
* @package Jio
*/
import { useEffect, useMemo, useRef, useState } from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';
import { apiUpload } from '../services/api';
type Featured = { type: string; data: Record<string, any>; mediaId?: number };
type PayState = {
enabled: boolean;
modo: 'cash' | 'points';
price: string;
points: number;
vip1Price: string;
vip2Price: string;
payLimit: number;
downloads: Array<Record<string, any>>;
};
type Draft = {
postId: number;
title: string;
excerpt: string;
categories: number[];
tags: string[];
custom: Record<string, string[]>;
featured: Featured;
pay: PayState;
};
type Capabilities = Record<string, boolean>;
type Limits = {
titleMin?: number;
titleMax?: number;
priceMin?: number;
priceMax?: number;
pointsMin?: number;
pointsMax?: number;
};
type Props = {
draft: Draft;
setDraft: (patch: Partial<Draft>) => void;
capabilities: Capabilities;
limits: Limits;
captcha: boolean;
taxonomy: {
categories: Array<{ id: number; name: string }>;
tags: boolean;
customDefs: Array<{ key: string; name: string; values: Array<{ key: string; name: string }> }>;
};
isPublishedEdit: boolean;
onClose: () => void;
onSaveDraft: () => void;
onSubmit: (publishNow: boolean) => void;
privateStorage: boolean;
privateUploads: Array<Record<string, any>>;
saving: boolean;
};
const bootstrap: Record<string, any> = window['jioCreatorStudio'] || {};
const nonce: string = bootstrap.nonce || '';
export function PublishDrawer(props: Props) {
const {
draft,
setDraft,
capabilities,
limits,
taxonomy,
isPublishedEdit,
onClose,
onSaveDraft,
onSubmit,
privateStorage,
privateUploads,
saving,
captcha,
} = props;
const [tagInput, setTagInput] = useState('');
const [uploadError, setUploadError] = useState('');
const [uploading, setUploading] = useState(false);
const coverInput = useRef<HTMLInputElement | null>(null);
const fileInput = useRef<HTMLInputElement | null>(null);
const captchaSlot = useRef<HTMLDivElement | null>(null);
// 人机验证由 PHP 渲染(脚本在页面加载时按 uid 注册实例),这里只把挂载点
// 移进抽屉,保证验证控件在用户提交时可见、可交互。
useEffect(() => {
const slot = captchaSlot.current;
const mount = document.getElementById('jio-creator-captcha');
if (!slot || !mount) {
return undefined;
}
const previous = mount.parentElement;
slot.appendChild(mount);
mount.removeAttribute('hidden');
return () => {
if (previous) {
previous.appendChild(mount);
mount.setAttribute('hidden', '');
}
};
}, []);
const titleLength = useMemo(() => (draft.title || '').trim().length, [draft.title]);
const canSubmit = titleLength >= Number(limits.titleMin || 1);
const toggleCategory = (id: number) => {
const has = draft.categories.includes(id);
setDraft({
categories: has ? draft.categories.filter((value) => value !== id) : [...draft.categories, id],
});
};
const addTag = () => {
const value = tagInput.trim();
if (!value) {
return;
}
const next = value
.split(/[,,\n]+/u)
.map((item) => item.trim())
.filter(Boolean);
setDraft({ tags: Array.from(new Set([...draft.tags, ...next])) });
setTagInput('');
};
const uploadCover = async () => {
const file = coverInput.current?.files?.[0];
if (!file || !bootstrap.media?.uploadUrl?.image) {
return;
}
setUploadError('');
setUploading(true);
try {
const response = await apiUpload(bootstrap.media.uploadUrl.image, file, nonce);
const id = Number(response.attachment_id || response.id || 0);
const url = String(response.url || '');
if (!id || !url) {
throw new Error(__('上传后无法读取图片。', 'jio'));
}
setDraft({ featured: { type: 'image', mediaId: id, data: { id, url } } });
} catch (error: any) {
setUploadError(String(error?.message || __('上传失败', 'jio')));
} finally {
setUploading(false);
}
};
const uploadAttachment = async () => {
const file = fileInput.current?.files?.[0];
if (!file) {
return;
}
setUploadError('');
setUploading(true);
try {
const response = await apiUpload(bootstrap.creator.privateUrl, file, nonce, {
post_id: String(draft.postId || 0),
});
const next = {
type: 'protected',
assetKey: String(response.asset_key || ''),
fileName: String(response.name || file.name),
sizeBytes: Number(response.size || file.size),
sizeLabel: String(response.size_label || ''),
label: '',
};
setDraft({
pay: { ...draft.pay, enabled: true, downloads: [...draft.pay.downloads, next] },
});
if (fileInput.current) {
fileInput.current.value = '';
}
} catch (error: any) {
setUploadError(String(error?.message || __('资料上传失败', 'jio')));
} finally {
setUploading(false);
}
};
const removeDownload = (index: number) => {
setDraft({
pay: { ...draft.pay, downloads: draft.pay.downloads.filter((_, i) => i !== index) },
});
};
return (
<div className="jio-creator-drawer" role="dialog" aria-modal="true" aria-labelledby="jio-creator-publish-title">
<button type="button" className="jio-creator-drawer__backdrop" aria-label={__('关闭', 'jio')} onClick={onClose} />
<div className="jio-creator-drawer__panel">
<header className="jio-creator-drawer__head">
<h2 id="jio-creator-publish-title">{isPublishedEdit ? __('提交改稿', 'jio') : __('发布文章', 'jio')}</h2>
<button type="button" className="jio-creator-drawer__close" onClick={onClose} aria-label={__('关闭', 'jio')}>
<i className="fa fas fa-times" aria-hidden="true" />
</button>
</header>
<div className="jio-creator-drawer__body">
{isPublishedEdit && (
<p className="jio-creator-drawer__note">
{__('审核通过前,读者看到的仍然是当前线上版本。', 'jio')}
</p>
)}
<section className="jio-creator-field">
<h3>{__('封面', 'jio')}</h3>
{draft.featured?.data?.url ? (
<div className="jio-creator-cover">
<img src={String(draft.featured.data.url)} alt="" />
<button
type="button"
onClick={() => setDraft({ featured: { type: '', data: {} } })}
aria-label={__('移除封面', 'jio')}
>
<i className="fa fas fa-times" aria-hidden="true" />
</button>
</div>
) : capabilities.canUploadImage ? (
<label className="jio-creator-cover-empty">
<input
ref={coverInput}
type="file"
accept="image/*"
onChange={() => void uploadCover()}
disabled={uploading}
/>
<i className="fa fas fa-plus" aria-hidden="true" />
<span>{uploading ? __('上传中…', 'jio') : __('上传封面', 'jio')}</span>
</label>
) : (
<p className="jio-creator-muted">{__('当前账号不能设置封面。', 'jio')}</p>
)}
</section>
<section className="jio-creator-field">
<h3>{__('分类', 'jio')}</h3>
<div className="jio-creator-chips">
{taxonomy.categories.length === 0 && <span className="jio-creator-muted">{__('暂无可选分类', 'jio')}</span>}
{taxonomy.categories.map((category) => (
<label
key={category.id}
className={`jio-creator-chip${draft.categories.includes(category.id) ? ' is-active' : ''}`}
>
<input
type="checkbox"
checked={draft.categories.includes(category.id)}
onChange={() => toggleCategory(category.id)}
/>
<span>{category.name}</span>
</label>
))}
</div>
</section>
{taxonomy.tags && (
<section className="jio-creator-field">
<h3>{__('标签', 'jio')}</h3>
<div className="jio-creator-chips">
{draft.tags.map((tag) => (
<span key={tag} className="jio-creator-chip is-active">
<span>{tag}</span>
<button
type="button"
onClick={() => setDraft({ tags: draft.tags.filter((value) => value !== tag) })}
aria-label={sprintf(__('移除标签 %s', 'jio'), tag)}
>
<i className="fa fas fa-times" aria-hidden="true" />
</button>
</span>
))}
</div>
<div className="jio-creator-inline">
<input
type="text"
value={tagInput}
placeholder={__('输入标签,逗号分隔', 'jio')}
onChange={(event) => setTagInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
addTag();
}
}}
/>
<button type="button" onClick={addTag}>{__('添加', 'jio')}</button>
</div>
</section>
)}
{taxonomy.customDefs.map((dimension) => (
<section className="jio-creator-field" key={dimension.key}>
<h3>{dimension.name}</h3>
<div className="jio-creator-chips">
{dimension.values.map((value) => {
const current = draft.custom[dimension.key] || [];
const active = current.includes(value.key);
return (
<label key={value.key} className={`jio-creator-chip${active ? ' is-active' : ''}`}>
<input
type="checkbox"
checked={active}
onChange={() =>
setDraft({
custom: {
...draft.custom,
[dimension.key]: active
? current.filter((item) => item !== value.key)
: [...current, value.key],
},
})
}
/>
<span>{value.name}</span>
</label>
);
})}
</div>
</section>
))}
{capabilities.canPay && (
<section className="jio-creator-field">
<div className="jio-creator-switch">
<div>
<h3>{__('付费内容', 'jio')}</h3>
<p className="jio-creator-muted">{__('开启后,未购买读者只能看到免费部分。', 'jio')}</p>
</div>
<label className="jio-creator-toggle">
<input
type="checkbox"
checked={draft.pay.enabled}
onChange={(event) => setDraft({ pay: { ...draft.pay, enabled: event.target.checked } })}
/>
<span aria-hidden="true" />
</label>
</div>
{draft.pay.enabled && (
<div className="jio-creator-pay">
<div className="jio-creator-inline">
<label>
<input
type="radio"
checked={draft.pay.modo === 'cash'}
onChange={() => setDraft({ pay: { ...draft.pay, modo: 'cash' } })}
/>
<span>{__('现金', 'jio')}</span>
</label>
<label>
<input
type="radio"
checked={draft.pay.modo === 'points'}
onChange={() => setDraft({ pay: { ...draft.pay, modo: 'points' } })}
/>
<span>{__('积分', 'jio')}</span>
</label>
</div>
{draft.pay.modo === 'cash' ? (
<label className="jio-creator-input">
<span>{__('价格', 'jio')}</span>
<input
type="number"
min={Number(limits.priceMin || 0)}
step="0.01"
value={draft.pay.price}
onChange={(event) => setDraft({ pay: { ...draft.pay, price: event.target.value } })}
/>
</label>
) : (
<label className="jio-creator-input">
<span>{__('积分价格', 'jio')}</span>
<input
type="number"
min={0}
step="1"
value={draft.pay.points}
onChange={(event) =>
setDraft({ pay: { ...draft.pay, points: Number(event.target.value) || 0 } })
}
/>
</label>
)}
{capabilities.canPayDownload && (
<div className="jio-creator-attachments">
<div className="jio-creator-attachments__head">
<h4>{__('资料附件', 'jio')}</h4>
{privateStorage ? (
<label className="jio-creator-upload">
<input
ref={fileInput}
type="file"
onChange={() => void uploadAttachment()}
disabled={uploading}
/>
<span>{uploading ? __('上传中…', 'jio') : __('添加 PDF / ZIP', 'jio')}</span>
</label>
) : (
<p className="jio-creator-muted">
{__('私有附件存储尚未配置,暂时不能上传付费资料。', 'jio')}
</p>
)}
</div>
{uploadError && <p className="jio-creator-error">{uploadError}</p>}
{draft.pay.downloads.length > 0 && (
<ul className="jio-creator-files">
{draft.pay.downloads.map((row, index) => (
<li key={`${row.assetKey || row.url}-${index}`}>
<span className="jio-creator-files__name">
{row.assetKey ? (
<>
<i className="fa fas fa-lock" aria-hidden="true" />
{row.fileName || row.label || __('受保护资料', 'jio')}
</>
) : (
<>
<i className="fa fas fa-link" aria-hidden="true" />
{row.label || row.url}
</>
)}
</span>
{row.sizeLabel && <span className="jio-creator-muted">{row.sizeLabel}</span>}
<button type="button" onClick={() => removeDownload(index)} aria-label={__('移除', 'jio')}>
<i className="fa fas fa-times" aria-hidden="true" />
</button>
</li>
))}
</ul>
)}
{privateUploads.length > 0 && (
<details className="jio-creator-reuse">
<summary>{__('从已上传资料中选择', 'jio')}</summary>
<ul>
{privateUploads
.filter((item) => !draft.pay.downloads.some((row) => row.assetKey === item.assetKey))
.map((item) => (
<li key={item.assetKey}>
<span>{item.fileName}</span>
<span className="jio-creator-muted">{item.sizeLabel}</span>
<button
type="button"
onClick={() =>
setDraft({
pay: {
...draft.pay,
enabled: true,
downloads: [...draft.pay.downloads, { ...item, type: 'protected' }],
},
})
}
>
{__('使用', 'jio')}
</button>
</li>
))}
</ul>
</details>
)}
</div>
)}
</div>
)}
</section>
)}
{!capabilities.canPay && capabilities.canSaveDraft && (
<p className="jio-creator-muted">{__('当前账号不能设置付费内容。', 'jio')}</p>
)}
</div>
<footer className="jio-creator-drawer__foot">
{captcha && (
<div className="jio-creator-drawer__captcha" ref={captchaSlot} />
)}
<div className="jio-creator-drawer__actions">
<button type="button" className="jio-creator-ghost" onClick={onSaveDraft} disabled={saving}>
{__('保存草稿', 'jio')}
</button>
<button
type="button"
className="jio-creator-primary"
onClick={() => onSubmit(false)}
disabled={saving || !canSubmit}
>
{isPublishedEdit ? __('提交审核', 'jio') : capabilities.canPublishNow ? __('发布', 'jio') : __('提交审核', 'jio')}
</button>
{capabilities.canPublishNow && (
<button type="button" className="jio-creator-link" onClick={() => onSubmit(true)} disabled={saving || !canSubmit}>
{__('立即发布', 'jio')}
</button>
)}
</div>
</footer>
</div>
</div>
);
}