refactor: 将页面的Markdown渲染改为HTML渲染 #79

Manually merged
remilia merged 5 commits from feat/html-render into master 2025-11-14 11:49:02 +08:00
18 changed files with 159 additions and 60 deletions

View File

@ -2,7 +2,14 @@
<div class="product-detail"> <div class="product-detail">
<el-tabs v-model="activeName" class="product-tabs" stretch> <el-tabs v-model="activeName" class="product-tabs" stretch>
<el-tab-pane :label="$t('product-tab.details')" name="details"> <el-tab-pane :label="$t('product-tab.details')" name="details">
<markdown-renderer :content="product.description || ''" /> <!-- eslint-disable-next-line vue/no-v-html -->
<div v-if="!hydrated" v-html="product.description || ''" />
<div v-else>
<html-renderer
class="html-typography"
:html="product.description || ''"
/>
</div>
</el-tab-pane> </el-tab-pane>
<el-tab-pane :label="$t('product-tab.specs')" name="specs"> <el-tab-pane :label="$t('product-tab.specs')" name="specs">
<spec-table :data="product.specs" /> <spec-table :data="product.specs" />
@ -25,7 +32,13 @@
}, },
}); });
const hydrated = ref(false);
const activeName = ref('details'); // 默认选中概览标签 const activeName = ref('details'); // 默认选中概览标签
onMounted(() => {
hydrated.value = true;
});
</script> </script>
<style scoped> <style scoped>

View File

@ -2,16 +2,14 @@
<article class="solution-defail"> <article class="solution-defail">
<header class="solution-header"> <header class="solution-header">
<h1 class="solution-title">{{ solution.title }}</h1> <h1 class="solution-title">{{ solution.title }}</h1>
<dl class="solution-meta">
<dt class="visually-hidden">CreatedAt:</dt>
<dd class="solution-date">
{{ new Date(solution.createAt).toLocaleDateString() }}
</dd>
</dl>
<p class="solution-summary">{{ solution.summary }}</p> <p class="solution-summary">{{ solution.summary }}</p>
</header> </header>
<section class="solution-content"> <section class="solution-content">
<markdown-renderer :content="solution?.content || ''" /> <!-- eslint-disable-next-line vue/no-v-html -->
<div v-if="!hydrated" v-html="solution?.content || ''" />
<div v-else>
<html-renderer class="html-typography" :html="solution.content || ''" />
</div>
</section> </section>
</article> </article>
</template> </template>
@ -23,6 +21,12 @@
default: null, default: null,
}, },
}); });
const hydrated = ref(false);
onMounted(() => {
hydrated.value = true;
});
</script> </script>
<style scoped> <style scoped>

View File

@ -13,11 +13,8 @@
const props = defineProps<Props>(); const props = defineProps<Props>();
const contentWithAbsoluteUrls = convertMedia(props.content);
// 将 Markdown 转换成 HTML // 将 Markdown 转换成 HTML
const safeHtml = computed(() => renderMarkdown(contentWithAbsoluteUrls)); const safeHtml = computed(() => renderMarkdown(props.content));
// const safeHtml = computed(() => renderMarkdown(props.content))
const container = ref<HTMLElement | null>(null); const container = ref<HTMLElement | null>(null);

View File

@ -8,10 +8,10 @@
:title="question.title" :title="question.title"
:name="question.id" :name="question.id"
> >
<!-- <markdown-renderer :content="question.content || ''" /> --> <!-- eslint-disable-next-line vue/no-v-html -->
<div v-if="!hydrated" v-html="question.content" /> <div v-if="!hydrated" v-html="question.content" />
<div v-else> <div v-else>
<HtmlRenderer <html-renderer
class="html-typography" class="html-typography"
:html="question.content || ''" :html="question.content || ''"
/> />

View File

@ -4,7 +4,14 @@
<app-breadcrumb class="breadcrumb" :items="breadcrumbItems" /> <app-breadcrumb class="breadcrumb" :items="breadcrumbItems" />
<div class="content"> <div class="content">
<markdown-renderer :content="companyProfile.content || ''" /> <!-- eslint-disable-next-line vue/no-v-html -->
<div v-if="!hydrated" v-html="companyProfile.content || ''" />
<div v-else>
<html-renderer
class="html-typography"
:html="companyProfile.content || ''"
/>
</div>
</div> </div>
<el-divider content-position="left">{{ $t('learn-more') }}</el-divider> <el-divider content-position="left">{{ $t('learn-more') }}</el-divider>
@ -28,6 +35,7 @@
<script setup lang="ts"> <script setup lang="ts">
const localePath = useLocalePath(); const localePath = useLocalePath();
const hydrated = ref(false);
const breadcrumbItems = [ const breadcrumbItems = [
{ label: $t('navigation.home'), to: localePath('/') }, { label: $t('navigation.home'), to: localePath('/') },
{ label: $t('navigation.about-us') }, { label: $t('navigation.about-us') },
@ -44,6 +52,10 @@
useHead({ useHead({
title: pageTitle, title: pageTitle,
}); });
onMounted(() => {
hydrated.value = true;
});
</script> </script>
<style scoped> <style scoped>
@ -62,14 +74,6 @@
margin-bottom: 2rem; margin-bottom: 2rem;
} }
:deep(.markdown-body p) {
text-indent: 2em;
}
:deep(.markdown-body h2) {
text-align: center;
}
:deep(.el-divider__text) { :deep(.el-divider__text) {
color: var(--el-color-info); color: var(--el-color-info);
font-size: 1em; font-size: 1em;

View File

@ -7,7 +7,14 @@
</div> </div>
<div v-if="!pending" class="page-content"> <div v-if="!pending" class="page-content">
<markdown-renderer :content="contactInfo.content || ''" /> <!-- eslint-disable-next-line vue/no-v-html -->
<div v-if="!hydrated" v-html="contactInfo?.content || ''" />
<div v-else>
<html-renderer
class="html-typography"
:html="contactInfo?.content || ''"
/>
</div>
</div> </div>
<div v-else class="loading"> <div v-else class="loading">
<el-skeleton :rows="5" animated /> <el-skeleton :rows="5" animated />
@ -17,6 +24,7 @@
<script setup lang="ts"> <script setup lang="ts">
const localePath = useLocalePath(); const localePath = useLocalePath();
const hydrated = ref(false);
const breadcrumbItems = [ const breadcrumbItems = [
{ label: $t('navigation.home'), to: localePath('/') }, { label: $t('navigation.home'), to: localePath('/') },
{ label: $t('navigation.support'), to: localePath('/support') }, { label: $t('navigation.support'), to: localePath('/support') },
@ -34,6 +42,10 @@
usePageSeo({ usePageSeo({
title: pageTitle, title: pageTitle,
}); });
onMounted(() => {
hydrated.value = true;
});
</script> </script>
<style scoped> <style scoped>

View File

@ -18,17 +18,3 @@ export function renderMarkdown(content: string): string {
return dirtyHtml; return dirtyHtml;
} }
export function convertMedia(content: string): string {
// 通过正则表达式替换Markdown中的图片链接
// ![alt text](image-url) -> ![alt text](strapiMedia(image-url))
if (!content) return '';
const contentWithAbsoluteUrls = content.replace(
/!\[([^\]]*)\]\((\/uploads\/[^)]+)\)/g,
(_, alt, url) => `![${alt}](${useStrapiMedia(url)})`
);
return contentWithAbsoluteUrls;
}

View File

@ -1,17 +0,0 @@
import { pinyin } from 'pinyin-pro';
/**
* 将汉语文本转换为拼音形式
*/
export function transliterateText(input: string): string {
if (!input) return '';
const text = input.normalize('NFKC').trim();
// 检测是否包含中文字符
if (/[\u4e00-\u9fa5]/.test(text)) {
return pinyin(text, { toneType: 'none', type: 'array' }).join('');
}
// 否则返回原文本
return text;
}

View File

@ -44,6 +44,10 @@ export default defineNuxtConfig({
}, },
directus: { directus: {
url: process.env.DIRECTUS_URL || 'http://localhost:8055', url: process.env.DIRECTUS_URL || 'http://localhost:8055',
publicUrl:
process.env.DIRECTUS_PUBLIC_URL ||
process.env.DIRECTUS_URL ||
'http://localhost:8055',
token: process.env.DIRECTUS_TOKEN || undefined, token: process.env.DIRECTUS_TOKEN || undefined,
}, },
}, },

View File

@ -22,6 +22,7 @@
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
"@unocss/nuxt": "^66.4.2", "@unocss/nuxt": "^66.4.2",
"@vueuse/nuxt": "^13.6.0", "@vueuse/nuxt": "^13.6.0",
"dom-serializer": "^2.0.0",
"domhandler": "^5.0.3", "domhandler": "^5.0.3",
"dompurify": "^3.2.6", "dompurify": "^3.2.6",
"element-plus": "^2.10.7", "element-plus": "^2.10.7",

3
pnpm-lock.yaml generated
View File

@ -41,6 +41,9 @@ importers:
'@vueuse/nuxt': '@vueuse/nuxt':
specifier: ^13.6.0 specifier: ^13.6.0
version: 13.9.0(magicast@0.3.5)(nuxt@4.1.2(@parcel/watcher@2.5.1)(@types/node@24.4.0)(@vue/compiler-sfc@3.5.21)(db0@0.3.2)(eslint@9.35.0(jiti@2.5.1))(ioredis@5.7.0)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.50.2)(sass@1.92.1)(terser@5.44.0)(typescript@5.9.2)(vite@7.1.5(@types/node@24.4.0)(jiti@2.5.1)(sass@1.92.1)(terser@5.44.0)(yaml@2.8.1))(yaml@2.8.1))(vue@3.5.21(typescript@5.9.2)) version: 13.9.0(magicast@0.3.5)(nuxt@4.1.2(@parcel/watcher@2.5.1)(@types/node@24.4.0)(@vue/compiler-sfc@3.5.21)(db0@0.3.2)(eslint@9.35.0(jiti@2.5.1))(ioredis@5.7.0)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.50.2)(sass@1.92.1)(terser@5.44.0)(typescript@5.9.2)(vite@7.1.5(@types/node@24.4.0)(jiti@2.5.1)(sass@1.92.1)(terser@5.44.0)(yaml@2.8.1))(yaml@2.8.1))(vue@3.5.21(typescript@5.9.2))
dom-serializer:
specifier: ^2.0.0
version: 2.0.0
domhandler: domhandler:
specifier: ^5.0.3 specifier: ^5.0.3
version: 5.0.3 version: 5.0.3

View File

@ -3,5 +3,13 @@ import { companyProfileService } from '~~/server/services/cms/companyProfileServ
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const locale = getHeader(event, 'x-locale') || 'zh-CN'; const locale = getHeader(event, 'x-locale') || 'zh-CN';
return companyProfileService.getCompanyProfile(locale); const companyProfile = await companyProfileService.getCompanyProfile(locale);
companyProfile.content = rewriteAssetUrls(
companyProfile.content,
useRuntimeConfig().public.directus.publicUrl,
'/api/assets'
);
return companyProfile;
}); });

View File

@ -3,5 +3,13 @@ import { contactInfoService } from '~~/server/services/cms/contactInfoService';
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const locale = getHeader(event, 'x-locale') || 'zh-CN'; const locale = getHeader(event, 'x-locale') || 'zh-CN';
return contactInfoService.getContactInfo(locale); const contactInfo = await contactInfoService.getContactInfo(locale);
contactInfo.content = rewriteAssetUrls(
contactInfo.content,
useRuntimeConfig().public.directus.publicUrl,
'/api/assets'
);
return contactInfo;
}); });

View File

@ -9,5 +9,13 @@ export default defineEventHandler(async (event) => {
}); });
const locale = getHeader(event, 'x-locale') || 'zh-CN'; const locale = getHeader(event, 'x-locale') || 'zh-CN';
return productService.getProductById(id, locale); const product = await productService.getProductById(id, locale);
product.description = rewriteAssetUrls(
product.description,
useRuntimeConfig().public.directus.publicUrl,
'/api/assets'
);
return product;
}); });

View File

@ -2,5 +2,15 @@ import { questionService } from '~~/server/services/cms/questionService';
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const locale = getHeader(event, 'x-locale') || 'zh-CN'; const locale = getHeader(event, 'x-locale') || 'zh-CN';
return questionService.getQuestionList(locale); const questions = await questionService.getQuestionList(locale);
questions.forEach((question) => {
question.content = rewriteAssetUrls(
question.content,
useRuntimeConfig().public.directus.publicUrl,
'/api/assets'
);
});
return questions;
}); });

View File

@ -10,5 +10,13 @@ export default defineEventHandler(async (event) => {
const locale = getHeader(event, 'x-locale') || 'zh-CN'; const locale = getHeader(event, 'x-locale') || 'zh-CN';
return solutionService.getSolutionById(id, locale); const solution = await solutionService.getSolutionById(id, locale);
solution.content = rewriteAssetUrls(
solution.content,
useRuntimeConfig().public.directus.publicUrl,
'/api/assets'
);
return solution;
}); });

View File

@ -0,0 +1,14 @@
import { test, expect, describe } from 'vitest';
import { rewriteAssetUrls } from './rewriteAssetUrls';
describe('rewriteAssetUrls', () => {
const cmsBase = 'https://cms.example.com';
const proxyBase = '/api/assets';
const baseHTML =
'<img src="https://cms.example.com/assets/rand-om__-uuid-1234" /><a href="https://cms.example.com/assets/rand-om__-uuid-5678">Link</a><video src="https://otherdomain.com/video.mp4"></video>';
test('rewrites asset URLs correctly', () => {
expect(rewriteAssetUrls(baseHTML, cmsBase, proxyBase)).toBe(
'<img src="/api/assets/rand-om__-uuid-1234"><a href="/api/assets/rand-om__-uuid-5678">Link</a><video src="https://otherdomain.com/video.mp4"></video>'
);
});
});

View File

@ -0,0 +1,36 @@
import { parseDocument, DomUtils } from 'htmlparser2';
import serialize from 'dom-serializer';
export function rewriteAssetUrls(
html: string,
cmsBase: string,
proxyBase: string
) {
if (!html) return html;
const dom = parseDocument(html);
const elements = DomUtils.findAll(
(elem) => elem.type === 'tag',
dom.children
);
for (const el of elements) {
// img / video / source -> src
// a -> href
const tag = el.name.toLowerCase();
const attr = tag === 'a' ? 'href' : 'src';
if (!el.attribs || !el.attribs[attr]) continue;
const url = el.attribs[attr];
// 替换cmsBase为proxyBase
if (url.startsWith(cmsBase)) {
const uuid = url.replace(`${cmsBase}/assets/`, '');
el.attribs[attr] = `${proxyBase}/${uuid}`;
}
}
return serialize(dom);
}