fix(creator-studio): 固定公众号式创作工具栏

- 顶部改为 fixed 创作工具栏:左侧返回/品牌/模式,中间文章标题与保存状态,
  右侧撤销/重做、保存草稿、预览、发布
- 新增工作台级撤销/重做快照栈,连续编辑合并记录;支持 Ctrl/Cmd+Z / Shift+Z,
  不覆盖 RichText 输入框自身的编辑历史
- 移动端收敛工具栏布局,保留撤销/重做、预览、发布
- 先保存草稿再请求审核/直接发布;请求使用服务端更新后的文章与版本 ID
- 固定工具栏以独立 chrome 层布局,正文避开顶部工具栏高度
- 补充固定工具栏、历史操作与键盘快捷键契约

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kate522andClaude Opus 5 committed 2026-11-06 17:36:15 +08:00
1 parent f5e9420266
commit 9e8913ea0a
9 files changed
+518 -50

No files matched your search

+126 -24
View File
@@ -27,6 +27,7 @@ import { InsertPanel, type InsertItem } from './panels/insert-panel';
import { buildInsertGroups } from './utils/insert-items';
import { SaveState, type SaveStateValue } from './components/save-state';
import { useAutosave } from './hooks/use-autosave';
import { useEditorHistory } from './hooks/use-editor-history';
import { useLocalRecovery } from './hooks/use-local-recovery';
import { normalizePayForRequest } from './utils/pay';
import './styles/studio.scss';
@@ -109,12 +110,55 @@ function initialDraft(): Draft {
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 blocksRef = useRef(blocks);
blocksRef.current = blocks;
const draftRef = useRef(draft);
draftRef.current = draft;
const [saveState, setSaveState] = useState<SaveStateValue>(() => ({
state: bootstrap.sourceId || bootstrap.versionId || bootstrap.content ? 'saved' : 'ready',
at: '',
}));
const [drawerOpen, setDrawerOpen] = useState(false);
const [insertOpen, setInsertOpen] = 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 restoreBlockSignatureRef = useRef('');
const restoreHistory = useCallback((value: { blocks: any[]; title: string; excerpt: string }) => {
dirtyRef.current = true;
blocksRef.current = value.blocks;
restoreBlockSignatureRef.current = serialize(value.blocks);
setBlocks(value.blocks);
const next = { ...draftRef.current, title: value.title, excerpt: value.excerpt };
draftRef.current = next;
setDraft(next);
}, []);
const history = useEditorHistory({
initial: { blocks, title: draft.title, excerpt: draft.excerpt },
onRestore: restoreHistory,
});
const historyRef = useRef(history);
historyRef.current = history;
const undo = useCallback(() => historyRef.current.undo(), []);
const redo = useCallback(() => historyRef.current.redo(), []);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'z') {
return;
}
const target = event.target as HTMLElement | null;
if (target?.closest('input, textarea, [contenteditable="true"]')) {
return;
}
event.preventDefault();
event.shiftKey ? redo() : undo();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [redo, undo]);
const capabilities = bootstrap.capabilities || {};
const limits: Limits = {
@@ -130,11 +174,32 @@ function Studio(): JSX.Element {
const patchDraft = useCallback((patch: Partial<Draft>) => {
dirtyRef.current = true;
setDraft((current) => ({ ...current, ...patch }));
const next = { ...draftRef.current, ...patch };
draftRef.current = next;
if ('title' in patch || 'excerpt' in patch) {
historyRef.current.record({
blocks: blocksRef.current,
title: next.title,
excerpt: next.excerpt,
});
}
setDraft(next);
}, []);
const onChangeBlocks = useCallback((next: any[]) => {
dirtyRef.current = true;
blocksRef.current = next;
const signature = serialize(next);
if (restoreBlockSignatureRef.current !== '' && signature === restoreBlockSignatureRef.current) {
restoreBlockSignatureRef.current = '';
setBlocks(next);
return;
}
historyRef.current.record({
blocks: next,
title: draftRef.current.title,
excerpt: draftRef.current.excerpt,
});
setBlocks(next);
}, []);
@@ -183,7 +248,9 @@ function Studio(): JSX.Element {
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 }));
const updated = { ...draftRef.current, postId: savedId, versionId, rev };
draftRef.current = updated;
setDraft(updated);
setSaveState({ state: 'saved', at: new Date().toLocaleTimeString() });
return true;
} catch (error: any) {
@@ -220,7 +287,13 @@ function Studio(): JSX.Element {
}
const response = await apiPost(
bootstrap.creator.submitUrl,
buildPayload({ publish_now: publishNow, captcha_proof: captchaProof }),
buildPayload({
post_id: draftRef.current.postId,
version_id: draftRef.current.versionId,
rev: draftRef.current.rev,
publish_now: publishNow,
captcha_proof: captchaProof,
}),
nonce
);
dirtyRef.current = false;
@@ -249,7 +322,7 @@ function Studio(): JSX.Element {
try {
const response = await apiPost(
bootstrap.creator.previewUrl,
{ post_id: draft.postId, version_id: draft.versionId },
{ post_id: draftRef.current.postId, version_id: draftRef.current.versionId },
nonce
);
if (response.url) {
@@ -315,12 +388,10 @@ function Studio(): JSX.Element {
if (raw) {
const parsed = JSON.parse(raw);
if (typeof parsed.title === 'string') {
dirtyRef.current = true;
setDraft((current) => ({ ...current, title: parsed.title }));
patchDraft({ title: parsed.title });
}
if (typeof parsed.content === 'string') {
dirtyRef.current = true;
setBlocks(parse(parsed.content));
onChangeBlocks(parse(parsed.content));
}
}
} catch (error) {
@@ -340,28 +411,59 @@ function Studio(): JSX.Element {
(item: InsertItem) => {
setInsertOpen(false);
dirtyRef.current = true;
setBlocks((current: any[]) => {
// 空文档时直接替换初始的空白段落,避免留下一个空行。
const next = [...current];
if (next.length === 1 && next[0].name === 'core/paragraph' && !next[0].attributes?.content) {
return [createBlock(item.name, item.attributes || {})];
}
return [...next, createBlock(item.name, item.attributes || {})];
const current = [...blocksRef.current];
let next: any[];
// 空文档时直接替换初始的空白段落,避免留下一个空行。
if (current.length === 1 && current[0].name === 'core/paragraph' && !current[0].attributes?.content) {
next = [createBlock(item.name, item.attributes || {})];
} else {
next = [...current, createBlock(item.name, item.attributes || {})];
}
blocksRef.current = next;
historyRef.current.record({
blocks: next,
title: draftRef.current.title,
excerpt: draftRef.current.excerpt,
});
setBlocks(next);
},
[]
);
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">
<div className="jio-creator" data-status={bootstrap.status || ''} data-blocks={selectedBlockCount}>
<header className="jio-creator__bar">
<div className="jio-creator__bar-left">
<a className="jio-creator__back" href={bootstrap.urls?.center || '/'} aria-label={__('返回作者中心', 'jio')}>
<i className="fa fas fa-angle-left" aria-hidden="true" />
</a>
<span className="jio-creator__bar-brand">Jio</span>
<span className="jio-creator__bar-divider" aria-hidden="true" />
<span className="jio-creator__bar-mode">
{isPublishedEdit ? __('修改文章', 'jio') : __('写文章', 'jio')}
</span>
</div>
<div className="jio-creator__bar-center">
<span className="jio-creator__doc-title">
{draft.title.trim() || __('无标题文章', 'jio')}
</span>
<SaveState value={saveState} />
<button type="button" className="jio-creator__ghost" onClick={() => void preview()}>
</div>
<div className="jio-creator__bar-actions">
<div className="jio-creator__history" role="group" aria-label={__('编辑历史', 'jio')}>
<button type="button" className="jio-creator__icon-btn" onClick={() => history.undo()} disabled={!history.canUndo} aria-label={__('撤销', 'jio')} title={__('撤销', 'jio')}>
<i className="fa fas fa-undo" aria-hidden="true" />
</button>
<button type="button" className="jio-creator__icon-btn" onClick={() => history.redo()} disabled={!history.canRedo} aria-label={__('重做', 'jio')} title={__('重做', 'jio')}>
<i className="fa fas fa-redo" aria-hidden="true" />
</button>
</div>
<button type="button" className="jio-creator__ghost jio-creator__save-draft" onClick={() => void save(false)}>
{__('保存草稿', 'jio')}
</button>
<button type="button" className="jio-creator__ghost jio-creator__preview" onClick={() => void preview()}>
{__('预览', 'jio')}
</button>
<button type="button" className="jio-creator__primary" onClick={() => setDrawerOpen(true)}>