欧美bbbwbbbw肥妇,免费乱码人妻系列日韩,一级黄片

基于Vue3實(shí)現(xiàn)一個(gè)簡(jiǎn)歷生成工具

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

項(xiàng)目介紹

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

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

體驗(yàn)地址: hj-hao.github.io/md2cv/

技術(shù)選型

項(xiàng)目整體技術(shù)棧如下:

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

功能實(shí)現(xiàn)

接下來(lái)簡(jiǎn)單介紹下具體的功能實(shí)現(xiàn)

Markdown解析&渲染

首先要處理的就是對(duì)輸入Markdown的解析。由于需要將內(nèi)容渲染在內(nèi)置的模板簡(jiǎn)歷中,這里就只需要MD -> HTML的能力,因此選用了Markdown-it進(jìn)行實(shí)現(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>

上面這段簡(jiǎn)易的代碼就能支持將用戶輸入文本,轉(zhuǎn)換成html了。
在這個(gè)基礎(chǔ)上如果希望增加一些前置元數(shù)據(jù)的配置,類似在Vitepress中我們可以在MD前用YAML語(yǔ)法編寫一些配置。可以使用gray-matter這個(gè)庫(kù),能通過(guò)分割符將識(shí)別解析文本字符串中的YAML格式信息。

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

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

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

在這個(gè)項(xiàng)目中,就通過(guò)這個(gè)庫(kù)將簡(jiǎn)歷個(gè)人信息(YAML)和簡(jiǎn)歷正本部分(MD)整合在同一個(gè)輸入框中編輯了,具體的實(shí)現(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>

模板功能

模板實(shí)現(xiàn)

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

實(shí)現(xiàn)上每個(gè)模板都是一個(gè)單獨(dú)的組件,UI由兩部分組件一個(gè)是簡(jiǎn)歷模板個(gè)人信息以及正文部分,除組件部分外還有模板相關(guān)的配置項(xiàng)跟隨組件需要導(dǎo)出,因此這里選用JSX/TSX實(shí)現(xiàn)簡(jiǎn)歷模板組件。構(gòu)造一個(gè)基礎(chǔ)的組件封裝公共部分邏輯, 模板間的UI差異通過(guò)slot實(shí)現(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 }) {
        // 可支持配置的樣式,在基礎(chǔ)模板中通過(guò)注入css變量讓子元素訪問(wèn)
        const { pagePadding, fontSize } = storeToRefs(useStyleConfigStore())
        return () => (
            <div
                class="page flex flex-col"
                style={{
                    '--page-padding': pagePadding.value + 'px',
                    '--page-font-size': fontSize.value + 'px',
                }}
            >
                {/** 渲染不同模板對(duì)應(yīng)的信息模塊 */}
                {props.page === 1 && (slots.header ? slots.header() : '')}
                {/** 簡(jiǎn)歷正文部分 */}
                <div
                    class={`${props.className} template-content`}
                    innerHTML={props.content}
                ></div>
            </div>
        )
    },
})

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

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'

// 模板每頁(yè)的最大高度,用于分頁(yè)計(jì)算
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;
    }
}

模板加載

完成不同模板組件后,項(xiàng)目需要能自動(dòng)將這些組件加載到項(xiàng)目中,并將對(duì)應(yīng)的組件信息注入全局。通過(guò)Vite提供的import.meta.glob可以在文件系統(tǒng)匹配導(dǎo)入對(duì)應(yīng)的文件,實(shí)現(xiàn)一個(gè)Vue插件,就能在Vue掛載前加載對(duì)應(yīng)目錄下的組件,并通過(guò)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

預(yù)覽分頁(yè)

有了對(duì)應(yīng)的組件和內(nèi)容后,就能在頁(yè)面中將簡(jiǎn)歷渲染出來(lái)了。但目前還存在一個(gè)問(wèn)題,如果內(nèi)容超長(zhǎng)了需要分頁(yè)不能直接體現(xiàn)用戶,僅能在導(dǎo)出預(yù)覽時(shí)候進(jìn)行分頁(yè)。需要補(bǔ)充上分頁(yè)的能力,將渲染的效果和導(dǎo)出預(yù)覽的效果對(duì)齊。

整體思路是先將組件渲染在不可見(jiàn)的區(qū)域,之后讀取對(duì)應(yīng)的dom節(jié)點(diǎn),計(jì)算每個(gè)子元素的高度和,超過(guò)后當(dāng)前內(nèi)容最大高度后,新建一頁(yè)。最后返回每頁(yè)對(duì)應(yīng)的html字符串,循環(huán)模板組件進(jì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[]>()
    
    // 每頁(yè)渲染的html字符串
    const renderList = computed(() => {
        return pages.value?.map((el) => el.innerHTML)
    })

    const pageSize = computed(() => pages.value?.length || 1)
    
    // 獲取當(dāng)前模板的內(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獲取邊距
    // 由于元素上下邊距合并的特性,此處僅考慮下邊距,上邊距通過(guò)樣式限制為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()
        
        // 當(dāng)前頁(yè)面可渲染的高度
        let PageSize = getCurrentPageHeight(currentPage)
        // 剩余可渲染高度
        let resetPageHeight = PageSize 
        // 頁(yè)面dom數(shù)組
        const pages = [currentPageElement]
 

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

            const height = getElementHeightWithBottomMargin(el)

            // 大于整頁(yè)高度,如果包含子節(jié)點(diǎn)就直接分隔
            // 無(wú)子節(jié)點(diǎn)直接放入當(dāng)頁(yè),然后創(chuàng)建新頁(yè)面
            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
            }
            
            // 針對(duì)高度大于300的元素且包含子元素的節(jié)點(diǎn)進(jìn)行分隔
            // 無(wú)子元素或高度小于300直接創(chuàng)建新頁(yè)面放入
            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,
    }
}
<!-- 實(shí)際展示容器 -->
<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>

上面的代碼目前還存在一些邊界場(chǎng)景分頁(yè)問(wèn)題比如:

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

最后

以上就是基于Vue3實(shí)現(xiàn)一個(gè)簡(jiǎn)歷生成工具的詳細(xì)內(nèi)容,更多關(guān)于Vue3簡(jiǎn)歷生成工具的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!

相關(guān)文章

  • vue之?dāng)?shù)據(jù)交互實(shí)例代碼

    vue之?dāng)?shù)據(jù)交互實(shí)例代碼

    本篇文章主要介紹了vue之?dāng)?shù)據(jù)交互實(shí)例代碼,vue中也存在像ajax和jsonp的數(shù)據(jù)交互,實(shí)現(xiàn)向服務(wù)器獲取數(shù)據(jù),有興趣的可以了解一下
    2017-06-06
  • Vue驗(yàn)證碼60秒倒計(jì)時(shí)功能簡(jiǎn)單實(shí)例代碼

    Vue驗(yàn)證碼60秒倒計(jì)時(shí)功能簡(jiǎn)單實(shí)例代碼

    這篇文章主要介紹了Vue驗(yàn)證碼60秒倒計(jì)時(shí)功能簡(jiǎn)單實(shí)例代碼,代碼簡(jiǎn)單易懂,非常不錯(cuò),具有一定的參考借鑒價(jià)值,需要的朋友可以參考下
    2018-06-06
  • vue-cli+webpack記事本項(xiàng)目創(chuàng)建

    vue-cli+webpack記事本項(xiàng)目創(chuàng)建

    這篇文章主要為大家詳細(xì)介紹了vue-cli+webpack創(chuàng)建記事本項(xiàng)目,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下
    2017-04-04
  • vue+el-element中根據(jù)文件名動(dòng)態(tài)創(chuàng)建dialog的方法實(shí)踐

    vue+el-element中根據(jù)文件名動(dòng)態(tài)創(chuàng)建dialog的方法實(shí)踐

    本文主要介紹了vue+el-element中根據(jù)文件名動(dòng)態(tài)創(chuàng)建dialog的方法實(shí)踐,文中通過(guò)示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下
    2021-12-12
  • vue中的自定義指令clickOutside

    vue中的自定義指令clickOutside

    這篇文章主要介紹了vue中的自定義指令clickOutside,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教
    2022-05-05
  • Vue3請(qǐng)求攔截器里如何配置token

    Vue3請(qǐng)求攔截器里如何配置token

    這篇文章主要介紹了Vue3請(qǐng)求攔截器里如何配置token,具有很好的參考價(jià)值,希望對(duì)大家有所幫助,如有錯(cuò)誤或未考慮完全的地方,望不吝賜教
    2024-08-08
  • 基于vue封裝下拉刷新上拉加載組件

    基于vue封裝下拉刷新上拉加載組件

    這篇文章主要為大家詳細(xì)介紹了基于vue封裝下拉刷新上拉加載組件,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下
    2021-09-09
  • Vue渲染器如何對(duì)節(jié)點(diǎn)進(jìn)行掛載和更新

    Vue渲染器如何對(duì)節(jié)點(diǎn)進(jìn)行掛載和更新

    這篇文章主要介紹了Vue 的渲染器是如何對(duì)節(jié)點(diǎn)進(jìn)行掛載和更新的,文中通過(guò)代碼示例給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作有一定的幫助,需要的朋友可以參考下
    2024-05-05
  • vue基礎(chǔ)之詳解ElementUI的表單

    vue基礎(chǔ)之詳解ElementUI的表單

    這篇文章主要為大家詳細(xì)介紹了vue基礎(chǔ)之ElementUI的表單,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下,希望能夠給你帶來(lái)幫助
    2022-02-02
  • 關(guān)于element-ui?單選框默認(rèn)值不選中的解決

    關(guān)于element-ui?單選框默認(rèn)值不選中的解決

    這篇文章主要介紹了關(guān)于element-ui?單選框默認(rèn)值不選中的解決方案,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教
    2022-09-09

最新評(píng)論