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

+450
View File
@@ -0,0 +1,450 @@
/**
* Jio Creator Studio — 前台沉浸式创作界面。
*
* Gutenberg 负责区块解析、编辑与序列化;本入口只搭建工作台外壳、发布配置抽屉
* 与保存/预览/提交编排。所有写入都走 Jio REST(CreatorRest),服务端负责权限、
* 字段清洗、审核状态与私有附件归属。
*
* @package Jio
*/
import { createRoot, useCallback, useEffect, useMemo, useRef, useState } from '@wordpress/element';
import { parse, serialize } from '@wordpress/blocks';
import { registerCoreBlocks } from '@wordpress/block-library';
import { useSelect } from '@wordpress/data';
import { BlockEditorProvider, BlockList, BlockTools, BlockInspector } from '@wordpress/block-editor';
import { count as countWords } from '@wordpress/wordcount';
import { __ } from '@wordpress/i18n';
import { apiPost } from './services/api';
import { installMediaUploadBridge } from './services/media';
import { PublishDrawer } from './panels/publish-drawer';
import { SaveState, type SaveStateValue } from './components/save-state';
import { useAutosave } from './hooks/use-autosave';
import { useLocalRecovery } from './hooks/use-local-recovery';
import { normalizePayForRequest } from './utils/pay';
import './styles/studio.scss';
// 核心区块注册只做一次;重复调用会触发 "Block type already registered"。
const coreRegistered = (window as any).__jioCoreBlocksRegistered;
if (!coreRegistered && typeof registerCoreBlocks === 'function') {
registerCoreBlocks();
(window as any).__jioCoreBlocksRegistered = true;
}
const bootstrap: Record<string, any> = window['jioCreatorStudio'] || {};
const nonce: string = bootstrap.nonce || '';
const STORAGE_KEY = `jio-studio-${bootstrap.sourceId || 0}`;
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 Limits = {
titleMin?: number;
titleMax?: number;
titlePlaceholder?: string;
priceMin?: number;
priceMax?: number;
pointsMin?: number;
pointsMax?: number;
};
type Draft = {
postId: number;
versionId: number;
rev: number;
title: string;
excerpt: string;
content: string;
categories: number[];
tags: string[];
custom: Record<string, string[]>;
featured: Featured;
pay: PayState;
};
function initialDraft(): Draft {
const pay = bootstrap.pay || {};
const selected = (bootstrap.categories && bootstrap.categories.selected) || [];
const tags = (bootstrap.tags && bootstrap.tags.items) || [];
return {
postId: Number(bootstrap.sourceId || 0),
versionId: Number(bootstrap.versionId || 0),
rev: Number(bootstrap.versionRev || 0),
title: String(bootstrap.title || ''),
excerpt: String(bootstrap.excerpt || ''),
content: String(bootstrap.content || ''),
categories: selected.map(Number),
tags: tags.slice(),
custom: bootstrap.custom || {},
featured: bootstrap.featured || { type: '', data: {} },
pay: {
enabled: !!pay.enabled,
modo: pay.payModo === 'points' ? 'points' : 'cash',
price: String(pay.price || '0.00'),
points: Number(pay.points || 0),
vip1Price: String(pay.vip1Price || '0.00'),
vip2Price: String(pay.vip2Price || '0.00'),
payLimit: Number(pay.payLimit || 0),
downloads: Array.isArray(pay.downloads) ? pay.downloads : [],
},
};
}
function Studio(): JSX.Element {
const [draft, setDraft] = useState<Draft>(initialDraft);
const [blocks, setBlocks] = useState(() => parse(draft.content || ''));
const [saveState, setSaveState] = useState<SaveStateValue>({ state: 'ready', at: '' });
const [drawerOpen, setDrawerOpen] = useState(false);
const [notice, setNotice] = useState<{ tone: 'ok' | 'error'; text: string } | null>(null);
const [recovery, setRecovery] = useState<null | { savedAt: string }>(null);
const dirtyRef = useRef(false);
const capabilities = bootstrap.capabilities || {};
const limits: Limits = {
titleMin: Number(bootstrap.limits?.titleMin || 1),
titleMax: Number(bootstrap.limits?.titleMax || 30),
titlePlaceholder: String(bootstrap.limits?.titlePlaceholder || __('请输入标题', 'jio')),
priceMin: Number(bootstrap.pay?.priceMin || 0),
priceMax: Number(bootstrap.pay?.priceMax || 0),
pointsMin: Number(bootstrap.pay?.pointsMin || 0),
pointsMax: Number(bootstrap.pay?.pointsMax || 0),
};
const isPublishedEdit = !!bootstrap.isPublished;
const patchDraft = useCallback((patch: Partial<Draft>) => {
dirtyRef.current = true;
setDraft((current) => ({ ...current, ...patch }));
}, []);
const onChangeBlocks = useCallback((next: any[]) => {
dirtyRef.current = true;
setBlocks(next);
}, []);
const serialized = useMemo(() => serialize(blocks), [blocks]);
const words = useMemo(() => countWords(serialized.replace(/<[^>]*>/g, ' '), 'words', {}), [serialized]);
const buildPayload = useCallback(
(extra: Record<string, any> = {}) => ({
post_id: draft.postId,
version_id: draft.versionId,
rev: draft.rev,
title: draft.title,
excerpt: draft.excerpt,
content: serialized,
categories: draft.categories,
tags: draft.tags,
custom: draft.custom,
featured: draft.featured,
pay: normalizePayForRequest(draft.pay),
...extra,
}),
[draft, serialized]
);
const save = useCallback(
async (autosave = false): Promise<boolean> => {
if (!dirtyRef.current && (draft.postId > 0 || draft.versionId > 0)) {
return true;
}
// 空文档不触发自动保存:服务端会拒绝无标题/无内容的写入,
// 保留本地快照即可,避免把“保存失败”当成真的故障。
const hasDocument = draft.title.trim() !== '' || serialized.replace(/<[^>]*>/g, '').trim() !== '';
if (autosave && !hasDocument) {
return true;
}
setSaveState({ state: 'saving', at: '' });
try {
const response = await apiPost(bootstrap.creator.saveUrl, buildPayload({ autosave }), nonce);
const savedId = Number(response.post_id || draft.postId || 0);
const versionId = Number(response.version_id || draft.versionId || 0);
const rev = Number(response.rev || response.versionRev || draft.rev || 0);
dirtyRef.current = false;
setDraft((current) => ({ ...current, postId: savedId, versionId, rev }));
setSaveState({ state: 'saved', at: new Date().toLocaleTimeString() });
return true;
} catch (error: any) {
setSaveState({ state: error?.status === 409 ? 'conflict' : 'error', at: '' });
return false;
}
},
[buildPayload, draft.postId, draft.versionId, draft.rev]
);
const submit = useCallback(
async (publishNow: boolean): Promise<void> => {
setSaveState({ state: 'submitting', at: '' });
try {
// 站点启用「前台投稿」人机验证时,先完成挑战再提交;
// 服务端仍会独立校验证明,前端只是把凭证带过去。
let captchaProof = '';
const captcha = (window as any).JioCaptcha;
if (bootstrap.captcha?.enabled && captcha && typeof captcha.run === 'function') {
const payload = await captcha.run('front_submit');
if (payload === null) {
setSaveState({ state: 'ready', at: '' });
setNotice({ tone: 'error', text: __('请先完成人机验证', 'jio') });
return;
}
captchaProof = String(payload?.jio_captcha_proof || '');
}
const response = await apiPost(
bootstrap.creator.submitUrl,
buildPayload({ publish_now: publishNow, captcha_proof: captchaProof }),
nonce
);
dirtyRef.current = false;
setSaveState({ state: 'submitted', at: '' });
setNotice({ tone: 'ok', text: String(response.message || __('已提交', 'jio')) });
setDrawerOpen(false);
const target = response.url || response.goto || bootstrap.urls?.center;
if (target) {
window.setTimeout(() => {
window.location.href = target;
}, 900);
}
} catch (error: any) {
setSaveState({ state: 'error', at: '' });
setNotice({ tone: 'error', text: String(error?.message || __('提交失败', 'jio')) });
}
},
[buildPayload]
);
const preview = useCallback(async (): Promise<void> => {
const saved = await save(false);
if (!saved) {
return;
}
try {
const response = await apiPost(
bootstrap.creator.previewUrl,
{ post_id: draft.postId, version_id: draft.versionId },
nonce
);
if (response.url) {
window.open(String(response.url), '_blank', 'noopener');
}
} catch (error: any) {
setNotice({ tone: 'error', text: String(error?.message || __('预览失败', 'jio')) });
}
}, [draft.postId, draft.versionId, save]);
const { markLocalSaved, clear } = useLocalRecovery({
key: STORAGE_KEY,
snapshot: { title: draft.title, content: serialized, at: new Date().toISOString() },
onFound: (savedAt: string) => setRecovery({ savedAt }),
});
const autosave = useAutosave({
enabled: !!capabilities.isLoggedIn,
onSave: async () => {
const ok = await save(true);
if (ok) {
clear();
}
return ok;
},
onLocal: markLocalSaved,
});
const autosaveSchedule = autosave.schedule;
useEffect(() => {
if (dirtyRef.current) {
autosaveSchedule();
}
}, [draft, serialized, autosaveSchedule]);
useEffect(() => {
const beforeUnload = (event: BeforeUnloadEvent) => {
if (!dirtyRef.current) {
return;
}
event.preventDefault();
event.returnValue = '';
};
window.addEventListener('beforeunload', beforeUnload);
return () => window.removeEventListener('beforeunload', beforeUnload);
}, []);
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
event.preventDefault();
void save(false);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [save]);
const restore = useCallback(() => {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (typeof parsed.title === 'string') {
dirtyRef.current = true;
setDraft((current) => ({ ...current, title: parsed.title }));
}
if (typeof parsed.content === 'string') {
dirtyRef.current = true;
setBlocks(parse(parsed.content));
}
}
} catch (error) {
// 快照损坏时忽略,不影响服务端内容。
}
setRecovery(null);
}, []);
const selectedBlockCount = useSelect(
(select: any) => select('core/block-editor').getBlockCount(),
[]
);
return (
<div className="jio-creator" data-status={bootstrap.status || ''} data-blocks={selectedBlockCount}>
<header className="jio-creator__bar">
<a className="jio-creator__back" href={bootstrap.urls?.center || '/'}>
<i className="fa fas fa-angle-left" aria-hidden="true" />
<span>{__('返回', 'jio')}</span>
</a>
<div className="jio-creator__brand">Jio Creator Studio</div>
<div className="jio-creator__actions">
<SaveState value={saveState} />
<button type="button" className="jio-creator__ghost" onClick={() => void preview()}>
{__('预览', 'jio')}
</button>
<button type="button" className="jio-creator__primary" onClick={() => setDrawerOpen(true)}>
{isPublishedEdit ? __('提交改稿', 'jio') : __('发布', 'jio')}
</button>
</div>
</header>
{recovery && (
<div className="jio-creator__notice" role="status">
<span>{__('发现本地未同步的草稿。', 'jio')}</span>
<button type="button" onClick={restore}>{__('恢复', 'jio')}</button>
<button type="button" onClick={() => setRecovery(null)}>{__('忽略', 'jio')}</button>
</div>
)}
{notice && (
<div className={`jio-creator__notice is-${notice.tone}`} role="status">
<span>{notice.text}</span>
<button type="button" onClick={() => setNotice(null)} aria-label={__('关闭', 'jio')}>
<i className="fa fas fa-times" aria-hidden="true" />
</button>
</div>
)}
{isPublishedEdit && (
<p className="jio-creator__hint">
{__('这篇改稿不会立即覆盖线上文章;审核通过后才会替换。', 'jio')}
</p>
)}
<main className="jio-creator__canvas">
<label className="jio-creator__title">
<span className="screen-reader-text">{__('文章标题', 'jio')}</span>
<textarea
value={draft.title}
onChange={(event) => patchDraft({ title: event.target.value })}
rows={1}
placeholder={limits.titlePlaceholder || __('请输入标题', 'jio')}
maxLength={(Number(limits.titleMax) || 30) * 4}
/>
</label>
<label className="jio-creator__excerpt">
<span>{__('摘要(可选)', 'jio')}</span>
<textarea
value={draft.excerpt}
onChange={(event) => patchDraft({ excerpt: event.target.value })}
rows={2}
placeholder={__('一句话概括这篇文章', 'jio')}
/>
</label>
<div className="jio-creator__editor">
<BlockEditorProvider
value={blocks}
onInput={onChangeBlocks}
onChange={onChangeBlocks}
settings={{
hasFixedToolbar: false,
supportsLayout: false,
mediaUpload: (window as any).jioCreatorMediaUpload,
}}
>
<BlockTools>
<BlockList />
</BlockTools>
<aside className="jio-creator__inspector" aria-label={__('区块设置', 'jio')}>
<BlockInspector />
</aside>
</BlockEditorProvider>
</div>
<footer className="jio-creator__footer">
<span>{__('字数', 'jio')}:{words}</span>
<span>
{__('约', 'jio')} {Math.max(1, Math.round(words / 400))} {__('分钟阅读', 'jio')}
</span>
</footer>
</main>
{drawerOpen && (
<PublishDrawer
draft={draft}
setDraft={patchDraft}
capabilities={capabilities}
limits={limits}
taxonomy={{
categories: (bootstrap.categories && bootstrap.categories.items) || [],
tags: !!bootstrap.tags?.enabled,
customDefs: bootstrap.customDefs || [],
}}
isPublishedEdit={isPublishedEdit}
onClose={() => setDrawerOpen(false)}
onSaveDraft={() => void save(false)}
onSubmit={(publishNow: boolean) => void submit(publishNow)}
privateStorage={!!bootstrap.privateStorage}
privateUploads={bootstrap.privateUploads || []}
captcha={!!bootstrap.captcha?.enabled}
saving={saveState.state === 'saving' || saveState.state === 'submitting'}
/>
)}
</div>
);
}
function mount() {
const target = document.getElementById('jio-creator-studio');
if (!target) {
return;
}
installMediaUploadBridge(bootstrap, nonce);
target.classList.add('is-ready');
createRoot(target).render(<Studio />);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mount);
} else {
mount();
}
export { Studio };