4db9bc82c8
Made-with: Cursor
270 lines
7.3 KiB
Vue
270 lines
7.3 KiB
Vue
<template>
|
||
<div class="main">
|
||
<van-cell-group inset>
|
||
<div class="editor-toolbar">
|
||
<span class="toolbar-hint">图片经服务端压缩后上传,并记入资源库便于排查</span>
|
||
<van-uploader
|
||
:max-count="1"
|
||
:max-size="orgHomepageImageMaxBytes"
|
||
accept="image/*"
|
||
:after-read="onImageAfterRead"
|
||
:before-read="beforeImageRead"
|
||
@oversize="onImageOversize"
|
||
>
|
||
<van-button
|
||
type="primary"
|
||
size="small"
|
||
icon="photo-o"
|
||
:loading="imageUploading"
|
||
>
|
||
插入图片
|
||
</van-button>
|
||
</van-uploader>
|
||
</div>
|
||
<van-field
|
||
ref="markdownFieldRef"
|
||
v-model="markdownText"
|
||
rows="10"
|
||
autosize
|
||
type="textarea"
|
||
label="机构首页 Markdown"
|
||
placeholder="请输入机构首页内容,或插入图片生成 Markdown"
|
||
/>
|
||
</van-cell-group>
|
||
|
||
<div class="actions">
|
||
<van-button type="primary" block @click="onSave">保存</van-button>
|
||
</div>
|
||
|
||
<div class="preview">
|
||
<h5>预览</h5>
|
||
<div class="preview-md" v-html="previewHtml" />
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script lang="ts">
|
||
import { computedAsync } from "@vueuse/core";
|
||
import { defineComponent, nextTick, onMounted, ref } from "vue";
|
||
import {
|
||
Button,
|
||
CellGroup,
|
||
Field,
|
||
showFailToast,
|
||
showSuccessToast,
|
||
Uploader,
|
||
} from "vant";
|
||
import { marked } from "marked";
|
||
import useOrg from "~/store/org";
|
||
import { ImageHelper } from "~/utils";
|
||
import { UpyunAccess } from "~/utils/upyun";
|
||
|
||
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: {
|
||
[Button.name]: Button,
|
||
[CellGroup.name]: CellGroup,
|
||
[Field.name]: Field,
|
||
[Uploader.name]: Uploader,
|
||
},
|
||
setup() {
|
||
const { store, getHomepage, saveHomepage } = useOrg();
|
||
const markdownText = ref("");
|
||
const markdownFieldRef = ref<InstanceType<typeof Field> | null>(null);
|
||
const imageUploading = ref(false);
|
||
|
||
const previewHtml = computedAsync(
|
||
async () => {
|
||
const raw = (markdownText.value || "").trim();
|
||
if (!raw) return '<p class="preview-empty">暂无内容</p>';
|
||
const html = marked.parse(raw) as string;
|
||
return signUpyunImagesInMarkedHtml(html);
|
||
},
|
||
'<p class="preview-empty">暂无内容</p>',
|
||
);
|
||
|
||
const getMarkdownTextarea = (): HTMLTextAreaElement | null => {
|
||
const root = markdownFieldRef.value as unknown as { $el?: HTMLElement };
|
||
return root?.$el?.querySelector?.("textarea") ?? null;
|
||
};
|
||
|
||
const insertMarkdownAtCaret = async (snippet: string) => {
|
||
const textarea = getMarkdownTextarea();
|
||
const text = markdownText.value;
|
||
if (!textarea) {
|
||
markdownText.value = text + snippet;
|
||
return;
|
||
}
|
||
const start = textarea.selectionStart ?? text.length;
|
||
const end = textarea.selectionEnd ?? text.length;
|
||
markdownText.value = text.slice(0, start) + snippet + text.slice(end);
|
||
await nextTick();
|
||
const ta = getMarkdownTextarea();
|
||
if (ta) {
|
||
const pos = start + snippet.length;
|
||
ta.focus();
|
||
ta.setSelectionRange(pos, pos);
|
||
}
|
||
};
|
||
|
||
const beforeImageRead = (file: File) => {
|
||
if (!file.type.startsWith("image/")) {
|
||
showFailToast("请选择图片文件");
|
||
return false;
|
||
}
|
||
return true;
|
||
};
|
||
|
||
const onImageOversize = () => {
|
||
showFailToast("文件大小不能超过 10MB");
|
||
};
|
||
|
||
const onImageAfterRead = async (item: { file: File } | { file: File }[]) => {
|
||
const first = Array.isArray(item) ? item[0] : item;
|
||
const file = first?.file;
|
||
if (!file) {
|
||
showFailToast("读取文件失败");
|
||
return;
|
||
}
|
||
imageUploading.value = true;
|
||
try {
|
||
console.info("[org-homepage] image upload start", {
|
||
name: file.name,
|
||
size: file.size,
|
||
type: file.type,
|
||
});
|
||
const urlRaw = await ImageHelper.compress_by_form(file);
|
||
if (!urlRaw) return;
|
||
const url = urlRaw.split("?")[0];
|
||
console.info("[org-homepage] compress ok, create_resource", { url });
|
||
const rid = await ImageHelper.create_resource(url, file.name || "image.jpg", {
|
||
res_type: "org_homepage",
|
||
});
|
||
if (!rid) {
|
||
showFailToast("资源登记失败,图片可能已上传但未绑定账号");
|
||
return;
|
||
}
|
||
console.info("[org-homepage] create_resource ok", { rid });
|
||
await insertMarkdownAtCaret(`\n\n`);
|
||
showSuccessToast("已插入图片");
|
||
} finally {
|
||
imageUploading.value = false;
|
||
}
|
||
};
|
||
|
||
onMounted(async () => {
|
||
if (!store.currentOrgId) {
|
||
showFailToast("请先选择机构");
|
||
return;
|
||
}
|
||
const loaded = (await getHomepage(store.currentOrgId)) || "";
|
||
markdownText.value = stripBrokenCompressMarkdown(loaded);
|
||
});
|
||
|
||
const onSave = async () => {
|
||
if (!store.currentOrgId) {
|
||
showFailToast("请先选择机构");
|
||
return;
|
||
}
|
||
const cleaned = stripBrokenCompressMarkdown(markdownText.value);
|
||
markdownText.value = cleaned;
|
||
const ok = await saveHomepage(store.currentOrgId, cleaned);
|
||
if (ok) {
|
||
showSuccessToast("保存成功");
|
||
}
|
||
};
|
||
|
||
return {
|
||
markdownText,
|
||
markdownFieldRef,
|
||
onSave,
|
||
previewHtml,
|
||
beforeImageRead,
|
||
onImageAfterRead,
|
||
onImageOversize,
|
||
imageUploading,
|
||
orgHomepageImageMaxBytes: ORG_HOMEPAGE_IMAGE_MAX_BYTES,
|
||
};
|
||
},
|
||
});
|
||
</script>
|
||
|
||
<style scoped lang="less">
|
||
.main {
|
||
padding: 0.5rem;
|
||
}
|
||
.editor-toolbar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 0.24rem;
|
||
padding: 0.32rem 0.32rem 0;
|
||
}
|
||
.toolbar-hint {
|
||
font-size: 0.24rem;
|
||
color: #969799;
|
||
flex: 1;
|
||
min-width: 4rem;
|
||
}
|
||
.actions {
|
||
margin: 0.8rem 0;
|
||
}
|
||
.preview h5 {
|
||
margin: 0 0 0.32rem;
|
||
font-size: 0.28rem;
|
||
color: #323233;
|
||
}
|
||
.preview-md {
|
||
word-break: break-word;
|
||
background: #f7f8fa;
|
||
border-radius: 8px;
|
||
padding: 0.5rem;
|
||
font-size: 0.28rem;
|
||
line-height: 1.6;
|
||
}
|
||
.preview-md :deep(img) {
|
||
max-width: 100%;
|
||
height: auto;
|
||
vertical-align: middle;
|
||
}
|
||
.preview-md :deep(.preview-empty) {
|
||
color: #969799;
|
||
margin: 0;
|
||
}
|
||
.preview-md :deep(pre) {
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
</style>
|