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:
kate522 committed 2026-11-10 17:58:22 +08:00
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">
+73 -18
View File
@@ -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 {
+59 -11
View File
@@ -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)}
+2
View File
@@ -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;
+54 -5
View File
@@ -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
+10 -1
View File
@@ -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 = []) {