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:
1 parent
7a8279f8e7
commit
fac4a714e0
45 files changed
+28495
-56
No files matched your search
@@ -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 };
|
||||
Reference in new issue
Block a user