downloadDynamic(true, filename)"
@download-picture="(filename: string) => downloadDynamic(false, filename)"
@download-native="downloadNative"
@download-md="downloadMD"
@import-md="importMD"
+ @sync-local-markdown="syncLocalMarkdown"
/>
diff --git a/src/views/editor/hook.ts b/src/views/editor/hook.ts
index bf0b3b83..1a128f7d 100644
--- a/src/views/editor/hook.ts
+++ b/src/views/editor/hook.ts
@@ -1,6 +1,8 @@
import { onActivated, onDeactivated, Ref, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useDebounceFn, useThrottleFn } from '@vueuse/core'
+import { ElMessageBox } from 'element-plus'
+import 'element-plus/es/components/message-box/style/css'
import { getLocalStorage } from '@/common/localstorage'
import { errorMessage, successMessage, warningMessage } from '@/common/message'
@@ -172,6 +174,121 @@ export function useImportMD(resumeType: string) {
}
}
+type LocalMarkdownFileHandle = {
+ name: string
+ getFile: () => Promise
+ createWritable: () => Promise<{
+ write: (content: string) => Promise
+ close: () => Promise
+ abort?: () => Promise
+ }>
+}
+
+type FilePickerWindow = Window & {
+ showOpenFilePicker?: (options: {
+ multiple: boolean
+ types: Array<{ description: string; accept: Record }>
+ }) => Promise
+}
+
+export function useLocalMarkdownSync(resumeType: Ref) {
+ const editorStore = useEditorStore()
+ const localMarkdownFileName = ref('')
+ const localMarkdownSyncStatus = ref<'idle' | 'syncing' | 'synced' | 'error'>('idle')
+ let fileHandle: LocalMarkdownFileHandle | null = null
+ let writeQueue = Promise.resolve()
+
+ async function writeFile(handle: LocalMarkdownFileHandle, content: string) {
+ const writable = await handle.createWritable()
+ try {
+ await writable.write(content)
+ await writable.close()
+ } catch (error) {
+ await writable.abort?.()
+ throw error
+ }
+ }
+
+ const writeChanges = useDebounceFn((content: string, currentHandle: LocalMarkdownFileHandle) => {
+ if (fileHandle !== currentHandle) return
+ localMarkdownSyncStatus.value = 'syncing'
+ writeQueue = writeQueue
+ .then(() => writeFile(currentHandle, content))
+ .then(() => {
+ if (fileHandle === currentHandle) localMarkdownSyncStatus.value = 'synced'
+ })
+ .catch(() => {
+ if (fileHandle !== currentHandle) return
+ fileHandle = null
+ localMarkdownFileName.value = ''
+ localMarkdownSyncStatus.value = 'error'
+ errorMessage('本地Markdown同步失败,请重新授权文件')
+ })
+ }, 500)
+
+ watch(
+ () => editorStore.MDContent,
+ content => fileHandle && writeChanges(content, fileHandle)
+ )
+
+ async function syncLocalMarkdown() {
+ if (editorStore.writable) return warningMessage('请先切换到Markdown模式')
+ const picker = (window as FilePickerWindow).showOpenFilePicker
+ if (!picker) return warningMessage('当前浏览器不支持本地文件同步,请继续使用导入/导出MD')
+
+ try {
+ const [handle] = await picker.call(window, {
+ multiple: false,
+ types: [{ description: 'Markdown', accept: { 'text/markdown': ['.md'] } }]
+ })
+ if (!handle) return
+ const fileContent = await (await handle.getFile()).text()
+ let loadFileContent = false
+
+ if (fileContent !== editorStore.MDContent) {
+ try {
+ await ElMessageBox.confirm(
+ `本地文件 ${handle.name} 与编辑器内容不同,请选择要保留的版本。`,
+ '内容不一致',
+ {
+ confirmButtonText: '用编辑器内容覆盖文件',
+ cancelButtonText: '载入本地文件',
+ distinguishCancelAndClose: true,
+ closeOnClickModal: false,
+ type: 'warning'
+ }
+ )
+ } catch (action) {
+ if (action !== 'cancel') return
+ loadFileContent = true
+ }
+ }
+
+ fileHandle = handle
+ localMarkdownFileName.value = handle.name
+ if (loadFileContent) {
+ editorStore.setMDContent(fileContent, resumeType.value)
+ } else if (fileContent !== editorStore.MDContent) {
+ await writeFile(handle, editorStore.MDContent)
+ }
+ localMarkdownSyncStatus.value = 'synced'
+ successMessage(`已同步 ${handle.name},后续修改将自动保存`)
+ } catch (error) {
+ if ((error as DOMException).name === 'AbortError') return
+ fileHandle = null
+ localMarkdownFileName.value = ''
+ localMarkdownSyncStatus.value = 'error'
+ errorMessage('无法同步本地Markdown文件')
+ }
+ }
+
+ return {
+ localMarkdownFileName,
+ localMarkdownSyncStatus,
+ syncLocalMarkdown
+ }
+}
+
export function useAvatar(resumeType: string) {
const matchAvatarSlot = /!\[个人头像\]\(.*\)/
function setAvatar(path: string) {