基于Vue3實現(xiàn)一個簡歷生成工具
項目介紹
之前在做個人簡歷的時候,發(fā)現(xiàn)目前一些工具網(wǎng)站上使用起來不太方便,于是打算動手簡單實現(xiàn)一個在線的簡歷工具網(wǎng)站,主要支持以下功能:
- 支持以markdown格式輸入,渲染成簡歷內(nèi)容
- 多模板切換
- 樣式調(diào)整
- 上傳導(dǎo)出功能
體驗地址: hj-hao.github.io/md2cv/

技術(shù)選型
項目整體技術(shù)棧如下:
- 框架: Vue 3
- 構(gòu)建: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>
上面這段簡易的代碼就能支持將用戶輸入文本,轉(zhuǎn)換成html了。
在這個基礎(chǔ)上如果希望增加一些前置元數(shù)據(jù)的配置,類似在Vitepress中我們可以在MD前用YAML語法編寫一些配置??梢允褂胓ray-matter這個庫,能通過分割符將識別解析文本字符串中的YAML格式信息。
此處用官方的例子直接展示用法, 可以看到其將輸入中的YAML部分轉(zhuǎn)換為對象返回,而其余部分則保持輸入直接輸出。
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)容渲染到簡歷模板上,以及可以在不同模板間直接切換實時渲染出對應(yīng)的效果。
實現(xiàn)上每個模板都是一個單獨的組件,UI由兩部分組件一個是簡歷模板個人信息以及正文部分,除組件部分外還有模板相關(guān)的配置項跟隨組件需要導(dǎo)出,因此這里選用JSX/TSX實現(xiàn)簡歷模板組件。構(gòu)造一個基礎(chǔ)的組件封裝公共部分邏輯, 模板間的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 }) {
// 可支持配置的樣式,在基礎(chǔ)模板中通過注入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',
}}
>
{/** 渲染不同模板對應(yīng)的信息模塊 */}
{props.page === 1 && (slots.header ? slots.header() : '')}
{/** 簡歷正文部分 */}
<div
class={`${props.className} template-content`}
innerHTML={props.content}
></div>
</div>
)
},
})
其余模板組件在上面組件的基礎(chǔ)上繼續(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;
}
}
模板加載
完成不同模板組件后,項目需要能自動將這些組件加載到項目中,并將對應(yīng)的組件信息注入全局。通過Vite提供的import.meta.glob可以在文件系統(tǒng)匹配導(dǎo)入對應(yīng)的文件,實現(xiàn)一個Vue插件,就能在Vue掛載前加載對應(yīng)目錄下的組件,并通過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īng)的組件和內(nèi)容后,就能在頁面中將簡歷渲染出來了。但目前還存在一個問題,如果內(nèi)容超長了需要分頁不能直接體現(xiàn)用戶,僅能在導(dǎo)出預(yù)覽時候進行分頁。需要補充上分頁的能力,將渲染的效果和導(dǎo)出預(yù)覽的效果對齊。
整體思路是先將組件渲染在不可見的區(qū)域,之后讀取對應(yīng)的dom節(jié)點,計算每個子元素的高度和,超過后當(dāng)前內(nèi)容最大高度后,新建一頁。最后返回每頁對應(yīng)的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)
// 獲取當(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獲取邊距
// 由于元素上下邊距合并的特性,此處僅考慮下邊距,上邊距通過樣式限制為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)前頁面可渲染的高度
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é)點直接放入當(dāng)頁,然后創(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é)點不會被分割,而是整體處理,導(dǎo)致可能會出現(xiàn)一個高度剛好超過剩余高度的節(jié)點被放置在下一頁造成大塊的空白
- 分割的閾值設(shè)置的比較大,而且沒有針對一些特殊元素(ol, table...)做判斷處理
最后
以上就是基于Vue3實現(xiàn)一個簡歷生成工具的詳細內(nèi)容,更多關(guān)于Vue3簡歷生成工具的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
vue-cli+webpack記事本項目創(chuàng)建
這篇文章主要為大家詳細介紹了vue-cli+webpack創(chuàng)建記事本項目,具有一定的參考價值,感興趣的小伙伴們可以參考一下2017-04-04
vue+el-element中根據(jù)文件名動態(tài)創(chuàng)建dialog的方法實踐
本文主要介紹了vue+el-element中根據(jù)文件名動態(tài)創(chuàng)建dialog的方法實踐,文中通過示例代碼介紹的非常詳細,具有一定的參考價值,感興趣的小伙伴們可以參考一下2021-12-12
關(guān)于element-ui?單選框默認值不選中的解決
這篇文章主要介紹了關(guān)于element-ui?單選框默認值不選中的解決方案,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2022-09-09

