feat(org-home): render homepage markdown with signed images on teacher/student home

Made-with: Cursor
This commit is contained in:
2026-04-28 10:45:11 +08:00
parent 4db9bc82c8
commit c3edff465b
4 changed files with 265 additions and 139 deletions
+5 -32
View File
@@ -54,41 +54,12 @@ import {
showSuccessToast,
Uploader,
} from "vant";
import { marked } from "marked";
import useOrg from "~/store/org";
import { ImageHelper } from "~/utils";
import { UpyunAccess } from "~/utils/upyun";
import { renderOrgHomepageMarkdown, stripBrokenCompressMarkdown } from "~/utils/orgHomepageMarkdown";
const ORG_HOMEPAGE_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
marked.setOptions({ breaks: true, gfm: true });
/** 历史失败上传留在正文里的占位,避免继续污染预览 */
function stripBrokenCompressMarkdown(md: string): string {
return md
.replace(/!\[[^\]]*\]\(\s*failed to open file[^)]*\)/g, "")
.replace(/\n{3,}/g, "\n\n");
}
/** 预览区 img:又拍云需带 _upt,否则浏览器无法加载 */
async function signUpyunImagesInMarkedHtml(html: string): Promise<string> {
if (typeof document === "undefined" || !html.includes("<img")) {
return html;
}
const wrap = document.createElement("div");
wrap.innerHTML = html;
const imgs = wrap.querySelectorAll("img[src]");
await Promise.all(
Array.from(imgs).map(async (el) => {
const src = el.getAttribute("src");
if (!src?.includes("upyun")) return;
const signed = await UpyunAccess.getSign(src);
if (signed) el.setAttribute("src", signed);
}),
);
return wrap.innerHTML;
}
export default defineComponent({
name: "org-homepage",
components: {
@@ -107,8 +78,10 @@ export default defineComponent({
async () => {
const raw = (markdownText.value || "").trim();
if (!raw) return '<p class="preview-empty">暂无内容</p>';
const html = marked.parse(raw) as string;
return signUpyunImagesInMarkedHtml(html);
const html = await renderOrgHomepageMarkdown(raw);
return html.trim()
? html
: '<p class="preview-empty">暂无内容</p>';
},
'<p class="preview-empty">暂无内容</p>',
);
+107 -55
View File
@@ -1,92 +1,144 @@
<template>
<div class="main">
<div class="header">
欢迎回来{{name}}
欢迎回来{{ name }}
</div>
<div class="org-homepage-card">
<div class="org-homepage-title">机构首页{{ currentOrgName || "未选择机构" }}</div>
<pre class="org-homepage-content">{{ homepageContent }}</pre>
<div v-if="!orgStore.currentOrgId" class="org-homepage-plain">
请先在我的页选择机构
</div>
<div v-else class="org-homepage-md" v-html="homepageHtml" />
</div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, onMounted, ref } from 'vue';
import { computedAsync } from "@vueuse/core";
import { computed, defineComponent, onActivated, onMounted, ref } from "vue";
import useUser from "~/store/user";
import useOrg from "~/store/org";
import { renderOrgHomepageMarkdown } from "~/utils/orgHomepageMarkdown";
export default defineComponent({
name: "student-home",
setup() {
const usingUser = useUser();
const { store: orgStore, getHomepage } = useOrg();
const homepageContent = ref("机构暂未配置首页内容。");
const markdownSource = ref("");
const name = computed(() => usingUser.store.current.real_name)
const homepageHtml = computedAsync(
async () => {
const raw = markdownSource.value.trim();
if (!raw)
return '<p class="org-homepage-empty">机构暂未配置首页内容。</p>';
const html = await renderOrgHomepageMarkdown(raw);
return html.trim()
? html
: '<p class="org-homepage-empty">机构暂未配置首页内容。</p>';
},
'<p class="org-homepage-empty">加载中…</p>',
);
const loadHomepage = async () => {
if (!orgStore.currentOrgId) return;
const markdownText = await getHomepage(orgStore.currentOrgId);
markdownSource.value = (markdownText || "").trim();
};
const name = computed(() => usingUser.store.current.real_name);
const currentOrgName = computed(() => {
const currentOrgId = orgStore.currentOrgId;
if (!currentOrgId) return "";
return orgStore.orgs.find((org) => org.id === currentOrgId)?.org_name || currentOrgId;
return (
orgStore.orgs.find((org) => org.id === currentOrgId)?.org_name ||
currentOrgId
);
});
onMounted(async () => {
if (!orgStore.currentOrgId) {
homepageContent.value = "请先在“我的”页选择机构。";
return;
}
const markdownText = await getHomepage(orgStore.currentOrgId);
homepageContent.value = (markdownText || "").trim() || "机构暂未配置首页内容。";
});
onMounted(loadHomepage);
onActivated(loadHomepage);
return {
name,
currentOrgName,
homepageContent,
orgStore,
homepageHtml,
};
}
},
});
</script>
<style scoped lang="less">
.main {
width: 100vw;
overflow: auto;
padding-bottom: 0.64rem;
background: #f7f7f7;
position: relative;
.header {
padding: 0.2rem 0.32rem;
background: #FFFFFF;
}
.org-homepage-card {
margin: 0.24rem 0.24rem 0;
padding: 0.24rem;
background: #ffffff;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.org-homepage-title {
font-weight: 600;
margin-bottom: 0.16rem;
color: #333;
}
.org-homepage-content {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
color: #444;
line-height: 1.6;
font-family: inherit;
font-size: 0.28rem;
}
.main {
width: 100vw;
overflow: auto;
padding-bottom: 0.64rem;
background: #f7f7f7;
position: relative;
.header {
padding: 0.2rem 0.32rem;
background: #ffffff;
}
.org-homepage-card {
margin: 0.24rem 0.24rem 0;
padding: 0.24rem;
background: #ffffff;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.org-homepage-title {
font-weight: 600;
margin-bottom: 0.16rem;
color: #333;
}
.org-homepage-plain {
margin: 0;
color: #444;
line-height: 1.6;
font-size: 0.28rem;
}
.org-homepage-md {
font-size: 0.28rem;
line-height: 1.6;
color: #444;
word-break: break-word;
}
.org-homepage-md :deep(.org-homepage-empty) {
color: #969799;
margin: 0;
}
.org-homepage-md :deep(img) {
max-width: 100%;
height: auto;
vertical-align: middle;
}
.org-homepage-md :deep(pre) {
white-space: pre-wrap;
word-break: break-word;
}
.org-homepage-md :deep(h1),
.org-homepage-md :deep(h2) {
margin: 0.24rem 0 0.16rem;
font-weight: 600;
}
.org-homepage-md :deep(p) {
margin: 0.16rem 0;
}
.org-homepage-md :deep(ul) {
padding-left: 0.4rem;
margin: 0.16rem 0;
}
}
</style>
+107 -52
View File
@@ -1,89 +1,144 @@
<template>
<div class="main">
<div class="header">
欢迎回来{{name}}
欢迎回来{{ name }}
</div>
<div class="org-homepage-card">
<div class="org-homepage-title">机构首页{{ currentOrgName || "未选择机构" }}</div>
<pre class="org-homepage-content">{{ homepageContent }}</pre>
<div v-if="!orgStore.currentOrgId" class="org-homepage-plain">
请先在我的页选择机构
</div>
<div v-else class="org-homepage-md" v-html="homepageHtml" />
</div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, onMounted, ref } from 'vue';
import { computedAsync } from "@vueuse/core";
import { computed, defineComponent, onActivated, onMounted, ref } from "vue";
import useUser from "~/store/user";
import useOrg from "~/store/org";
import { renderOrgHomepageMarkdown } from "~/utils/orgHomepageMarkdown";
export default defineComponent({
name: "teacher-home",
setup() {
const usingUser = useUser();
const { store: orgStore, getHomepage } = useOrg();
const homepageContent = ref("机构暂未配置首页内容。");
const markdownSource = ref("");
const name = computed(() => usingUser.store.current.real_name)
const homepageHtml = computedAsync(
async () => {
const raw = markdownSource.value.trim();
if (!raw)
return '<p class="org-homepage-empty">机构暂未配置首页内容。</p>';
const html = await renderOrgHomepageMarkdown(raw);
return html.trim()
? html
: '<p class="org-homepage-empty">机构暂未配置首页内容。</p>';
},
'<p class="org-homepage-empty">加载中…</p>',
);
const loadHomepage = async () => {
if (!orgStore.currentOrgId) return;
const markdownText = await getHomepage(orgStore.currentOrgId);
markdownSource.value = (markdownText || "").trim();
};
const name = computed(() => usingUser.store.current.real_name);
const currentOrgName = computed(() => {
const currentOrgId = orgStore.currentOrgId;
if (!currentOrgId) return "";
return orgStore.orgs.find((org) => org.id === currentOrgId)?.org_name || currentOrgId;
return (
orgStore.orgs.find((org) => org.id === currentOrgId)?.org_name ||
currentOrgId
);
});
onMounted(async () => {
if (!orgStore.currentOrgId) {
homepageContent.value = "请先在“我的”页选择机构。";
return;
}
const markdownText = await getHomepage(orgStore.currentOrgId);
homepageContent.value = (markdownText || "").trim() || "机构暂未配置首页内容。";
});
onMounted(loadHomepage);
onActivated(loadHomepage);
return {
name,
currentOrgName,
homepageContent,
orgStore,
homepageHtml,
};
}
},
});
</script>
<style scoped lang="less">
.main {
width: 100vw;
overflow: auto;
padding-bottom: 0.64rem;
background: #f7f7f7;
position: relative;
.main {
width: 100vw;
overflow: auto;
padding-bottom: 0.64rem;
background: #f7f7f7;
position: relative;
.header {
padding: 0.2rem 0.32rem;
background: #FFFFFF;
}
.org-homepage-card {
margin: 0.24rem 0.24rem 0;
padding: 0.24rem;
background: #ffffff;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.org-homepage-title {
font-weight: 600;
margin-bottom: 0.16rem;
color: #333;
}
.org-homepage-content {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
color: #444;
line-height: 1.6;
font-family: inherit;
font-size: 0.28rem;
}
.header {
padding: 0.2rem 0.32rem;
background: #ffffff;
}
.org-homepage-card {
margin: 0.24rem 0.24rem 0;
padding: 0.24rem;
background: #ffffff;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.org-homepage-title {
font-weight: 600;
margin-bottom: 0.16rem;
color: #333;
}
.org-homepage-plain {
margin: 0;
color: #444;
line-height: 1.6;
font-size: 0.28rem;
}
.org-homepage-md {
font-size: 0.28rem;
line-height: 1.6;
color: #444;
word-break: break-word;
}
.org-homepage-md :deep(.org-homepage-empty) {
color: #969799;
margin: 0;
}
.org-homepage-md :deep(img) {
max-width: 100%;
height: auto;
vertical-align: middle;
}
.org-homepage-md :deep(pre) {
white-space: pre-wrap;
word-break: break-word;
}
.org-homepage-md :deep(h1),
.org-homepage-md :deep(h2) {
margin: 0.24rem 0 0.16rem;
font-weight: 600;
}
.org-homepage-md :deep(p) {
margin: 0.16rem 0;
}
.org-homepage-md :deep(ul) {
padding-left: 0.4rem;
margin: 0.16rem 0;
}
}
</style>
+46
View File
@@ -0,0 +1,46 @@
import { marked } from "marked";
import { UpyunAccess } from "~/utils/upyun";
marked.setOptions({ breaks: true, gfm: true });
/** 历史失败上传留在正文里的占位,避免继续污染渲染 */
export function stripBrokenCompressMarkdown(md: string): string {
return md
.replace(/!\[[^\]]*\]\(\s*failed to open file[^)]*\)/g, "")
.replace(/\n{3,}/g, "\n\n");
}
/**
* 窄屏编辑器可能把 `![](url)` 折成两行,GFM 无法识别,合并为单行。
*/
function normalizeMarkdownImageLineBreaks(md: string): string {
return md.replace(/!\[([^\]]*)\]\s*\r?\n\s*\(/g, "![$1](");
}
/** 预览 / 首页:img 又拍直链需带 _upt */
export async function signUpyunImagesInMarkedHtml(html: string): Promise<string> {
if (typeof document === "undefined" || !html.includes("<img")) {
return html;
}
const wrap = document.createElement("div");
wrap.innerHTML = html;
const imgs = wrap.querySelectorAll("img[src]");
await Promise.all(
Array.from(imgs).map(async (el) => {
const src = el.getAttribute("src");
if (!src?.includes("upyun")) return;
const signed = await UpyunAccess.getSign(src);
if (signed) el.setAttribute("src", signed);
}),
);
return wrap.innerHTML;
}
/** 机构首页 Markdown → 可展示的 HTML(含又拍图签名) */
export async function renderOrgHomepageMarkdown(markdown: string): Promise<string> {
let cleaned = stripBrokenCompressMarkdown(markdown || "");
cleaned = normalizeMarkdownImageLineBreaks(cleaned).trim();
if (!cleaned) return "";
const html = marked.parse(cleaned) as string;
return signUpyunImagesInMarkedHtml(html);
}