mirror of https://github.com/halo-dev/halo
pref: optimize the caching functionality of content editing (#4458)
#### What type of PR is this? /kind improvement #### What this PR does / why we need it: 本 PR 优化了文章、内容编辑时的本地缓存功能,之前内容对于本地缓存的依赖性非常强,因此导致了部分问题,如 #3820 ,本 PR 计划之后本地缓存将仅用于特殊情况下的内容恢复,因此尝试做了如下修改: 1. 当前正在编辑的文章,会在正常模式(切换路由、刷新页面、失去当前窗口焦点、停止编写一定时间均属于正常模式)下进行自动保存后删除本地缓存。 2. 若本地具有缓存,则会在进入编辑页面时,比对一次 content 的 version,若缓存 version 小于线上 version,则抛弃缓存,若一致,则使用缓存。 但经过测试,本 PR 无法解决如下问题: 1. 同时编辑一篇文章时,内容会被覆盖的问题。 2. 对比版本后会抛弃缓存,而实际上应当将本地缓存加入历史版本中。 #### How to test it? 1. 新建一篇文章,编写任意内容,返回文章页查看是否已经具有新的文章,且内容已被保存。 2. 修改一篇文章的内容,然后返回文章页,查看是否不是从缓存中加载。 3. 断网模式下修改一篇文章的内容,然后返回文章页,联网后,再次打开此文章,查看是否显示从缓存中加载。 4. 断网模式下修改一篇文章的内容,然后返回文章页。在另一个浏览器或页面修改此文章并保存后,回到断网页面联网后,查看是否更新为最新内容。 #### Which issue(s) this PR fixes: Fixes #3820 Fixes #4223 #### Does this PR introduce a user-facing change? ```release-note 减少内容编辑对本地缓存依赖,支持内容自动保存至服务端。 ```pull/4495/head v2.9.0-rc.1
parent
8ad59631b1
commit
a28c9a9781
|
@ -0,0 +1,40 @@
|
|||
import { useWindowFocus } from "@vueuse/core";
|
||||
import { watch, type Ref } from "vue";
|
||||
import { onBeforeRouteLeave } from "vue-router";
|
||||
import { useTimeoutFn } from "@vueuse/core";
|
||||
import type { ContentCache } from "./use-content-cache";
|
||||
|
||||
export function useAutoSaveContent(
|
||||
currentCache: Ref<ContentCache | undefined>,
|
||||
raw: Ref<string | undefined>,
|
||||
callback: () => void
|
||||
) {
|
||||
// TODO it may be necessary to know the latest version before saving, otherwise it will overwrite the latest version
|
||||
const handleAutoSave = () => {
|
||||
callback();
|
||||
};
|
||||
|
||||
onBeforeRouteLeave(() => {
|
||||
handleAutoSave();
|
||||
});
|
||||
|
||||
watch(useWindowFocus(), (newFocus) => {
|
||||
if (!newFocus && currentCache.value) {
|
||||
handleAutoSave();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("beforeunload", () => {
|
||||
handleAutoSave();
|
||||
});
|
||||
|
||||
const { start, isPending, stop } = useTimeoutFn(() => {
|
||||
handleAutoSave();
|
||||
}, 20 * 1000);
|
||||
watch(raw, () => {
|
||||
if (isPending.value) {
|
||||
stop();
|
||||
}
|
||||
start();
|
||||
});
|
||||
}
|
|
@ -1,10 +1,10 @@
|
|||
import { useLocalStorage } from "@vueuse/core";
|
||||
import { Toast } from "@halo-dev/components";
|
||||
import type { Ref } from "vue";
|
||||
|
||||
interface ContentCache {
|
||||
import { ref, watch, type Ref } from "vue";
|
||||
export interface ContentCache {
|
||||
name: string;
|
||||
content?: string;
|
||||
version: number;
|
||||
}
|
||||
import debounce from "lodash.debounce";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
@ -13,81 +13,95 @@ interface useContentCacheReturn {
|
|||
handleResetCache: () => void;
|
||||
handleSetContentCache: () => void;
|
||||
handleClearCache: (name?: string) => void;
|
||||
currentCache: Ref<ContentCache | undefined>;
|
||||
}
|
||||
|
||||
export function useContentCache(
|
||||
key: string,
|
||||
name: Ref<string | undefined>,
|
||||
raw: Ref<string | undefined>
|
||||
raw: Ref<string | undefined>,
|
||||
contentVersion: Ref<number>
|
||||
): useContentCacheReturn {
|
||||
const content_caches = useLocalStorage<ContentCache[]>(key, []);
|
||||
const currentCache = ref<ContentCache | undefined>(undefined);
|
||||
const { t } = useI18n();
|
||||
|
||||
const handleResetCache = () => {
|
||||
let cache: ContentCache | undefined;
|
||||
if (name.value) {
|
||||
const cache = content_caches.value.find(
|
||||
cache = content_caches.value.find(
|
||||
(c: ContentCache) => c.name === name.value
|
||||
);
|
||||
if (cache) {
|
||||
Toast.info(t("core.composables.content_cache.toast_recovered"));
|
||||
raw.value = cache.content;
|
||||
}
|
||||
} else {
|
||||
const cache = content_caches.value.find(
|
||||
cache = content_caches.value.find(
|
||||
(c: ContentCache) => c.name === "" && c.content
|
||||
);
|
||||
if (cache) {
|
||||
Toast.info(t("core.composables.content_cache.toast_recovered"));
|
||||
raw.value = cache.content;
|
||||
}
|
||||
}
|
||||
if (!cache) {
|
||||
return;
|
||||
}
|
||||
if (cache.version != contentVersion.value) {
|
||||
// TODO save the local offline cached content as a historical version
|
||||
handleClearCache(name.value);
|
||||
return;
|
||||
}
|
||||
Toast.info(t("core.composables.content_cache.toast_recovered"));
|
||||
raw.value = cache.content;
|
||||
handleClearCache(name.value);
|
||||
};
|
||||
|
||||
const handleSetContentCache = debounce(() => {
|
||||
let cache: ContentCache | undefined;
|
||||
if (name.value) {
|
||||
const cache = content_caches.value.find(
|
||||
cache = content_caches.value.find(
|
||||
(c: ContentCache) => c.name === name.value
|
||||
);
|
||||
if (cache) {
|
||||
cache.content = raw?.value;
|
||||
} else {
|
||||
content_caches.value.push({
|
||||
name: name.value || "",
|
||||
content: raw?.value,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const cache = content_caches.value.find(
|
||||
(c: ContentCache) => c.name === ""
|
||||
);
|
||||
if (cache) {
|
||||
cache.content = raw?.value;
|
||||
} else {
|
||||
content_caches.value.push({
|
||||
name: "",
|
||||
content: raw?.value,
|
||||
});
|
||||
}
|
||||
cache = content_caches.value.find((c: ContentCache) => c.name === "");
|
||||
}
|
||||
if (cache) {
|
||||
cache.content = raw?.value;
|
||||
} else {
|
||||
content_caches.value.push({
|
||||
name: name.value || "",
|
||||
content: raw?.value,
|
||||
version: contentVersion.value,
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
|
||||
const handleClearCache = (name?: string) => {
|
||||
let index: number;
|
||||
if (name) {
|
||||
const index = content_caches.value.findIndex(
|
||||
index = content_caches.value.findIndex(
|
||||
(c: ContentCache) => c.name === name
|
||||
);
|
||||
index > -1 && content_caches.value.splice(index, 1);
|
||||
} else {
|
||||
const index = content_caches.value.findIndex(
|
||||
index = content_caches.value.findIndex(
|
||||
(c: ContentCache) => c.name === ""
|
||||
);
|
||||
index > -1 && content_caches.value.splice(index, 1);
|
||||
}
|
||||
index > -1 && content_caches.value.splice(index, 1);
|
||||
};
|
||||
|
||||
watch(content_caches, (newCaches) => {
|
||||
if (newCaches.length > 0) {
|
||||
if (name.value) {
|
||||
currentCache.value = newCaches.find(
|
||||
(c: ContentCache) => c.name === name.value
|
||||
);
|
||||
} else {
|
||||
currentCache.value = newCaches.find((c: ContentCache) => c.name === "");
|
||||
}
|
||||
} else {
|
||||
currentCache.value = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
handleClearCache,
|
||||
handleResetCache,
|
||||
handleSetContentCache,
|
||||
currentCache,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
import { apiClient } from "@/utils/api-client";
|
||||
import { watch, type Ref, ref, nextTick } from "vue";
|
||||
|
||||
interface SnapshotContent {
|
||||
version: Ref<number>;
|
||||
handleFetchSnapshot: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useContentSnapshot(
|
||||
snapshotName: Ref<string | undefined>
|
||||
): SnapshotContent {
|
||||
const version = ref(0);
|
||||
watch(snapshotName, () => {
|
||||
nextTick(() => {
|
||||
handleFetchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
const handleFetchSnapshot = async () => {
|
||||
if (!snapshotName.value) {
|
||||
return;
|
||||
}
|
||||
const { data } =
|
||||
await apiClient.extension.snapshot.getcontentHaloRunV1alpha1Snapshot({
|
||||
name: snapshotName.value,
|
||||
});
|
||||
version.value = data.metadata.version || 0;
|
||||
};
|
||||
|
||||
return {
|
||||
version,
|
||||
handleFetchSnapshot,
|
||||
};
|
||||
}
|
|
@ -36,6 +36,8 @@ import { useI18n } from "vue-i18n";
|
|||
import UrlPreviewModal from "@/components/preview/UrlPreviewModal.vue";
|
||||
import { contentAnnotations } from "@/constants/annotations";
|
||||
import { usePageUpdateMutate } from "./composables/use-page-update-mutate";
|
||||
import { useAutoSaveContent } from "@/composables/use-auto-save-content";
|
||||
import { useContentSnapshot } from "@/composables/use-content-snapshot";
|
||||
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
@ -143,22 +145,22 @@ const handleSave = async (options?: { mute?: boolean }) => {
|
|||
|
||||
formState.value.page = data;
|
||||
} else {
|
||||
// Clear new page content cache
|
||||
handleClearCache();
|
||||
const { data } = await apiClient.singlePage.draftSinglePage({
|
||||
singlePageRequest: formState.value,
|
||||
});
|
||||
formState.value.page = data;
|
||||
routeQueryName.value = data.metadata.name;
|
||||
|
||||
// Clear new page content cache
|
||||
handleClearCache();
|
||||
}
|
||||
|
||||
if (!options?.mute) {
|
||||
Toast.success(t("core.common.toast.save_success"));
|
||||
}
|
||||
|
||||
handleClearCache(routeQueryName.value as string);
|
||||
handleClearCache(formState.value.page.metadata.name as string);
|
||||
await handleFetchContent();
|
||||
await handleFetchSnapshot();
|
||||
} catch (error) {
|
||||
console.error("Failed to save single page", error);
|
||||
Toast.error(t("core.common.toast.save_failed_and_retry"));
|
||||
|
@ -330,13 +332,28 @@ onMounted(async () => {
|
|||
handleResetCache();
|
||||
});
|
||||
|
||||
const headSnapshot = computed(() => {
|
||||
return formState.value.page.spec.headSnapshot;
|
||||
});
|
||||
|
||||
const { version, handleFetchSnapshot } = useContentSnapshot(headSnapshot);
|
||||
|
||||
// SinglePage content cache
|
||||
const { handleSetContentCache, handleResetCache, handleClearCache } =
|
||||
useContentCache(
|
||||
"singlePage-content-cache",
|
||||
routeQueryName,
|
||||
toRef(formState.value.content, "raw")
|
||||
);
|
||||
const {
|
||||
currentCache,
|
||||
handleSetContentCache,
|
||||
handleResetCache,
|
||||
handleClearCache,
|
||||
} = useContentCache(
|
||||
"singlePage-content-cache",
|
||||
routeQueryName,
|
||||
toRef(formState.value.content, "raw"),
|
||||
version
|
||||
);
|
||||
|
||||
useAutoSaveContent(currentCache, toRef(formState.value.content, "raw"), () => {
|
||||
handleSave({ mute: true });
|
||||
});
|
||||
|
||||
// SinglePage preview
|
||||
const previewModal = ref(false);
|
||||
|
|
|
@ -36,6 +36,8 @@ import { useI18n } from "vue-i18n";
|
|||
import UrlPreviewModal from "@/components/preview/UrlPreviewModal.vue";
|
||||
import { usePostUpdateMutate } from "./composables/use-post-update-mutate";
|
||||
import { contentAnnotations } from "@/constants/annotations";
|
||||
import { useAutoSaveContent } from "@/composables/use-auto-save-content";
|
||||
import { useContentSnapshot } from "@/composables/use-content-snapshot";
|
||||
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
@ -146,21 +148,21 @@ const handleSave = async (options?: { mute?: boolean }) => {
|
|||
|
||||
formState.value.post = data;
|
||||
} else {
|
||||
// Clear new post content cache
|
||||
handleClearCache();
|
||||
const { data } = await apiClient.post.draftPost({
|
||||
postRequest: formState.value,
|
||||
});
|
||||
formState.value.post = data;
|
||||
name.value = data.metadata.name;
|
||||
|
||||
// Clear new post content cache
|
||||
handleClearCache();
|
||||
}
|
||||
|
||||
if (!options?.mute) {
|
||||
Toast.success(t("core.common.toast.save_success"));
|
||||
}
|
||||
handleClearCache(name.value as string);
|
||||
handleClearCache(formState.value.post.metadata.name as string);
|
||||
await handleFetchContent();
|
||||
await handleFetchSnapshot();
|
||||
} catch (e) {
|
||||
console.error("Failed to save post", e);
|
||||
Toast.error(t("core.common.toast.save_failed_and_retry"));
|
||||
|
@ -346,13 +348,28 @@ onMounted(async () => {
|
|||
handleResetCache();
|
||||
});
|
||||
|
||||
const headSnapshot = computed(() => {
|
||||
return formState.value.post.spec.headSnapshot;
|
||||
});
|
||||
|
||||
const { version, handleFetchSnapshot } = useContentSnapshot(headSnapshot);
|
||||
|
||||
// Post content cache
|
||||
const { handleSetContentCache, handleResetCache, handleClearCache } =
|
||||
useContentCache(
|
||||
"post-content-cache",
|
||||
name,
|
||||
toRef(formState.value.content, "raw")
|
||||
);
|
||||
const {
|
||||
currentCache,
|
||||
handleSetContentCache,
|
||||
handleResetCache,
|
||||
handleClearCache,
|
||||
} = useContentCache(
|
||||
"post-content-cache",
|
||||
name,
|
||||
toRef(formState.value.content, "raw"),
|
||||
version
|
||||
);
|
||||
|
||||
useAutoSaveContent(currentCache, toRef(formState.value.content, "raw"), () => {
|
||||
handleSave({ mute: true });
|
||||
});
|
||||
|
||||
// Post preview
|
||||
const previewModal = ref(false);
|
||||
|
|
Loading…
Reference in New Issue