fix(submit): make the draft chain survive continuous writing, reloads and flaky networks
Autosave only ran after 4s of idle, and every keystroke reset that timer, so a writer who never stopped had zero saves (measured: 15s of typing = 0 requests). Saving also never rebound the URL, so a new draft could not be found again after a reload, the local backup was cleared on every autosave, and a failed save had no retry and stayed failed once the network came back. Client - autosave: keep the idle debounce but add a 30s max-wait window that is not reset by further keystrokes, and retry failures at 5s / 15s / 60s - bind `?edit=<id>` into the URL once the server returns a post id, so reloads, new tabs and history all reopen the same draft - only clear the local backup when the save settled and nothing changed since - key the local backup by draft id instead of the constant `jio-studio-0` - restore snapshots through hydrateBlocks so code blocks come back editable - never disable publish while an autosave is in flight, and mark autosave states separately (自动保存中… / 已自动保存 / 自动保存失败,将重试) - on 409, adopt the server rev and tell the author instead of failing silently - status bar reports the real save state instead of a bogus Markdown hint Server - draft saves no longer rewrite the lifecycle: pending / future / publish / private posts keep their status, so an autosave cannot pull a submitted post out of the review queue - drafts and autosaves stop consuming the publish flood quota, which is meant to cap actual publishing (measured: 4 drafts used to burn 4 slots) - autosave skips wp_save_post_revision for both plain drafts and submission versions; manual saves and submits still create a revision to roll back to - media-only drafts (image / table / code / hr) count as content - the response now carries the resulting status so the client can tell "just submitted" from "still under review" Tests cover the max-wait window, retry schedule, URL binding, backup clearing, status labelling and every server-side draft rule.
This commit is contained in:
1 parent
ec582ac9f9
commit
a5386f72c6
10 files changed
+330
-49
No files matched your search
@@ -1,5 +1,8 @@
|
||||
/**
|
||||
* 顶栏保存状态。状态文案即用户对“内容有没有保存”的唯一反馈。
|
||||
* 保存状态。状态文案即用户对「内容有没有保存」的唯一反馈。
|
||||
*
|
||||
* 自动保存与手动保存共用同一套取值,但 `autosave` 标记让界面能区别对待:
|
||||
* 自动保存不该抢走提交按钮的可用性,也不该把「保存中」渲染成用户主动操作。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
@@ -8,7 +11,12 @@ import { __ } from '@wordpress/i18n';
|
||||
|
||||
export type SaveStatus = 'ready' | 'saving' | 'saved' | 'error' | 'conflict' | 'submitting' | 'submitted';
|
||||
|
||||
export type SaveStateValue = { state: SaveStatus; at: string };
|
||||
export type SaveStateValue = {
|
||||
state: SaveStatus;
|
||||
at: string;
|
||||
/** 当前状态是否来自后台自动保存(不阻塞提交按钮)。 */
|
||||
autosave?: boolean;
|
||||
};
|
||||
|
||||
const LABELS: Record<SaveStatus, string> = {
|
||||
ready: __('尚未保存', 'jio'),
|
||||
@@ -20,11 +28,23 @@ const LABELS: Record<SaveStatus, string> = {
|
||||
submitted: __('已提交', 'jio'),
|
||||
};
|
||||
|
||||
/** 自动保存专用的说法:避免让作者以为是自己点了保存。 */
|
||||
const AUTOSAVE_LABELS: Partial<Record<SaveStatus, string>> = {
|
||||
saving: __('自动保存中…', 'jio'),
|
||||
saved: __('已自动保存', 'jio'),
|
||||
error: __('自动保存失败,将重试', 'jio'),
|
||||
};
|
||||
|
||||
export function saveStateLabel(value: SaveStateValue): string {
|
||||
const autosave = value.autosave ? AUTOSAVE_LABELS[value.state] : '';
|
||||
return autosave || LABELS[value.state] || LABELS.ready;
|
||||
}
|
||||
|
||||
export function SaveState({ value }: { value: SaveStateValue }) {
|
||||
return (
|
||||
<span className={`jio-creator__save is-${value.state}`} role="status" aria-live="polite">
|
||||
<i className="fa fas fa-circle" aria-hidden="true" />
|
||||
<span>{LABELS[value.state] || LABELS.ready}</span>
|
||||
<span>{saveStateLabel(value)}</span>
|
||||
{value.state === 'saved' && value.at ? <time>{value.at}</time> : null}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import { __, sprintf } from '@wordpress/i18n';
|
||||
|
||||
import { SaveState, type SaveStateValue } from './save-state';
|
||||
import { SaveState, saveStateLabel, type SaveStateValue } from './save-state';
|
||||
|
||||
type Props = {
|
||||
charCount: number;
|
||||
@@ -93,6 +93,8 @@ export function StatusBar({
|
||||
}: Props) {
|
||||
const savedAt = saveState.at ? relativeTime(saveState.at) : '';
|
||||
const status = STATUS_LABELS[saveState.state] || STATUS_LABELS.ready;
|
||||
// 时间和状态各显示一次会重复(「17:04:15 · 草稿」),有具体时间时以时间为准。
|
||||
const meta = savedAt || status;
|
||||
|
||||
return (
|
||||
<footer className="jio-creator__status" role="contentinfo">
|
||||
@@ -100,13 +102,13 @@ export function StatusBar({
|
||||
<span className="jio-creator__status-count">
|
||||
{__('字数', 'jio')}:{charCount}
|
||||
</span>
|
||||
<span className="jio-creator__status-mode">{__('Markdown 语法输入中', 'jio')}</span>
|
||||
<span className="jio-creator__status-mode">{saveStateLabel(saveState)}</span>
|
||||
</div>
|
||||
|
||||
<div className="jio-creator__status-meta">
|
||||
<SaveState value={saveState} />
|
||||
<span className="jio-creator__status-dot" aria-hidden="true">·</span>
|
||||
<span className="jio-creator__status-time">{savedAt || status}</span>
|
||||
<span className="jio-creator__status-time">{meta}</span>
|
||||
</div>
|
||||
|
||||
<div className="jio-creator__status-actions">
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
/**
|
||||
* 自动保存节奏控制。
|
||||
*
|
||||
* - 云端自动保存 4 秒防抖,保存进行中不叠加请求;
|
||||
* - 本地快照 1.5 秒防抖,由调用方写入 localStorage;
|
||||
* - 页面隐藏/失焦时立即冲刷一次,避免切走丢稿。
|
||||
* 三种触发并行,任一命中就保存:
|
||||
* - **空闲防抖**:停手 4 秒后保存(沿用原有体验);
|
||||
* - **最长等待**:首次改动后最多 30 秒必定落一次盘。只靠防抖的话,连续写作
|
||||
* 永远不会触发 —— 每次按键都会重置定时器,实测连写 15 秒一次都没保存;
|
||||
* - **失焦 / 页面隐藏**:立即冲刷。
|
||||
*
|
||||
* 保存失败后按 5s → 15s → 60s 退避重试,超过三次才停下来等下一次触发,
|
||||
* 避免断网时不停地打接口,也避免网络恢复后状态一直卡在「保存失败」。
|
||||
*
|
||||
* 回调放进 ref,保证 schedule 引用稳定:否则每次输入产生的重渲染都会重置
|
||||
* 定时器,自动保存永远不会触发。
|
||||
@@ -19,40 +24,75 @@ type Options = {
|
||||
onLocal: () => void;
|
||||
remoteDelay?: number;
|
||||
localDelay?: number;
|
||||
/** 首次改动后最长多久必须保存一次(ms)。 */
|
||||
maxWait?: number;
|
||||
};
|
||||
|
||||
export function useAutosave({ enabled, onSave, onLocal, remoteDelay = 4000, localDelay = 1500 }: Options) {
|
||||
/** 失败重试的退避间隔(ms),长度即最大重试次数。 */
|
||||
const RETRY_DELAYS = [5000, 15000, 60000];
|
||||
|
||||
export function useAutosave({
|
||||
enabled,
|
||||
onSave,
|
||||
onLocal,
|
||||
remoteDelay = 4000,
|
||||
localDelay = 1500,
|
||||
maxWait = 30000,
|
||||
}: Options) {
|
||||
const remoteTimer = useRef<number | null>(null);
|
||||
const localTimer = useRef<number | null>(null);
|
||||
const retryTimer = useRef<number | null>(null);
|
||||
const deadlineTimer = useRef<number | null>(null);
|
||||
const inFlight = useRef(false);
|
||||
const retryCount = useRef(0);
|
||||
|
||||
const saveRef = useRef(onSave);
|
||||
const localRef = useRef(onLocal);
|
||||
saveRef.current = onSave;
|
||||
localRef.current = onLocal;
|
||||
|
||||
const clear = useCallback(() => {
|
||||
if (remoteTimer.current) {
|
||||
window.clearTimeout(remoteTimer.current);
|
||||
remoteTimer.current = null;
|
||||
}
|
||||
if (localTimer.current) {
|
||||
window.clearTimeout(localTimer.current);
|
||||
localTimer.current = null;
|
||||
const clearTimer = useCallback((timer: { current: number | null }) => {
|
||||
if (timer.current) {
|
||||
window.clearTimeout(timer.current);
|
||||
timer.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const flush = useCallback(async () => {
|
||||
/** 只清「空闲防抖」相关的定时器;最长等待窗口要跨多次按键保留。 */
|
||||
const clear = useCallback(() => {
|
||||
clearTimer(remoteTimer);
|
||||
clearTimer(localTimer);
|
||||
}, [clearTimer]);
|
||||
|
||||
const flush = useCallback(async (): Promise<boolean> => {
|
||||
clearTimer(retryTimer);
|
||||
clearTimer(deadlineTimer);
|
||||
if (inFlight.current) {
|
||||
return;
|
||||
// 保存正在进行,内容会在下一次 schedule 里重新排队。
|
||||
return false;
|
||||
}
|
||||
inFlight.current = true;
|
||||
let ok = false;
|
||||
try {
|
||||
await saveRef.current();
|
||||
ok = await saveRef.current();
|
||||
} finally {
|
||||
inFlight.current = false;
|
||||
}
|
||||
}, []);
|
||||
if (ok) {
|
||||
retryCount.current = 0;
|
||||
return true;
|
||||
}
|
||||
// 失败后按退避重试;重试期间本地副本已经写过,不会丢稿。
|
||||
const delay = RETRY_DELAYS[retryCount.current];
|
||||
if (delay !== undefined && enabled) {
|
||||
retryCount.current += 1;
|
||||
retryTimer.current = window.setTimeout(() => {
|
||||
retryTimer.current = null;
|
||||
void flush();
|
||||
}, delay);
|
||||
}
|
||||
return false;
|
||||
}, [clearTimer, enabled]);
|
||||
|
||||
const schedule = useCallback(() => {
|
||||
clear();
|
||||
@@ -64,7 +104,22 @@ export function useAutosave({ enabled, onSave, onLocal, remoteDelay = 4000, loca
|
||||
remoteTimer.current = window.setTimeout(() => {
|
||||
void flush();
|
||||
}, remoteDelay);
|
||||
}, [clear, enabled, flush, localDelay, remoteDelay]);
|
||||
// 连续写作时防抖永远等不到空闲,这里补一个从「第一次未保存改动」起算的
|
||||
// 上限。它不随每次按键重置,否则等于没有。
|
||||
if (!deadlineTimer.current) {
|
||||
deadlineTimer.current = window.setTimeout(() => {
|
||||
deadlineTimer.current = null;
|
||||
void flush();
|
||||
}, maxWait);
|
||||
}
|
||||
}, [clear, enabled, flush, localDelay, maxWait, remoteDelay]);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
clear();
|
||||
clearTimer(retryTimer);
|
||||
clearTimer(deadlineTimer);
|
||||
retryCount.current = 0;
|
||||
}, [clear, clearTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
@@ -87,5 +142,5 @@ export function useAutosave({ enabled, onSave, onLocal, remoteDelay = 4000, loca
|
||||
};
|
||||
}, [clear, enabled, flush]);
|
||||
|
||||
return useMemo(() => ({ schedule, flush, cancel: clear }), [clear, flush, schedule]);
|
||||
return useMemo(() => ({ schedule, flush, cancel }), [cancel, flush, schedule]);
|
||||
}
|
||||
@@ -2,7 +2,11 @@
|
||||
* 本地草稿备份。
|
||||
*
|
||||
* 只保存标题与序列化后的正文,绝不保存 nonce、文件或付费凭据;恢复前由界面
|
||||
* 询问用户,成功保存服务端后才清除。
|
||||
* 询问用户,云端确认保存成功后才清除。
|
||||
*
|
||||
* 存储键跟着草稿 ID 走:新文章首次保存后会从 `jio-studio-0` 切到
|
||||
* `jio-studio-<id>`,两篇草稿不会互相覆盖。切换键时重新检查一次,新键上
|
||||
* 通常没有旧快照,因此不会重复弹恢复提示。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
@@ -22,13 +26,15 @@ const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
export function useLocalRecovery({ key, snapshot, onFound }: Options) {
|
||||
const latest = useRef(snapshot);
|
||||
latest.current = snapshot;
|
||||
const checked = useRef(false);
|
||||
const onFoundRef = useRef(onFound);
|
||||
onFoundRef.current = onFound;
|
||||
const checkedKey = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (checked.current) {
|
||||
if (checkedKey.current === key) {
|
||||
return;
|
||||
}
|
||||
checked.current = true;
|
||||
checkedKey.current = key;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) {
|
||||
@@ -40,13 +46,18 @@ export function useLocalRecovery({ key, snapshot, onFound }: Options) {
|
||||
window.localStorage.removeItem(key);
|
||||
return;
|
||||
}
|
||||
if (typeof parsed.content === 'string' && parsed.content !== snapshot.content) {
|
||||
onFound(String(parsed.at));
|
||||
// 云端内容比本地快照新时不打扰作者;只有本地确实带着云端没有的内容才提示。
|
||||
const differs = typeof parsed.content === 'string' && parsed.content !== snapshot.content;
|
||||
const localTitle = typeof parsed.title === 'string' && parsed.title !== snapshot.title;
|
||||
if (differs || localTitle) {
|
||||
onFoundRef.current(String(parsed.at));
|
||||
} else {
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
} catch (error) {
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
}, [key, onFound, snapshot.content]);
|
||||
}, [key, snapshot.content, snapshot.title]);
|
||||
|
||||
const markLocalSaved = useCallback(() => {
|
||||
try {
|
||||
|
||||
@@ -46,7 +46,8 @@ import './styles/studio.scss';
|
||||
|
||||
const bootstrap: Record<string, any> = window['jioCreatorStudio'] || {};
|
||||
const nonce: string = bootstrap.nonce || '';
|
||||
const STORAGE_KEY = `jio-studio-${bootstrap.sourceId || 0}`;
|
||||
/** 本地草稿的存储键:跟着草稿 ID 走,新文章保存后自动切到带 ID 的键。 */
|
||||
const storageKeyFor = (draft: { postId: number }): string => `jio-studio-${draft.postId || bootstrap.sourceId || 0}`;
|
||||
const EDITOR_ID = 'jio-creator-content';
|
||||
|
||||
function escapeAttribute(value: string): string {
|
||||
@@ -72,6 +73,27 @@ function plainText(content: string): string {
|
||||
return (container.textContent || '').replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 把刚创建 / 刚保存的草稿 ID 写进地址栏。
|
||||
*
|
||||
* 用 `history.replaceState`,不刷新页面也不留一堆历史记录;已经绑定过的地址
|
||||
* 不再改动,避免覆盖用户手动输入的其它查询参数。
|
||||
*/
|
||||
function bindDraftUrl(draft: { postId: number; versionId: number }): void {
|
||||
if (typeof window === 'undefined' || !window.history?.replaceState) {
|
||||
return;
|
||||
}
|
||||
const url = new URL(window.location.href);
|
||||
const current = Number(url.searchParams.get('edit') || 0);
|
||||
// 新文章经服务端保存后才拿到 ID;已发布文章的改稿绑定的是源文章 ID。
|
||||
const target = draft.postId > 0 ? draft.postId : 0;
|
||||
if (target <= 0 || current === target) {
|
||||
return;
|
||||
}
|
||||
url.searchParams.set('edit', String(target));
|
||||
window.history.replaceState(window.history.state, '', url.toString());
|
||||
}
|
||||
|
||||
function Studio(): JSX.Element {
|
||||
const [draft, setDraft] = useState<Draft>(() => initialDraft(bootstrap));
|
||||
const draftRef = useRef(draft);
|
||||
@@ -214,7 +236,7 @@ function Studio(): JSX.Element {
|
||||
if (!hasDocument) {
|
||||
return true;
|
||||
}
|
||||
setSaveState({ state: 'saving', at: '' });
|
||||
setSaveState({ state: 'saving', at: '', autosave });
|
||||
try {
|
||||
const response = await apiPost(bootstrap.creator.saveUrl, buildPayload({ autosave }), nonce);
|
||||
const updated = {
|
||||
@@ -226,14 +248,31 @@ function Studio(): JSX.Element {
|
||||
if ((window as any).jioSubmitData) {
|
||||
(window as any).jioSubmitData.postId = updated.postId;
|
||||
}
|
||||
// 首次保存后把文章 ID 写进地址栏:刷新、重开标签页、回退都要能回到
|
||||
// 同一篇草稿,否则新文章一刷新就分不到同一篇,草稿等于丢了。
|
||||
bindDraftUrl(updated);
|
||||
const settled = editRevisionRef.current === requestRevision;
|
||||
dirtyRef.current = !settled;
|
||||
draftRef.current = updated;
|
||||
setDraft(updated);
|
||||
setSaveState({ state: settled ? 'saved' : 'saving', at: settled ? new Date().toLocaleTimeString() : '' });
|
||||
setSaveState({ state: settled ? 'saved' : 'saving', at: settled ? new Date().toLocaleString() : '', autosave });
|
||||
return settled;
|
||||
} catch (error: any) {
|
||||
setSaveState({ state: error?.status === 409 ? 'conflict' : 'error', at: '' });
|
||||
if (error?.status === 409) {
|
||||
// 改稿版本已在别处更新:把本地 rev 对齐到服务端,下一次保存就能继续,
|
||||
// 同时明确告诉作者这件事,而不是让他以为稿子坏了。
|
||||
const currentRev = Number(error?.data?.data?.current_rev ?? 0);
|
||||
if (currentRev > 0) {
|
||||
draftRef.current = { ...draftRef.current, rev: currentRev };
|
||||
}
|
||||
setSaveState({ state: 'conflict', at: '', autosave });
|
||||
setNotice({
|
||||
tone: 'error',
|
||||
text: __('这篇改稿已在其他页面更新,已同步到最新版本,请确认内容后继续编辑。', 'jio'),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
setSaveState({ state: 'error', at: '', autosave });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
@@ -311,7 +350,7 @@ function Studio(): JSX.Element {
|
||||
}, [save]);
|
||||
|
||||
const { markLocalSaved, clear } = useLocalRecovery({
|
||||
key: STORAGE_KEY,
|
||||
key: storageKeyFor(draft),
|
||||
snapshot: { title: draft.title, content: draft.content, at: new Date().toISOString() },
|
||||
onFound: (savedAt: string) => setRecovery({ savedAt }),
|
||||
});
|
||||
@@ -320,7 +359,9 @@ function Studio(): JSX.Element {
|
||||
enabled: !!capabilities.isLoggedIn,
|
||||
onSave: async () => {
|
||||
const ok = await save(true);
|
||||
if (ok) {
|
||||
// 本地副本只有等这次保存真正落盘、且之后没有再被改动时才清:
|
||||
// 保存进行中又输入的内容还没上传,清掉就等于丢稿。
|
||||
if (ok && !dirtyRef.current) {
|
||||
clear();
|
||||
}
|
||||
return ok;
|
||||
@@ -440,7 +481,7 @@ function Studio(): JSX.Element {
|
||||
|
||||
const restore = useCallback(() => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
const raw = window.localStorage.getItem(storageKeyFor(draftRef.current));
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
const patch: Partial<Draft> = {};
|
||||
@@ -448,10 +489,12 @@ function Studio(): JSX.Element {
|
||||
if (typeof parsed.content === 'string') patch.content = parsed.content;
|
||||
patchDraft(patch);
|
||||
if (typeof parsed.content === 'string') {
|
||||
// 快照存的是保存格式(pre > code),编辑器要可编辑的形态。
|
||||
const content = hydrateBlocks(parsed.content);
|
||||
if (editorReadyRef.current) {
|
||||
editorRef.current?.setContent(parsed.content);
|
||||
editorRef.current?.setContent(content);
|
||||
} else {
|
||||
pendingRestoreContentRef.current = parsed.content;
|
||||
pendingRestoreContentRef.current = content;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -836,7 +879,12 @@ function Studio(): JSX.Element {
|
||||
charCount={charCount}
|
||||
saveState={saveState}
|
||||
isPublishedEdit={isPublishedEdit}
|
||||
saving={!editorReady || saveState.state === 'saving' || saveState.state === 'submitting'}
|
||||
saving={
|
||||
!editorReady
|
||||
|| saveState.state === 'submitting'
|
||||
// 自动保存不该禁用发布:它随时可能在进行,按钮闪断会让作者点空。
|
||||
|| (saveState.state === 'saving' && !saveState.autosave)
|
||||
}
|
||||
onSaveDraft={() => void save(false)}
|
||||
onPreview={() => void preview()}
|
||||
onPublish={() => setPublishOpen(true)}
|
||||
@@ -859,7 +907,7 @@ function Studio(): JSX.Element {
|
||||
canPublishNow={!!capabilities.canPublishNow}
|
||||
isPublishedEdit={isPublishedEdit}
|
||||
captcha={!!bootstrap.captcha?.enabled}
|
||||
saving={saveState.state === 'saving' || saveState.state === 'submitting'}
|
||||
saving={saveState.state === 'submitting' || (saveState.state === 'saving' && !saveState.autosave)}
|
||||
canSubmit={(draft.title || '').trim().length >= Number(limits.titleMin || 1)}
|
||||
onClose={() => setPublishOpen(false)}
|
||||
onSaveDraft={() => void save(false)}
|
||||
|
||||
@@ -235,6 +235,7 @@ final class CreatorRest {
|
||||
'content' => $payload['content'],
|
||||
'base_rev' => absint($request->get_param('rev')),
|
||||
'snapshot' => $this->snapshot_from_payload($payload),
|
||||
'autosave' => $is_auto,
|
||||
], false);
|
||||
if (is_wp_error($result)) {
|
||||
return $result;
|
||||
@@ -252,6 +253,7 @@ final class CreatorRest {
|
||||
'tags' => implode(',', array_map('strval', $payload['tags'])),
|
||||
'featured_data'=> $payload['featured'],
|
||||
'schedule' => $payload['schedule'],
|
||||
'autosave' => $is_auto,
|
||||
];
|
||||
foreach ($payload['custom'] as $key => $value) {
|
||||
$data['cf_' . sanitize_key((string) $key)] = $value;
|
||||
|
||||
@@ -17,7 +17,7 @@ trait FrontSubmitSave {
|
||||
*
|
||||
* @param array<string, mixed> $data Request payload.
|
||||
* @param string $action posts_save|posts_draft
|
||||
* @return array{success:bool,message:string,post_id?:int,url?:string,goto?:string,reload?:bool,time?:string,posts_id?:int}
|
||||
* @return array{success:bool,message:string,post_id?:int,url?:string,goto?:string,reload?:bool,time?:string,posts_id?:int,status?:string}
|
||||
*/
|
||||
public function persist(array $data, string $action = 'posts_save'): array {
|
||||
$user_id = get_current_user_id();
|
||||
@@ -26,6 +26,8 @@ trait FrontSubmitSave {
|
||||
$is_guest = $user_id <= 0;
|
||||
$is_edit = $posts_id > 0;
|
||||
$is_draft = $action === 'posts_draft';
|
||||
// 自动保存是后台动作:不建版本、不扣防刷配额、不改动审核状态。
|
||||
$is_auto = $is_draft && !empty($data['autosave']);
|
||||
|
||||
if (empty($settings['enabled'])) {
|
||||
return ['success' => false, 'message' => __('投稿功能已关闭', 'jio')];
|
||||
@@ -73,7 +75,9 @@ trait FrontSubmitSave {
|
||||
if (trim(wp_strip_all_tags($content)) === '') {
|
||||
return ['success' => false, 'message' => __('还未填写任何内容', 'jio')];
|
||||
}
|
||||
} elseif ($title === '' && trim(wp_strip_all_tags($content)) === '') {
|
||||
} elseif ($title === '' && !$this->draft_has_content($content)) {
|
||||
// 图片 / 表格 / 代码块这类媒体块即使没有文字也算草稿内容,
|
||||
// 只贴了一张图的草稿不能被判成「什么都没写」。
|
||||
return ['success' => false, 'message' => __('请先写点什么再保存', 'jio')];
|
||||
}
|
||||
|
||||
@@ -126,7 +130,9 @@ trait FrontSubmitSave {
|
||||
}
|
||||
|
||||
// [防刷机制]发布限制(后台「扩展&增强 → 网站安全」),管理员不受限。
|
||||
if (!$is_edit && !current_user_can('manage_options') && class_exists('\Jio\Extensions\SecurityHardening')) {
|
||||
// 草稿与自动保存不计数:配额防的是刷发布,把写作中的保存也算进去会让
|
||||
// 正常写一篇长文就把额度用光。只有正式投稿(posts_save)计一次。
|
||||
if (!$is_draft && !$is_edit && !current_user_can('manage_options') && class_exists('\Jio\Extensions\SecurityHardening')) {
|
||||
$security = \Jio\Settings\ExtensionRuntime::get_security_settings();
|
||||
$brush_subject = $is_guest
|
||||
? (string) (function_exists('jio_get_client_ip') ? jio_get_client_ip() : ($_SERVER['REMOTE_ADDR'] ?? ''))
|
||||
@@ -149,6 +155,13 @@ trait FrontSubmitSave {
|
||||
$status = 'future';
|
||||
}
|
||||
}
|
||||
} elseif ($is_edit) {
|
||||
// 草稿动作不能改写已有文章的生命周期:自动保存发生在投稿 / 审核期间时,
|
||||
// 把 pending 或 future 直接写成 draft 会让文章悄悄掉出审核队列与排期。
|
||||
$current_status = (string) get_post_status($posts_id);
|
||||
$status = in_array($current_status, ['pending', 'future', 'publish', 'private'], true)
|
||||
? $current_status
|
||||
: 'draft';
|
||||
}
|
||||
|
||||
// Paid fields are validated before anything is written, so an invalid
|
||||
@@ -193,7 +206,15 @@ trait FrontSubmitSave {
|
||||
|
||||
if ($is_edit) {
|
||||
$postarr['ID'] = $posts_id;
|
||||
if ($is_auto) {
|
||||
// 自动保存每隔几秒就可能落一次,逐次建 revision 会把版本表撑到几百条。
|
||||
// 版本只留给手动「保存草稿」与正式投稿,自动保存静默覆盖当前版本。
|
||||
remove_action('post_updated', 'wp_save_post_revision');
|
||||
}
|
||||
$in_id = wp_update_post($postarr, true);
|
||||
if ($is_auto) {
|
||||
add_action('post_updated', 'wp_save_post_revision');
|
||||
}
|
||||
} else {
|
||||
$postarr['post_author'] = $author_id;
|
||||
$in_id = wp_insert_post($postarr, true);
|
||||
@@ -238,6 +259,7 @@ trait FrontSubmitSave {
|
||||
'post_id' => (int) $in_id,
|
||||
'posts_id' => (int) $in_id,
|
||||
'url' => $url,
|
||||
'status' => $status,
|
||||
];
|
||||
|
||||
switch ($status) {
|
||||
@@ -254,8 +276,12 @@ trait FrontSubmitSave {
|
||||
}
|
||||
break;
|
||||
case 'pending':
|
||||
$send['message'] = __('内容已提交,正在等待审核', 'jio');
|
||||
if ($user_id > 0 && function_exists('jio_get_user_home_url')) {
|
||||
// 自动保存落到待审文章上时不是「刚提交」,文案与跳转都不能再走提交流程。
|
||||
$send['message'] = $is_draft
|
||||
? __('已保存,文章仍在审核队列中', 'jio')
|
||||
: __('内容已提交,正在等待审核', 'jio');
|
||||
$send['time'] = current_time('mysql');
|
||||
if (!$is_draft && $user_id > 0 && function_exists('jio_get_user_home_url')) {
|
||||
$send['reload'] = true;
|
||||
$send['goto'] = add_query_arg(
|
||||
['tab' => 'post', 'post_status' => 'pending'],
|
||||
@@ -263,6 +289,16 @@ trait FrontSubmitSave {
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'publish':
|
||||
case 'private':
|
||||
// 已发布文章的草稿保存不能触发「文章已发布」与整页跳转。
|
||||
$send['message'] = $is_draft ? __('已保存线上文章', 'jio') : __('文章已发布', 'jio');
|
||||
$send['time'] = current_time('mysql');
|
||||
if (!$is_draft) {
|
||||
$send['reload'] = true;
|
||||
$send['goto'] = $url;
|
||||
}
|
||||
break;
|
||||
case 'draft':
|
||||
$send['message'] = __('草稿已保存', 'jio');
|
||||
$send['time'] = current_time('mysql');
|
||||
@@ -344,6 +380,19 @@ trait FrontSubmitSave {
|
||||
return strlen($text);
|
||||
}
|
||||
|
||||
/**
|
||||
* 草稿是否已经有可保存的内容。
|
||||
*
|
||||
* 纯文字的草稿看文字,图片 / 表格 / 代码块 / 分割线这类块即使没有文字也算内容;
|
||||
* 与前端 `plainText` + 块级元素判断保持同一口径,避免两边对「空草稿」结论不同。
|
||||
*/
|
||||
private function draft_has_content(string $content): bool {
|
||||
if (trim(wp_strip_all_tags($content)) !== '') {
|
||||
return true;
|
||||
}
|
||||
return (bool) preg_match('/<(?:img|table|pre|hr|iframe|video|audio|figure)\b/i', $content);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $data
|
||||
* @param array<string,mixed> $settings
|
||||
|
||||
@@ -192,7 +192,7 @@ final class SubmissionVersion {
|
||||
/**
|
||||
* 创建或更新作者的工作版本。
|
||||
*
|
||||
* @param array{title?:string,content?:string,excerpt?:string,snapshot?:array<string,mixed>,base_rev?:int} $data
|
||||
* @param array{title?:string,content?:string,excerpt?:string,snapshot?:array<string,mixed>,base_rev?:int,autosave?:bool} $data
|
||||
* @return array{success:bool,message?:string,version_id?:int,rev?:int,conflict?:bool}|\WP_Error
|
||||
*/
|
||||
public function save_working(int $source_id, int $user_id, array $data, bool $submit = false) {
|
||||
@@ -265,7 +265,16 @@ final class SubmissionVersion {
|
||||
'post_excerpt' => $excerpt,
|
||||
'post_status' => $next_status === self::STATUS_PENDING ? 'pending' : 'draft',
|
||||
];
|
||||
// 自动保存不建 revision:几秒一次地写版本表会把改稿记录撑满,
|
||||
// 需要回滚的节点由手动保存与提交产生。
|
||||
$is_auto = !empty($data['autosave']);
|
||||
if ($is_auto) {
|
||||
remove_action('post_updated', 'wp_save_post_revision');
|
||||
}
|
||||
$updated = wp_update_post($update, true);
|
||||
if ($is_auto) {
|
||||
add_action('post_updated', 'wp_save_post_revision');
|
||||
}
|
||||
if (is_wp_error($updated)) {
|
||||
return new \WP_Error('jio_version_failed', $updated->get_error_message(), ['status' => 500]);
|
||||
}
|
||||
|
||||
@@ -270,7 +270,9 @@ test('fixed top toolbar has TinyMCE formatting, media, and extension actions', (
|
||||
assert.match(read('assets/creator-studio/src/components/status-bar.tsx'), /保存草稿/);
|
||||
assert.match(read('assets/creator-studio/src/components/status-bar.tsx'), /预览/);
|
||||
assert.match(read('assets/creator-studio/src/components/status-bar.tsx'), /字数/);
|
||||
assert.match(read('assets/creator-studio/src/components/status-bar.tsx'), /Markdown 语法输入中/);
|
||||
// 状态栏展示真实保存状态:编辑器是 TinyMCE 富文本,不支持 Markdown 语法。
|
||||
assert.doesNotMatch(read('assets/creator-studio/src/components/status-bar.tsx'), /Markdown/);
|
||||
assert.match(read('assets/creator-studio/src/components/status-bar.tsx'), /saveStateLabel\(saveState\)/);
|
||||
assert.match(read('assets/creator-studio/src/components/status-bar.tsx'), /<SaveState value=\{saveState\}/);
|
||||
assert.match(appSource, /draftRef\.current\.postId/);
|
||||
const barStyles = read('assets/creator-studio/src/styles/_block-tools.scss');
|
||||
@@ -698,3 +700,77 @@ test('creator snapshot keeps every download display field', () => {
|
||||
assert.match(rest, /'copyKey'\s*=>/);
|
||||
assert.match(rest, /'copyVal'\s*=>/);
|
||||
});
|
||||
|
||||
// 草稿链路:自动保存不能因为「不停手」而永远不触发,也不能在成功后把本地副本删掉。
|
||||
test('autosave keeps a max-wait window and retries after failure', () => {
|
||||
const hook = read('assets/creator-studio/src/hooks/use-autosave.ts');
|
||||
|
||||
// 只靠防抖的话持续输入永远不会落盘(每次按键都会重置定时器)。
|
||||
assert.match(hook, /maxWait\s*=\s*30000/);
|
||||
assert.match(hook, /deadlineTimer/);
|
||||
// 上限窗口不能随每次按键重置。
|
||||
assert.match(hook, /if \(!deadlineTimer\.current\) \{/);
|
||||
// 失败后按退避重试,而不是停在「保存失败」。
|
||||
assert.match(hook, /RETRY_DELAYS = \[5000, 15000, 60000\]/);
|
||||
assert.match(hook, /retryTimer\.current = window\.setTimeout/);
|
||||
});
|
||||
|
||||
test('draft save binds the id into the url and only clears the local backup once settled', () => {
|
||||
const app = read('assets/creator-studio/src/index.tsx');
|
||||
|
||||
// 首次保存后地址栏要能回到同一篇草稿,否则刷新就丢。
|
||||
assert.match(app, /function bindDraftUrl/);
|
||||
assert.match(app, /window\.history\.replaceState/);
|
||||
assert.match(app, /url\.searchParams\.set\('edit', String\(target\)\)/);
|
||||
assert.match(app, /bindDraftUrl\(updated\)/);
|
||||
// 本地副本只在「保存成功且之后没有再改动」时才清。
|
||||
assert.match(app, /if \(ok && !dirtyRef\.current\) \{/);
|
||||
// 存储键跟着草稿 ID 走,两篇草稿不会互相覆盖。
|
||||
assert.match(app, /const storageKeyFor = /);
|
||||
assert.match(app, /key: storageKeyFor\(draft\)/);
|
||||
// 恢复快照时要还原成编辑器里的可编辑形态。
|
||||
assert.match(app, /const content = hydrateBlocks\(parsed\.content\)/);
|
||||
// 自动保存不阻塞发布按钮。
|
||||
assert.match(app, /saveState\.state === 'saving' && !saveState\.autosave/);
|
||||
// 冲突要同步 rev 并告知作者。
|
||||
assert.match(app, /error\?\.status === 409/);
|
||||
assert.match(app, /current_rev/);
|
||||
});
|
||||
|
||||
test('autosave state is labelled differently from a manual save', () => {
|
||||
const saveState = read('assets/creator-studio/src/components/save-state.tsx');
|
||||
|
||||
assert.match(saveState, /autosave\?: boolean/);
|
||||
assert.match(saveState, /AUTOSAVE_LABELS/);
|
||||
assert.match(saveState, /自动保存中…/);
|
||||
assert.match(saveState, /已自动保存/);
|
||||
assert.match(saveState, /export function saveStateLabel/);
|
||||
});
|
||||
|
||||
// 服务端:草稿不能改写文章生命周期、不能吃发布配额、自动保存不建 revision。
|
||||
test('draft saves never demote a submitted post or consume the publish quota', () => {
|
||||
const save = read('src/Core/FrontSubmitSave.php');
|
||||
|
||||
// 自动保存标记从 REST 一路透传到 persist()。
|
||||
assert.match(save, /\$is_auto\s*=\s*\$is_draft && !empty\(\$data\['autosave'\]\)/);
|
||||
// pending / future / publish 的草稿保存保持原状态。
|
||||
assert.match(save, /\$status = in_array\(\$current_status, \['pending', 'future', 'publish', 'private'\], true\)/);
|
||||
// 草稿与自动保存不扣防刷配额,只有正式投稿计数。
|
||||
assert.match(save, /if \(!\$is_draft && !\$is_edit && !current_user_can\('manage_options'\)/);
|
||||
// 自动保存不建 revision。
|
||||
assert.match(save, /remove_action\('post_updated', 'wp_save_post_revision'\)/);
|
||||
// 只有媒体的草稿也算有内容。
|
||||
assert.match(save, /private function draft_has_content/);
|
||||
assert.match(save, /img\|table\|pre\|hr\|iframe\|video\|audio\|figure/);
|
||||
// 响应要带上真实状态,前端才能区分「刚提交」与「仍在审核中」。
|
||||
assert.match(save, /'status'\s*=>\s*\$status/);
|
||||
assert.match(save, /已保存,文章仍在审核队列中/);
|
||||
|
||||
// 改稿版本同样抑制自动保存的 revision。
|
||||
const version = read('src/Core/SubmissionVersion.php');
|
||||
assert.match(version, /remove_action\('post_updated', 'wp_save_post_revision'\)/);
|
||||
assert.match(version, /\$is_auto = !empty\(\$data\['autosave'\]\)/);
|
||||
|
||||
// REST 侧要把 autosave 传给 persist()。
|
||||
assert.match(read('src/Core/CreatorRest.php'), /'autosave'\s*=>\s*\$is_auto/);
|
||||
});
|
||||
@@ -193,7 +193,16 @@ namespace {
|
||||
function user_can($user_id, $capability, ...$args): bool { return false; }
|
||||
function add_filter($tag, $callback, $priority = 10, $accepted_args = 1): bool { return true; }
|
||||
function remove_filter($tag, $callback, $priority = 10): bool { return true; }
|
||||
function add_action($tag, $callback, $priority = 10, $accepted_args = 1): bool { return true; }
|
||||
function remove_action($tag, $callback, $priority = 10): bool { return true; }
|
||||
function do_action($tag, ...$args): void { $GLOBALS['front_pending_state']['actions'][] = $tag; }
|
||||
function get_post_status($post = null): string {
|
||||
if ($post instanceof WP_Post) {
|
||||
return (string) $post->post_status;
|
||||
}
|
||||
$found = $GLOBALS['front_pending_state']['posts'][(int) $post] ?? null;
|
||||
return $found instanceof WP_Post ? (string) $found->post_status : false;
|
||||
}
|
||||
function current_time($type): string { return '2026-10-30 12:00:00'; }
|
||||
function wp_set_post_categories($post_id, $categories): void {}
|
||||
function get_terms($args = []) {
|
||||
|
||||
Reference in new issue
Block a user