feat!: 将QuestionList的内容渲染由Markdown改为HTML #77
53
app/assets/css/typography.css
Normal file
53
app/assets/css/typography.css
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
.html-typography {
|
||||||
|
padding: 10px;
|
||||||
|
ling-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.html-typography h1 {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
font-size: 1.5em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.html-typography h2 {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
font-size: 1.5em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.html-typography h3 {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
font-size: 1.2em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.html-typography p {
|
||||||
|
text-indent: 2em;
|
||||||
|
text-align: justify;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.html-typography ol {
|
||||||
|
list-style-type: decimal;
|
||||||
|
padding-left: 2em;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.html-typography ul {
|
||||||
|
list-style-type: disc;
|
||||||
|
padding-left: 2em;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.html-typography hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--el-border-color);
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.html-typography table {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
}
|
||||||
30
app/components/shared/HtmlRenderer.ts
Normal file
30
app/components/shared/HtmlRenderer.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
export default defineComponent({
|
||||||
|
name: 'HtmlRenderer',
|
||||||
|
|
||||||
|
props: {
|
||||||
|
html: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
map: {
|
||||||
|
type: Object as () => HtmlRenderMap,
|
||||||
|
default: () => defaultHtmlRenderMap,
|
||||||
|
},
|
||||||
|
allowUnknown: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
setup(props, { attrs }) {
|
||||||
|
const nodes: VNode[] = useHtmlRenderer(props.html, {
|
||||||
|
map: props.map,
|
||||||
|
allowUnknownTags: props.allowUnknown,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug('nodes: ', nodes);
|
||||||
|
|
||||||
|
// 渲染函数:直接返回 VNode 数组
|
||||||
|
return () => h('div', { ...attrs }, nodes);
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -8,7 +8,14 @@
|
|||||||
:title="question.title"
|
:title="question.title"
|
||||||
:name="question.id"
|
:name="question.id"
|
||||||
>
|
>
|
||||||
<markdown-renderer :content="question.content || ''" />
|
<!-- <markdown-renderer :content="question.content || ''" /> -->
|
||||||
|
<div v-if="!hydrated" v-html="question.content" />
|
||||||
|
<div v-else>
|
||||||
|
<HtmlRenderer
|
||||||
|
class="html-typography"
|
||||||
|
:html="question.content || ''"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</el-collapse-item>
|
</el-collapse-item>
|
||||||
</el-collapse>
|
</el-collapse>
|
||||||
</div>
|
</div>
|
||||||
@ -25,6 +32,8 @@
|
|||||||
|
|
||||||
const activeNames = ref<(string | number)[]>([]);
|
const activeNames = ref<(string | number)[]>([]);
|
||||||
|
|
||||||
|
const hydrated = ref(false);
|
||||||
|
|
||||||
// 当路由变化(包括初次挂载)时,检查是否需要聚焦
|
// 当路由变化(包括初次挂载)时,检查是否需要聚焦
|
||||||
watch(
|
watch(
|
||||||
() => route.query.focus,
|
() => route.query.focus,
|
||||||
@ -47,6 +56,10 @@
|
|||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
hydrated.value = true;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
80
app/composables/useHtmlRenderer.ts
Normal file
80
app/composables/useHtmlRenderer.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { h, type VNode } from 'vue';
|
||||||
|
import { parseDocument } from 'htmlparser2';
|
||||||
|
import type { Element, Text, Node } from 'domhandler';
|
||||||
|
|
||||||
|
/** 标签映射的类型:key=标签名,value=渲染函数 */
|
||||||
|
export type HtmlRenderMap = Record<
|
||||||
|
string,
|
||||||
|
(node: Element, children: VNode[]) => VNode
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface HtmlRenderOptions {
|
||||||
|
/** 自定义标签渲染映射 */
|
||||||
|
map?: HtmlRenderMap;
|
||||||
|
|
||||||
|
/** 默认是否将未知标签渲染为原生标签(默认 true) */
|
||||||
|
allowUnknownTags?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useHtmlRenderer = (
|
||||||
|
html: string,
|
||||||
|
options: HtmlRenderOptions = {}
|
||||||
|
) => {
|
||||||
|
const { map = {}, allowUnknownTags = true } = options;
|
||||||
|
|
||||||
|
const doc = parseDocument(html);
|
||||||
|
|
||||||
|
function render(node: Node): VNode | string | null {
|
||||||
|
// 文本节点
|
||||||
|
if (node.type === 'text') {
|
||||||
|
const textNode = node as Text;
|
||||||
|
const content = textNode.data;
|
||||||
|
|
||||||
|
// ❗忽略"纯空白" 文本(换行、空格)
|
||||||
|
if (!content || /^\s+$/.test(content)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标签节点
|
||||||
|
if (node.type === 'tag') {
|
||||||
|
const elem = node as Element;
|
||||||
|
const rawName = elem.name ?? '';
|
||||||
|
const name = rawName.trim().toLowerCase();
|
||||||
|
|
||||||
|
// 标签名有效性校验
|
||||||
|
const isValidTag = /^[a-zA-Z][a-zA-Z0-9-]*$/.test(name);
|
||||||
|
if (!isValidTag) {
|
||||||
|
logger.warn(`Invalid tag name ignored: "${rawName}"`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const children: VNode[] = (elem.children || [])
|
||||||
|
.map((child) => render(child))
|
||||||
|
.filter(Boolean) as VNode[];
|
||||||
|
|
||||||
|
// 自定义映射
|
||||||
|
if (map[name]) {
|
||||||
|
return map[name](elem, children);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认将未知标签渲染为原生标签
|
||||||
|
if (allowUnknownTags) {
|
||||||
|
return h(name, elem.attribs || {}, children);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 忽略无法渲染节点
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes = doc.children
|
||||||
|
.map((child) => render(child))
|
||||||
|
.filter(Boolean) as VNode[];
|
||||||
|
|
||||||
|
return nodes;
|
||||||
|
};
|
||||||
30
app/utils/htmlDefaultMap.ts
Normal file
30
app/utils/htmlDefaultMap.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import type { HtmlRenderMap } from '@/composables/useHtmlRenderer';
|
||||||
|
import { h } from 'vue';
|
||||||
|
import MarkdownTable from '@/components/shared/MarkdownTable.vue';
|
||||||
|
|
||||||
|
export const defaultHtmlRenderMap: HtmlRenderMap = {
|
||||||
|
h1: (_, children) => h('h1', {}, children),
|
||||||
|
|
||||||
|
h2: (_, children) => h('h2', {}, children),
|
||||||
|
|
||||||
|
p: (_, children) => h('p', {}, children),
|
||||||
|
|
||||||
|
// table: (_, children) => h('div', {}, [h('table', {}, children)]),
|
||||||
|
table: (node) => {
|
||||||
|
const { headers, rows } = parseHtmlTable(node);
|
||||||
|
|
||||||
|
return h(
|
||||||
|
'div',
|
||||||
|
{},
|
||||||
|
h(MarkdownTable, {
|
||||||
|
headers,
|
||||||
|
rows,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
img: (node) =>
|
||||||
|
h('img', {
|
||||||
|
src: node.attribs?.src,
|
||||||
|
}),
|
||||||
|
};
|
||||||
90
app/utils/parseTable.ts
Normal file
90
app/utils/parseTable.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import type { Element, Node } from 'domhandler';
|
||||||
|
|
||||||
|
export interface ParsedTable {
|
||||||
|
headers: string[];
|
||||||
|
rows: Record<string, string>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const isElement = (n: Node): n is Element => n.type === 'tag';
|
||||||
|
|
||||||
|
export function parseHtmlTable(table: Element): ParsedTable {
|
||||||
|
if (table.name !== 'table') {
|
||||||
|
throw new Error('parseHtmlTable: node is not <table>');
|
||||||
|
}
|
||||||
|
// 获取 table 下的所有 tag 子节点
|
||||||
|
const getChildren = (node: Node): Element[] =>
|
||||||
|
isElement(node) ? node.children.filter(isElement) : [];
|
||||||
|
|
||||||
|
// ---- 找 thead / tbody ----
|
||||||
|
const children = getChildren(table);
|
||||||
|
const thead = children.find((n) => n.name === 'thead');
|
||||||
|
const tbody = children.find((n) => n.name === 'tbody');
|
||||||
|
|
||||||
|
if (!tbody) {
|
||||||
|
return { headers: [], rows: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyRows = getChildren(tbody).filter((n) => n.name === 'tr');
|
||||||
|
|
||||||
|
if (bodyRows.length === 0) {
|
||||||
|
return { headers: [], rows: [] };
|
||||||
|
}
|
||||||
|
// ---- 1. 表头 ----
|
||||||
|
let headerCells: Element[] = [];
|
||||||
|
|
||||||
|
if (thead) {
|
||||||
|
// 如果 Directus 有 thead(一般不会)
|
||||||
|
const headerRow = getChildren(thead).find((n) => n.name === 'tr');
|
||||||
|
headerCells = headerRow
|
||||||
|
? getChildren(headerRow).filter((n) => n.name === 'th' || n.name === 'td')
|
||||||
|
: [];
|
||||||
|
} else {
|
||||||
|
// Directus 情况:没有 thead → 用 tbody 第一行作为 header
|
||||||
|
headerCells = getChildren(bodyRows[0]).filter(
|
||||||
|
(n) => n.name === 'th' || n.name === 'td'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(headerCells);
|
||||||
|
|
||||||
|
const headers = headerCells.map((cell, i) => {
|
||||||
|
const text = cell.children
|
||||||
|
.filter((c) => c.type === 'text')
|
||||||
|
.map((t) => t.data.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('');
|
||||||
|
return text || `列${i + 1}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 2. 数据行 ----
|
||||||
|
// 如果没有 thead,则跳过第一行(它是 header)
|
||||||
|
const dataRows = thead ? bodyRows : bodyRows.slice(1);
|
||||||
|
|
||||||
|
const rows = dataRows.map((row) => {
|
||||||
|
const cells = getChildren(row).filter(
|
||||||
|
(n) => n.name === 'td' || n.name === 'th'
|
||||||
|
);
|
||||||
|
|
||||||
|
const record: Record<string, string> = {};
|
||||||
|
|
||||||
|
headers.forEach((header, i) => {
|
||||||
|
const cell = cells[i];
|
||||||
|
if (!cell) {
|
||||||
|
record[header] = '';
|
||||||
|
} else {
|
||||||
|
const text = cell.children
|
||||||
|
.filter((c) => c.type === 'text')
|
||||||
|
.map((t) => t.data.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('');
|
||||||
|
record[header] = text;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return record;
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(headers, rows);
|
||||||
|
|
||||||
|
return { headers, rows };
|
||||||
|
}
|
||||||
@ -68,6 +68,7 @@ export default defineNuxtConfig({
|
|||||||
'@unocss/reset/tailwind.css',
|
'@unocss/reset/tailwind.css',
|
||||||
'~/assets/scss/index.scss',
|
'~/assets/scss/index.scss',
|
||||||
'~/assets/css/fonts.css',
|
'~/assets/css/fonts.css',
|
||||||
|
'~/assets/css/typography.css',
|
||||||
'@mdi/font/css/materialdesignicons.min.css',
|
'@mdi/font/css/materialdesignicons.min.css',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@ -22,10 +22,12 @@
|
|||||||
"@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",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
"dompurify": "^3.2.6",
|
"dompurify": "^3.2.6",
|
||||||
"element-plus": "^2.10.7",
|
"element-plus": "^2.10.7",
|
||||||
"fuse.js": "^7.1.0",
|
"fuse.js": "^7.1.0",
|
||||||
"graphql": "^16.12.0",
|
"graphql": "^16.12.0",
|
||||||
|
"htmlparser2": "^10.0.0",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
"meilisearch": "^0.53.0",
|
"meilisearch": "^0.53.0",
|
||||||
"nuxt": "^4.0.3",
|
"nuxt": "^4.0.3",
|
||||||
|
|||||||
22
pnpm-lock.yaml
generated
22
pnpm-lock.yaml
generated
@ -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))
|
||||||
|
domhandler:
|
||||||
|
specifier: ^5.0.3
|
||||||
|
version: 5.0.3
|
||||||
dompurify:
|
dompurify:
|
||||||
specifier: ^3.2.6
|
specifier: ^3.2.6
|
||||||
version: 3.2.6
|
version: 3.2.6
|
||||||
@ -53,6 +56,9 @@ importers:
|
|||||||
graphql:
|
graphql:
|
||||||
specifier: ^16.12.0
|
specifier: ^16.12.0
|
||||||
version: 16.12.0
|
version: 16.12.0
|
||||||
|
htmlparser2:
|
||||||
|
specifier: ^10.0.0
|
||||||
|
version: 10.0.0
|
||||||
markdown-it:
|
markdown-it:
|
||||||
specifier: ^14.1.0
|
specifier: ^14.1.0
|
||||||
version: 14.1.0
|
version: 14.1.0
|
||||||
@ -3090,6 +3096,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||||
engines: {node: '>=0.12'}
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
|
entities@6.0.1:
|
||||||
|
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||||
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
env-paths@2.2.1:
|
env-paths@2.2.1:
|
||||||
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
|
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@ -3544,6 +3554,9 @@ packages:
|
|||||||
hookable@5.5.3:
|
hookable@5.5.3:
|
||||||
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
|
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
|
||||||
|
|
||||||
|
htmlparser2@10.0.0:
|
||||||
|
resolution: {integrity: sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==}
|
||||||
|
|
||||||
http-errors@2.0.0:
|
http-errors@2.0.0:
|
||||||
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
|
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@ -9013,6 +9026,8 @@ snapshots:
|
|||||||
|
|
||||||
entities@4.5.0: {}
|
entities@4.5.0: {}
|
||||||
|
|
||||||
|
entities@6.0.1: {}
|
||||||
|
|
||||||
env-paths@2.2.1: {}
|
env-paths@2.2.1: {}
|
||||||
|
|
||||||
environment@1.1.0: {}
|
environment@1.1.0: {}
|
||||||
@ -9541,6 +9556,13 @@ snapshots:
|
|||||||
|
|
||||||
hookable@5.5.3: {}
|
hookable@5.5.3: {}
|
||||||
|
|
||||||
|
htmlparser2@10.0.0:
|
||||||
|
dependencies:
|
||||||
|
domelementtype: 2.3.0
|
||||||
|
domhandler: 5.0.3
|
||||||
|
domutils: 3.2.2
|
||||||
|
entities: 6.0.1
|
||||||
|
|
||||||
http-errors@2.0.0:
|
http-errors@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
depd: 2.0.0
|
depd: 2.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user