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.
128 lines
3.4 KiB
TypeScript
128 lines
3.4 KiB
TypeScript
/**
|
||
* 底部固定状态栏。
|
||
*
|
||
* 与公众号/知乎一致:左侧是字数与输入法状态,中间是草稿时间,右侧是预览与发布。
|
||
* 固定在视口底部,不随正文滚动。
|
||
*
|
||
* @package Jio
|
||
*/
|
||
|
||
import { __, sprintf } from '@wordpress/i18n';
|
||
|
||
import { SaveState, saveStateLabel, type SaveStateValue } from './save-state';
|
||
|
||
type Props = {
|
||
charCount: number;
|
||
saveState: SaveStateValue;
|
||
isPublishedEdit: boolean;
|
||
saving: boolean;
|
||
onSaveDraft: () => void;
|
||
onPreview: () => void;
|
||
onPublish: () => void;
|
||
};
|
||
|
||
const MINUTE = 60 * 1000;
|
||
const HOUR = 60 * MINUTE;
|
||
const DAY = 24 * HOUR;
|
||
const MONTH = 30 * DAY;
|
||
const YEAR = 365 * DAY;
|
||
|
||
/** 相对时间:刚刚 / X分钟前 / X小时前 … 与参考编辑器一致。 */
|
||
export function relativeTime(iso: string, now = Date.now()): string {
|
||
const at = Date.parse(iso);
|
||
if (!Number.isFinite(at)) {
|
||
return '';
|
||
}
|
||
const diff = Math.max(0, now - at);
|
||
if (diff < MINUTE) {
|
||
return __('刚刚', 'jio');
|
||
}
|
||
if (diff < HOUR) {
|
||
return sprintf(
|
||
/* translators: %d: minutes */
|
||
__('%d分钟前', 'jio'),
|
||
Math.floor(diff / MINUTE)
|
||
);
|
||
}
|
||
if (diff < DAY) {
|
||
return sprintf(
|
||
/* translators: %d: hours */
|
||
__('%d小时前', 'jio'),
|
||
Math.floor(diff / HOUR)
|
||
);
|
||
}
|
||
if (diff < MONTH) {
|
||
return sprintf(
|
||
/* translators: %d: days */
|
||
__('%d天前', 'jio'),
|
||
Math.floor(diff / DAY)
|
||
);
|
||
}
|
||
if (diff < YEAR) {
|
||
return sprintf(
|
||
/* translators: %d: months */
|
||
__('%d个月前', 'jio'),
|
||
Math.floor(diff / MONTH)
|
||
);
|
||
}
|
||
return sprintf(
|
||
/* translators: %d: years */
|
||
__('%d年前', 'jio'),
|
||
Math.floor(diff / YEAR)
|
||
);
|
||
}
|
||
|
||
const STATUS_LABELS: Record<SaveStateValue['state'], string> = {
|
||
ready: __('草稿', 'jio'),
|
||
saving: __('保存中', 'jio'),
|
||
saved: __('草稿', 'jio'),
|
||
error: __('保存失败', 'jio'),
|
||
conflict: __('冲突', 'jio'),
|
||
submitting: __('提交中', 'jio'),
|
||
submitted: __('已提交', 'jio'),
|
||
};
|
||
|
||
export function StatusBar({
|
||
charCount,
|
||
saveState,
|
||
isPublishedEdit,
|
||
saving,
|
||
onSaveDraft,
|
||
onPreview,
|
||
onPublish,
|
||
}: 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">
|
||
<div className="jio-creator__status-left">
|
||
<span className="jio-creator__status-count">
|
||
{__('字数', 'jio')}:{charCount}
|
||
</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">{meta}</span>
|
||
</div>
|
||
|
||
<div className="jio-creator__status-actions">
|
||
<button type="button" className="jio-creator__status-ghost" onClick={onSaveDraft} disabled={saving}>
|
||
{__('保存草稿', 'jio')}
|
||
</button>
|
||
<button type="button" className="jio-creator__status-ghost" onClick={onPreview} disabled={saving}>
|
||
{__('预览', 'jio')}
|
||
</button>
|
||
<button type="button" className="jio-creator__status-primary" onClick={onPublish} disabled={saving}>
|
||
{isPublishedEdit ? __('提交改稿', 'jio') : __('发布', 'jio')}
|
||
</button>
|
||
</div>
|
||
</footer>
|
||
);
|
||
}
|