最新国产好看的视频,伊人天堂AV在线,国产Aaaaaa视频,蜜臀视频在线观看一区,人妻av色图,密臀久久久精品影片,青青视频免费观看毛片,久草在线观看视,国产三级精品色情在线

基于Vue3實現(xiàn)一個簡歷生成工具

 更新時間:2025年07月06日 11:14:19   作者:khalil  
本文介紹如何從零開始構建一個基于 Vue3 + Markdown 的在線簡歷生成工具,支持實時編輯預覽、模板切換、自定義樣式配置以及導出為 PDF,感興趣的小伙伴可以參考閱讀本文

項目介紹

之前在做個人簡歷的時候,發(fā)現(xiàn)目前一些工具網(wǎng)站上使用起來不太方便,于是打算動手簡單實現(xiàn)一個在線的簡歷工具網(wǎng)站,主要支持以下功能:

  • 支持以markdown格式輸入,渲染成簡歷內(nèi)容
  • 多模板切換
  • 樣式調(diào)整
  • 上傳導出功能

體驗地址: hj-hao.github.io/md2cv/

技術選型

項目整體技術棧如下:

  • 框架: Vue 3
  • 構建:Vite
  • 項目UI: PrimeVue + TailwindCSS
  • 狀態(tài)管理: Pinia
  • Markdown處理:Markdown-it + gray-matter

功能實現(xiàn)

接下來簡單介紹下具體的功能實現(xiàn)

Markdown解析&渲染

首先要處理的就是對輸入Markdown的解析。由于需要將內(nèi)容渲染在內(nèi)置的模板簡歷中,這里就只需要MD -> HTML的能力,因此選用了Markdown-it進行實現(xiàn)。拿到html字符串后在vue中直接渲染即可。

<template>
    <div v-html="result"></div>
</template>

<script setup>
import { ref, computed } from 'vue'
import markdownit from 'markdown-it'
const md = markdownit({
    html: true,
})
const input = ref('')
const result = computed(() => md.render(input))
</script>

上面這段簡易的代碼就能支持將用戶輸入文本,轉換成html了。
在這個基礎上如果希望增加一些前置元數(shù)據(jù)的配置,類似在Vitepress中我們可以在MD前用YAML語法編寫一些配置??梢允褂胓ray-matter這個庫,能通過分割符將識別解析文本字符串中的YAML格式信息。

此處用官方的例子直接展示用法, 可以看到其將輸入中的YAML部分轉換為對象返回,而其余部分則保持輸入直接輸出。

console.log(matter('---\ntitle: Front Matter\n---\nThis is content.'));

// 輸出
{
  content: '\nThis is content.',
  data: {
    title: 'Front Matter'
  }
}

在這個項目中,就通過這個庫將簡歷個人信息(YAML)和簡歷正本部分(MD)整合在同一個輸入框中編輯了,具體的實現(xiàn)如下:

<template>
    <div v-html="result.content"></div>
</template>

<script setup>
import { ref, computed } from 'vue'
import matter from 'gray-matter'
import markdownit from 'markdown-it'
const md = markdownit({
    html: true,
})
const input = ref('')
const result = computed(() => {
    // 解析yaml
    const { data, content } = matter(input.value)
    return {
        data,
        content: md.render(content),
    }
})
</script>

模板功能

模板實現(xiàn)

之后是將上面解析后的內(nèi)容渲染到簡歷模板上,以及可以在不同模板間直接切換實時渲染出對應的效果。

實現(xiàn)上每個模板都是一個單獨的組件,UI由兩部分組件一個是簡歷模板個人信息以及正文部分,除組件部分外還有模板相關的配置項跟隨組件需要導出,因此這里選用JSX/TSX實現(xiàn)簡歷模板組件。構造一個基礎的組件封裝公共部分邏輯, 模板間的UI差異通過slot實現(xiàn)

import '@/style/templates/baseTemplate.css'
import { defineComponent } from 'vue'
import { storeToRefs } from 'pinia'
import { useStyleConfigStore } from '@/store/styleConfig'

// base component to reuse in other cv templates
export default defineComponent({
    name: 'BaseTemplate',
    props: {
        content: {
            type: String,
            default: '',
        },
        page: {
            type: Number,
            default: 1,
        },
        className: {
            type: String,
            default: '',
        },
    },
    setup(props, { slots }) {
        // 可支持配置的樣式,在基礎模板中通過注入css變量讓子元素訪問
        const { pagePadding, fontSize } = storeToRefs(useStyleConfigStore())
        return () => (
            <div
                class="page flex flex-col"
                style={{
                    '--page-padding': pagePadding.value + 'px',
                    '--page-font-size': fontSize.value + 'px',
                }}
            >
                {/** 渲染不同模板對應的信息模塊 */}
                {props.page === 1 && (slots.header ? slots.header() : '')}
                {/** 簡歷正文部分 */}
                <div
                    class={`${props.className} template-content`}
                    innerHTML={props.content}
                ></div>
            </div>
        )
    },
})

其余模板組件在上面組件的基礎上繼續(xù)擴展,下面是其中一個組件示例

import { defineComponent, computed, type PropType } from 'vue'
import BaseTemplate from '../BaseTemplate'
import ResumeAvatar from '@/components/ResumeAvatar.vue'
import { A4_PAGE_SIZE } from '@/constants'
import '@/style/templates/simpleTemplate.css'

const defaultConfig = {
    name: 'Your Name',
    blog: 'https://yourblog.com',
    phone: '123-456-7890',
    location: 'Your Location',
}

// 模板名(組件名稱)
export const name = 'SimpleTemplate'
// 模板樣式 類名
const className = 'simple-template-content-box'

// 模板每頁的最大高度,用于分頁計算
export const getCurrentPageHeight = (page: number) => {
    if (page === 1) {
        return A4_PAGE_SIZE - 130
    }
    return A4_PAGE_SIZE
}

export default defineComponent({
    name: 'SimpleTemplate',
    components: {
        BaseTemplate,
        ResumeAvatar,
    },
    props: {
        config: {
            type: Object as PropType<{ [key: string]: any }>,
            default: () => ({ ...defaultConfig }),
        },
        content: {
            type: String,
            default: '',
        },
        page: {
            type: Number,
            default: 1,
        },
    },
    setup(props) {
        const config = computed(() => {
            return { ...defaultConfig, ...props.config }
        })
        const slots = {
            header: () => (
                <div class="flex relative gap-2.5 mb-2.5 items-center">
                    <div class="flex flex-col flex-1 gap-2">
                        <div class="text-3xl font-bold">
                            {config.value.name}
                        </div>
                        <div class="flex items-center text-sm">
                            <div class="text-gray-500 not-last:after:content-['|'] after:m-1.5">
                                <span>Blog:</span>
                                <a
                                    href="javascript:void(0)" rel="external nofollow" 
                                    target="_blank"
                                    rel="noopener noreferrer"
                                >
                                    {config.value.blog}
                                </a>
                            </div>
                            <div class="text-gray-500 not-last:after:content-['|'] after:m-1.5">
                                <span>Phone:</span>
                                {config.value.phone}
                            </div>
                            <div class="text-gray-500 not-last:after:content-['|'] after:m-1.5">
                                <span>Location:</span>
                                {config.value.location}
                            </div>
                        </div>
                    </div>
                    <ResumeAvatar />
                </div>
            ),
        }
        return () => (
            <BaseTemplate
                v-slots={slots}
                page={props.page}
                content={props.content}
                className={className}
            />
        )
    },
})
/** @/style/templates/simpleTemplate.css */
.simple-template-content-box {
    h1 {
        font-size: calc(var(--page-font-size) * 1.4);
        font-weight: bold;
        border-bottom: 2px solid var(--color-zinc-800);
        margin-bottom: 0.5em;
    }


    h2 {
        font-weight: bold;
        margin-bottom: 0.5em;
    }
}

模板加載

完成不同模板組件后,項目需要能自動將這些組件加載到項目中,并將對應的組件信息注入全局。通過Vite提供的import.meta.glob可以在文件系統(tǒng)匹配導入對應的文件,實現(xiàn)一個Vue插件,就能在Vue掛載前加載對應目錄下的組件,并通過provide注入。完整代碼如下

// plugins/templateLoader.ts
import type { App, Component } from 'vue'

export type TemplateMeta = {
    name: string
    component: Component
    getCurrentPageHeight: (page: number) => number
}

export const TemplateProvideKey = 'Templates'

const templateLoaderPlugin = {
    install(app: App) {
        const componentModules = import.meta.glob(
            '../components/templates/**/index.tsx',
            { eager: true }
        )
        const templates: Record<string, TemplateMeta> = {}

        const getTemplateName = (path: string) => {
            const match = path.match(/templates\/([^/]+)\//)
            return match ? match?.[1] : null
        }

        // path => component Name
        for (const path in componentModules) {
            // eg: ../components/templates/simple/index.vue => simple
            const name = getTemplateName(path)
            if (name) {
                const config = (componentModules as any)[path]
                templates[name] = {
                    component: config.default,
                    name: config.name || name,
                    getCurrentPageHeight: config.getCurrentPageHeight,
                } as TemplateMeta
            }
        }

        app.provide(TemplateProvideKey, templates)
    },
}

export default templateLoaderPlugin

預覽分頁

有了對應的組件和內(nèi)容后,就能在頁面中將簡歷渲染出來了。但目前還存在一個問題,如果內(nèi)容超長了需要分頁不能直接體現(xiàn)用戶,僅能在導出預覽時候進行分頁。需要補充上分頁的能力,將渲染的效果和導出預覽的效果對齊。

整體思路是先將組件渲染在不可見的區(qū)域,之后讀取對應的dom節(jié)點,計算每個子元素的高度和,超過后當前內(nèi)容最大高度后,新建一頁。最后返回每頁對應的html字符串,循環(huán)模板組件進行渲染。具體代碼如下:

import { computed, onMounted, ref, watch, nextTick, type Ref } from 'vue'
import { useTemplateStore } from '@/store/template'
import { useStyleConfigStore } from '@/store/styleConfig'
import { useMarkdownStore } from '@/store/markdown'
import { storeToRefs } from 'pinia'

export const useSlicePage = (target: Ref<HTMLElement | null>) => {
    const { currentConfig, currentTemplate } = storeToRefs(useTemplateStore())
    const { pagePadding, fontSize } = storeToRefs(useStyleConfigStore())

    const { result } = storeToRefs(useMarkdownStore())
    const pages = ref<Element[]>()
    
    // 每頁渲染的html字符串
    const renderList = computed(() => {
        return pages.value?.map((el) => el.innerHTML)
    })

    const pageSize = computed(() => pages.value?.length || 1)
    
    // 獲取當前模板的內(nèi)容高度,減去邊距
    const getCurrentPageHeight = (page: number) => {
        return (
            currentConfig.value.getCurrentPageHeight(page) -
            pagePadding.value * 2
        )
    }

    const createPage = (children: HTMLElement[] = []) => {
        const page = document.createElement('div')
        children.forEach((item) => {
            page.appendChild(item)
        })
        return page
    }

    // getBoundingClientRect 只返回元素的寬度 需要getComputedStyle獲取邊距
    // 由于元素上下邊距合并的特性,此處僅考慮下邊距,上邊距通過樣式限制為0
    const getElementHeightWithBottomMargin = (el: HTMLElement): number => {
        const style = getComputedStyle(el)
        const marginBottom = parseFloat(style.marginBottom || '0')
        const height = el.getBoundingClientRect().height
        return height + marginBottom
    }

    const sliceElement = (element: Element): Element[] => {
        const children = Array.from(element.children)
        let currentPage = 1
        let currentPageElement = createPage()
        
        // 當前頁面可渲染的高度
        let PageSize = getCurrentPageHeight(currentPage)
        // 剩余可渲染高度
        let resetPageHeight = PageSize 
        // 頁面dom數(shù)組
        const pages = [currentPageElement]
 

        while (children.length > 0) {
            const el = children.shift() as HTMLElement

            const height = getElementHeightWithBottomMargin(el)

            // 大于整頁高度,如果包含子節(jié)點就直接分隔
            // 無子節(jié)點直接放入當頁,然后創(chuàng)建新頁面
            if (height > PageSize) {
                const subChildren = Array.from(el.children)
                if (subChildren.length > 0) {
                    children.unshift(...subChildren)
                } else {
                    pages.push(
                        createPage([el.cloneNode(true)] as HTMLElement[])
                    ) // Create a new page for the oversized element
                    currentPage += 1
                    PageSize = getCurrentPageHeight(currentPage)
                    resetPageHeight = PageSize
                    currentPageElement = createPage()
                    pages.push(currentPageElement) // Push the new page to the pages array
                }

                continue // Skip to the next element
            }
            
            // 針對高度大于300的元素且包含子元素的節(jié)點進行分隔
            // 無子元素或高度小于300直接創(chuàng)建新頁面放入
            if (height > resetPageHeight && height > 300) {
                const subChildren = Array.from(el.children)
                if (subChildren.length > 0) {
                    children.unshift(...subChildren)
                } else {
                    currentPageElement = createPage([
                        el.cloneNode(true),
                    ] as HTMLElement[]) // Create a new page
                    currentPage += 1
                    PageSize = getCurrentPageHeight(currentPage)
                    resetPageHeight = PageSize - height
                    pages.push(currentPageElement) // Push the new page to the pages array
                }
            } else if (height > resetPageHeight && height <= 300) {
                currentPageElement = createPage([
                    el.cloneNode(true),
                ] as HTMLElement[]) // Create a new page
                currentPage += 1
                PageSize = getCurrentPageHeight(currentPage)
                resetPageHeight = PageSize - height
                pages.push(currentPageElement) // Push the new page to the pages array
            } else {
                currentPageElement.appendChild(
                    el.cloneNode(true) as HTMLElement
                )
                resetPageHeight -= height
            }
        }

        return pages
    }

    const getSlicePage = () => {
        const targetElement = target.value?.querySelector(`.template-content`)
        const newPages = sliceElement(targetElement!)
        pages.value = newPages
    }

    watch(
        () => [
            result.value,
            currentTemplate.value,
            pagePadding.value,
            fontSize.value,
        ],
        () => {
            nextTick(() => {
                getSlicePage()
            })
        }
    )

    onMounted(() => {
        nextTick(() => {
            getSlicePage()
        })
    })

    return {
        getSlicePage,
        pages,
        pageSize,
        renderList,
    }
}
<!-- 實際展示容器 -->
<div
    class="bg-white dark:bg-surface-800 rounded-lg shadow-md overflow-auto"
    ref="previewRef"
>
    <component
        v-for="(content, index) in renderList"
        :key="index"
        :is="currentComponent"
        :config="result.data"
        :content="content"
        :page="index + 1"
    />
</div>

<!-- 隱藏的容器 -->
<div ref="renderRef" class="render-area">
    <component
        :is="currentComponent"
        :config="result.data"
        :content="result.content"
    />
</div>
<script setup>
// 省略其他代碼
const renderRef = ref<HTMLElement | null>(null)
const previewRef = ref<HTMLElement | null>(null)

const mdStore = useMarkdownStore()
const templateStore = useTemplateStore()

const { result, input } = storeToRefs(mdStore)
const { currentComponent } = storeToRefs(templateStore)
const { renderList } = useSlicePage(renderRef)
</script>

上面的代碼目前還存在一些邊界場景分頁問題比如:

  • 一個僅包含文本的P或者DIV節(jié)點,目前這個節(jié)點不會被分割,而是整體處理,導致可能會出現(xiàn)一個高度剛好超過剩余高度的節(jié)點被放置在下一頁造成大塊的空白
  • 分割的閾值設置的比較大,而且沒有針對一些特殊元素(ol, table...)做判斷處理

最后

以上就是基于Vue3實現(xiàn)一個簡歷生成工具的詳細內(nèi)容,更多關于Vue3簡歷生成工具的資料請關注腳本之家其它相關文章!

相關文章

  • 講解vue-router之什么是編程式路由

    講解vue-router之什么是編程式路由

    編程式路由在我們的項目使用過程中最常用的的方法了。這篇文章主要介紹了講解vue-router之什么是編程式路由,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧
    2018-05-05
  • element-ui tooltip修改背景顏色和箭頭顏色的實現(xiàn)

    element-ui tooltip修改背景顏色和箭頭顏色的實現(xiàn)

    這篇文章主要介紹了element-ui tooltip修改背景顏色和箭頭顏色的實現(xiàn),文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨著小編來一起學習學習吧
    2019-12-12
  • 詳解基于vue-cli配置移動端自適應

    詳解基于vue-cli配置移動端自適應

    本篇文章主要介紹了詳解基于vue-cli配置移動端自適應,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧
    2018-01-01
  • 利用Vue.js+Node.js+MongoDB實現(xiàn)一個博客系統(tǒng)(附源碼)

    利用Vue.js+Node.js+MongoDB實現(xiàn)一個博客系統(tǒng)(附源碼)

    本文主要介紹了利用Vue.js+Node.js+MongoDB實現(xiàn)一個博客系統(tǒng),這個博客使用Vue做前端框架,Node+express做后端,數(shù)據(jù)庫使用的是MongoDB。實現(xiàn)了用戶注冊、用戶登錄、博客管理、文章編輯、標簽分類等功能,需要的朋友可以參考學習。
    2017-04-04
  • Vue常見錯誤Error?in?mounted?hook解決辦法

    Vue常見錯誤Error?in?mounted?hook解決辦法

    這篇文章主要給大家介紹了關于Vue常見錯誤Error?in?mounted?hook的解決辦法,出現(xiàn)這樣的問題,會發(fā)現(xiàn)跟聲明周期鉤子有關系,文中通過示例代碼介紹的非常詳細,需要的朋友可以參考下
    2023-07-07
  • 你點的 ES6一些小技巧,請查收

    你點的 ES6一些小技巧,請查收

    本文給大家總結ES6新特性:默認參數(shù)、reduce、解構賦值和Set在使用時的一些小技巧。需要的朋友參考下吧
    2018-04-04
  • vue使用input封裝上傳文件圖片全局組件的示例代碼

    vue使用input封裝上傳文件圖片全局組件的示例代碼

    實際開發(fā)過程中,我們經(jīng)常遇見需要上傳文件圖片功能,可以封裝一個全局組件來調(diào)用,這篇文章給大家介紹vue使用input封裝上傳文件圖片全局組件,感興趣的朋友跟隨小編一起看看吧
    2023-11-11
  • 解決在vue+webpack開發(fā)中出現(xiàn)兩個或多個菜單公用一個組件問題

    解決在vue+webpack開發(fā)中出現(xiàn)兩個或多個菜單公用一個組件問題

    這篇文章主要介紹了在vue+webpack實際開發(fā)中出現(xiàn)兩個或多個菜單公用一個組件的解決方案,需要的朋友可以參考下
    2017-11-11
  • Vue.js快速入門實例教程

    Vue.js快速入門實例教程

    vue是法語中視圖的意思,Vue.js是一個輕巧、高性能、可組件化的MVVM庫,同時擁有非常容易上手的API。這篇文章主要介紹了Vue.js快速入門實例教程的相關資料,需要的朋友可以參考下
    2016-10-10
  • Vue+ElementUI技巧之自定義表單項label的文字提示方法

    Vue+ElementUI技巧之自定義表單項label的文字提示方法

    這篇文章主要給大家介紹了關于Vue+ElementUI技巧之自定義表單項label文字提示的相關資料,文中通過圖文以及代碼示例介紹的非常詳細,對大家的學習或者工作具有一定的參考借鑒價值,需要的朋友可以參考下
    2024-02-02

最新評論

南岸区| 姜堰市| 右玉县| 濮阳县| 阿坝县| 常德市| 湖州市| 黄骅市| 闸北区| 托里县| 弥勒县| 宿州市| 宁波市| 乃东县| 禹州市| 呼伦贝尔市| 德惠市| 嘉义县| 扶风县| 高州市| 潮州市| 搜索| 荃湾区| 金阳县| 阿城市| 越西县| 吉林省| 都江堰市| 临泉县| 鄂州市| 孟连| 奉节县| 周宁县| 临沧市| 环江| 开封市| 石渠县| 金秀| 东辽县| 河源市| 玉树县|