u
This commit is contained in:
1 parent
bcc4722efc
commit
f48536774f
403 files changed
+34776
No files matched your search
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<div class="home-query">
|
||||
<div class="home-query-head">
|
||||
<div class="sort-container" :class="{ active: isActiveSort }">
|
||||
<a-cascader
|
||||
:options="commonOptions.propertyOptions.value"
|
||||
class="sort-select"
|
||||
:style="{ width: '280px', height: '36px', background: '#fff' }"
|
||||
placeholder="请选择行业"
|
||||
v-model="searchCompanyStore.selectedIndustryValue"
|
||||
@focus="isActiveSort = true"
|
||||
@blur="isActiveSort = false"
|
||||
multiple />
|
||||
</div>
|
||||
<div class="area-container" :class="{ active: isActiveArea }">
|
||||
<a-cascader
|
||||
:options="commonOptions.areaOptions.value"
|
||||
class="area-select"
|
||||
:style="{ width: '340px', height: '36px', background: '#fff' }"
|
||||
placeholder="请选择地区"
|
||||
v-model="searchCompanyStore.selectedAreaValue"
|
||||
@focus="isActiveArea = true"
|
||||
@blur="isActiveArea = false"
|
||||
check-strictly
|
||||
multiple />
|
||||
</div>
|
||||
<div class="query-container" :class="{ active: isActiveQuery }">
|
||||
<a-input
|
||||
:style="{ width: '340px', height: '36px', background: '#fff' }"
|
||||
placeholder="请输入公司名称"
|
||||
v-model="searchCompanyStore.searchNameValue"
|
||||
@focus="isActiveQuery = true"
|
||||
@blur="isActiveQuery = false"
|
||||
allow-clear />
|
||||
</div>
|
||||
<div class="btn-container" @click="handleSearch">
|
||||
<div class="i-material-symbols:search icon" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
// import { AreaType, getAreaTreeDataApi } from "@/service/area";
|
||||
import { useRoute } from "vue-router";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useSearchCompanyStore } from "@/store/searchCompanyStore";
|
||||
import { useCommonOptions } from "@/store/commonOptions";
|
||||
|
||||
const emit = defineEmits(["query"]);
|
||||
|
||||
const commonOptions = useCommonOptions();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const searchCompanyStore = useSearchCompanyStore();
|
||||
|
||||
const isActiveSort = ref(false);
|
||||
const isActiveArea = ref(false);
|
||||
const isActiveQuery = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
initData();
|
||||
});
|
||||
|
||||
async function initData() {}
|
||||
|
||||
const handleSearch = () => {
|
||||
if (route.fullPath === "/home") {
|
||||
router.push("/company");
|
||||
} else {
|
||||
emit("query");
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.home-query {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.home-query-head {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 8px 40px #61626514;
|
||||
padding: 0 8px;
|
||||
border: 0;
|
||||
.sort-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 300px;
|
||||
height: 60px;
|
||||
background: #fff;
|
||||
}
|
||||
.sort-container.active {
|
||||
border-radius: 4px;
|
||||
border: 2px solid #165dff;
|
||||
}
|
||||
.sort-container ::v-deep(.arco-select-view) {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
height: 100%;
|
||||
padding: 0 12px;
|
||||
background: transparent;
|
||||
}
|
||||
.area-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 360px;
|
||||
height: 60px;
|
||||
background: #fff;
|
||||
}
|
||||
.area-container.active {
|
||||
border-radius: 4px;
|
||||
border: 2px solid #165dff;
|
||||
}
|
||||
.area-container ::v-deep(.arco-select-view) {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
height: 100%;
|
||||
padding: 0 12px;
|
||||
background: transparent;
|
||||
}
|
||||
.query-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 340px;
|
||||
height: 60px;
|
||||
background: #fff;
|
||||
}
|
||||
.query-container.active {
|
||||
border-radius: 4px;
|
||||
border: 2px solid #165dff;
|
||||
}
|
||||
.query-container :deep(.arco-input-wrapper) {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
height: 100%;
|
||||
padding: 0 12px;
|
||||
background: transparent;
|
||||
}
|
||||
.btn-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
margin-left: 40px;
|
||||
background: #232324;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
.icon {
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sort-container ::v-deep(.arco-select-view-multiple.arco-select-view-size-medium .arco-select-view-input) {
|
||||
min-height: 32px; /* 你想要的高度 */
|
||||
}
|
||||
.sort-container ::v-deep(.arco-select-view-multiple.arco-select-view-size-medium .arco-select-view-tag) {
|
||||
min-height: 32px; /* 你想要的高度 */
|
||||
}
|
||||
.area-container ::v-deep(.arco-select-view-multiple.arco-select-view-size-medium .arco-select-view-input) {
|
||||
min-height: 32px; /* 你想要的高度 */
|
||||
}
|
||||
.area-container ::v-deep(.arco-select-view-multiple.arco-select-view-size-medium .arco-select-view-tag) {
|
||||
min-height: 32px; /* 你想要的高度 */
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<a-card class="card-demo" title="浏览量" hoverable>
|
||||
<template #extra>
|
||||
<a-link>刷新</a-link>
|
||||
</template>
|
||||
<div ref="domRef" class="h-360px overflow-hidden"></div>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useEcharts } from "@/hooks/echarts";
|
||||
|
||||
const { domRef } = useEcharts(() => ({}));
|
||||
|
||||
const props = defineProps({});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div :class="['empty-container', { 'full-page': !inContainer }]">
|
||||
<svg-icon local-icon="no-data" class="empty-icon" />
|
||||
<div class="empty-title">{{ title }}</div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
inContainer: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: "该资源不存在!"
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.empty-container {
|
||||
width: 100%;
|
||||
height: 100vh; // 撑满整个视口高度
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.empty-icon {
|
||||
font-size: 240px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
// 当不在容器内,撑满整个页面高度
|
||||
.full-page {
|
||||
height: 100vh;
|
||||
background-color: white;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div :class="containerClass">
|
||||
<!-- From Uiverse.io by nazeer004 -->
|
||||
<div class="loader">
|
||||
<span>link56.net</span>
|
||||
<span>link56.net</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
inContainer: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const containerClass = computed(() => {
|
||||
return props.inContainer ? "flex justify-center items-center w-full h-full" : "fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2";
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* From Uiverse.io by nazeer004 */
|
||||
.loader {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.loader span {
|
||||
position: absolute;
|
||||
color: #fff;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 38px;
|
||||
letter-spacing: 5px;
|
||||
}
|
||||
|
||||
.loader span:nth-child(1) {
|
||||
color: transparent;
|
||||
-webkit-text-stroke: 0.3px rgb(0, 57, 244);
|
||||
}
|
||||
|
||||
.loader span:nth-child(2) {
|
||||
color: rgb(0, 4, 255);
|
||||
-webkit-text-stroke: 1px rgb(17, 0, 255);
|
||||
animation: uiverse723 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes uiverse723 {
|
||||
0%,
|
||||
100% {
|
||||
clip-path: polygon(0% 45%, 15% 44%, 32% 50%, 54% 60%, 70% 61%, 84% 59%, 100% 52%, 100% 100%, 0% 100%);
|
||||
}
|
||||
|
||||
50% {
|
||||
clip-path: polygon(0% 60%, 16% 65%, 34% 66%, 51% 62%, 67% 50%, 84% 45%, 100% 46%, 100% 100%, 0% 100%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,415 @@
|
||||
<template>
|
||||
<a-spin :loading="props.loading" class="spin" tip="正在加载中,请稍等 . . .">
|
||||
<div class="app">
|
||||
<div class="topbar" v-if="props.showTopbar">
|
||||
<a-space>
|
||||
<template #split>
|
||||
<a-divider direction="vertical" />
|
||||
</template>
|
||||
<!-- <div class="action">
|
||||
<div class="i-material-symbols:format-list-bulleted-rounded icon"></div>
|
||||
<div>菜单</div>
|
||||
<div class="i-material-symbols:arrow-drop-down icon"></div>
|
||||
</div> -->
|
||||
<a-space>
|
||||
<a-tooltip content="撤销 " position="bottom">
|
||||
<div class="action" @click="editor?.chain().focus().undo().run()">
|
||||
<div class="i-material-symbols:undo icon"></div>
|
||||
</div>
|
||||
</a-tooltip>
|
||||
<a-tooltip content="重做 " position="bottom">
|
||||
<div class="action" @click="editor?.chain().focus().redo().run()">
|
||||
<div class="i-material-symbols:redo icon"></div>
|
||||
</div>
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
|
||||
<a-dropdown>
|
||||
<div class="action">
|
||||
<div class="i-material-symbols:add-circle-outline icon"></div>
|
||||
<div>插入</div>
|
||||
<div class="i-material-symbols:arrow-drop-down icon"></div>
|
||||
</div>
|
||||
<template #content>
|
||||
<a-doption @click="setImage">
|
||||
<template #icon>
|
||||
<div class="i-material-symbols:imagesmode-rounded icon"></div>
|
||||
</template>
|
||||
图片
|
||||
</a-doption>
|
||||
<a-doption @click="setLink">
|
||||
<template #icon>
|
||||
<div class="i-material-symbols:attachment icon"></div>
|
||||
</template>
|
||||
链接
|
||||
</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
<a-space>
|
||||
<a-dropdown :popup-max-height="false">
|
||||
<div class="action">
|
||||
<div>{{ computedTitle }}</div>
|
||||
<div class="i-material-symbols:arrow-drop-down icon"></div>
|
||||
</div>
|
||||
<template #content>
|
||||
<a-doption @click="editor?.chain().focus().setParagraph().run()" :class="{ active: editor?.isActive('paragraph') }">
|
||||
<span>正文</span>
|
||||
</a-doption>
|
||||
<a-doption @click="editor?.chain().focus().toggleHeading({ level: 1 }).run()" :class="{ active: editor?.isActive('heading', { level: 1 }) }">
|
||||
<h1>H1</h1>
|
||||
</a-doption>
|
||||
<a-doption @click="editor?.chain().focus().toggleHeading({ level: 2 }).run()" :class="{ active: editor?.isActive('heading', { level: 2 }) }">
|
||||
<h2>H2</h2>
|
||||
</a-doption>
|
||||
<a-doption @click="editor?.chain().focus().toggleHeading({ level: 3 }).run()" :class="{ active: editor?.isActive('heading', { level: 3 }) }">
|
||||
<h3>H3</h3>
|
||||
</a-doption>
|
||||
<a-doption @click="editor?.chain().focus().toggleHeading({ level: 4 }).run()" :class="{ active: editor?.isActive('heading', { level: 4 }) }">
|
||||
<h4>H4</h4>
|
||||
</a-doption>
|
||||
<a-doption @click="editor?.chain().focus().toggleHeading({ level: 5 }).run()" :class="{ active: editor?.isActive('heading', { level: 5 }) }">
|
||||
<h5>H5</h5>
|
||||
</a-doption>
|
||||
<a-doption @click="editor?.chain().focus().toggleHeading({ level: 6 }).run()" :class="{ active: editor?.isActive('heading', { level: 6 }) }">
|
||||
<h6>H6</h6>
|
||||
</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
<!-- <div class="action">
|
||||
字体
|
||||
<div class="i-material-symbols:arrow-drop-down icon"></div>
|
||||
</div> -->
|
||||
</a-space>
|
||||
|
||||
<a-space>
|
||||
<!-- <a-tooltip content="字体加大 " position="bottom">
|
||||
<div class="action">
|
||||
<div class="i-fluent:font-increase-24-filled icon"></div>
|
||||
</div>
|
||||
</a-tooltip>
|
||||
<a-tooltip content="字体缩小 " position="bottom">
|
||||
<div class="action">
|
||||
<div class="i-fluent:font-decrease-24-filled icon"></div>
|
||||
</div>
|
||||
</a-tooltip> -->
|
||||
<a-tooltip content="加粗" position="bottom">
|
||||
<div class="action" @click="editor?.chain().focus().toggleBold().run()" :class="{ disabled: !editor?.can().chain().focus().toggleBold().run(), active: editor?.isActive('bold') }">
|
||||
<div class="i-material-symbols:format-bold icon"></div>
|
||||
</div>
|
||||
</a-tooltip>
|
||||
<a-tooltip content="倾斜" position="bottom">
|
||||
<div class="action" @click="editor?.chain().focus().toggleItalic().run()" :class="{ disabled: !editor?.can().chain().focus().toggleItalic().run(), active: editor?.isActive('italic') }">
|
||||
<div class="i-material-symbols:format-italic icon"></div>
|
||||
</div>
|
||||
</a-tooltip>
|
||||
<a-tooltip content="中划线" position="bottom">
|
||||
<div class="action" @click="editor?.chain().focus().toggleStrike().run()" :class="{ disabled: !editor?.can().chain().focus().toggleStrike().run(), active: editor?.isActive('strike') }">
|
||||
<div class="i-material-symbols:format-strikethrough icon"></div>
|
||||
</div>
|
||||
</a-tooltip>
|
||||
<a-tooltip content="横线" position="bottom">
|
||||
<div class="action" @click="editor.chain().focus().setHorizontalRule().run()">
|
||||
<div class="i-tabler:separator icon"></div>
|
||||
</div>
|
||||
</a-tooltip>
|
||||
<a-tooltip content="高亮" position="bottom">
|
||||
<div class="action" @click="editor?.chain().focus().toggleHighlight().run()" :class="{ 'highlight-active': editor?.isActive('highlight') }">
|
||||
<div class="i-material-symbols:format-ink-highlighter-rounded icon"></div>
|
||||
</div>
|
||||
</a-tooltip>
|
||||
<a-color-picker size="mini" default-value="#000000" :model-value="editor?.getAttributes('textStyle').color" @change="colorChange" />
|
||||
</a-space>
|
||||
<a-space>
|
||||
<a-tooltip content="无序列表" position="bottom">
|
||||
<div class="action" @click="editor?.chain().focus().toggleBulletList().run()" :class="{ active: editor?.isActive('bulletList') }">
|
||||
<div class="i-material-symbols:format-list-bulleted-rounded icon"></div>
|
||||
</div>
|
||||
</a-tooltip>
|
||||
<a-tooltip content="有序列表" position="bottom">
|
||||
<div class="action" @click="editor?.chain().focus().toggleOrderedList().run()" :class="{ active: editor?.isActive('orderedList') }">
|
||||
<div class="i-material-symbols:format-list-numbered icon"></div>
|
||||
</div>
|
||||
</a-tooltip>
|
||||
<a-tooltip content="引用" position="bottom">
|
||||
<div class="action" @click="editor?.chain().focus().toggleBlockquote().run()" :class="{ active: editor?.isActive('blockquote') }">
|
||||
<div class="i-pajamas:quote icon"></div>
|
||||
</div>
|
||||
</a-tooltip>
|
||||
<a-tooltip content="对齐" position="bottom">
|
||||
<a-dropdown :popup-max-height="false">
|
||||
<div class="action">
|
||||
<div class="i-material-symbols:format-align-justify icon"></div>
|
||||
</div>
|
||||
<!-- 安装依赖 -->
|
||||
<template #content>
|
||||
<a-doption @click="editor?.chain().focus().setTextAlign('left').run()" :class="{ active: editor?.isActive({ textAlign: 'left' }) }">
|
||||
<div class="i-material-symbols:format-align-left icon"></div>
|
||||
</a-doption>
|
||||
<a-doption @click="editor?.chain().focus().setTextAlign('center').run()" :class="{ active: editor?.isActive({ textAlign: 'center' }) }">
|
||||
<div class="i-material-symbols:format-align-center icon"></div>
|
||||
</a-doption>
|
||||
<a-doption @click="editor?.chain().focus().setTextAlign('right').run()" :class="{ active: editor?.isActive({ textAlign: 'right' }) }">
|
||||
<div class="i-material-symbols:format-align-right icon"></div>
|
||||
</a-doption>
|
||||
<a-doption @click="editor?.chain().focus().setTextAlign('justify').run()" :class="{ active: editor?.isActive({ textAlign: 'justify' }) }">
|
||||
<div class="i-material-symbols:align-justify-space-around icon"></div>
|
||||
</a-doption>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
</a-space>
|
||||
</div>
|
||||
<div class="editor-container">
|
||||
<editor-content :editor="editor" class="editor" :class="{ 'editor-border': props.showBorder, 'editor-maxHeight': props.showMaxHeight }" />
|
||||
</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
<image-upload v-model:visible="uploadModalVisible" @success="handleUploadImageSuccess" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, computed, defineProps, defineExpose } from "vue";
|
||||
import { Editor, EditorContent } from "@tiptap/vue-3";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import TextAlign from "@tiptap/extension-text-align";
|
||||
import TextStyle from "@tiptap/extension-text-style";
|
||||
import Highlight from "@tiptap/extension-highlight";
|
||||
import { Color } from "@tiptap/extension-color";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import ImageUpload from "@/components/image-upload/index.vue";
|
||||
const editor = ref<any>(null);
|
||||
|
||||
const props = defineProps({
|
||||
loading: {
|
||||
type: Boolean
|
||||
},
|
||||
showTopbar: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showBorder: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showMaxHeight: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
editable: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
});
|
||||
|
||||
const uploadModalVisible = ref<boolean>(false);
|
||||
|
||||
onMounted(() => {
|
||||
editor.value = new Editor({
|
||||
editable: props.editable,
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Placeholder.configure({
|
||||
placeholder: "点击在此输入内容. . ."
|
||||
}),
|
||||
TextAlign.configure({
|
||||
types: ["heading", "paragraph"]
|
||||
}),
|
||||
Highlight.configure({ multicolor: true }),
|
||||
TextStyle,
|
||||
Color,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
defaultProtocol: "https"
|
||||
}),
|
||||
Image.configure({
|
||||
inline: true,
|
||||
allowBase64: true
|
||||
})
|
||||
],
|
||||
onUpdate: () => {
|
||||
console.log("update");
|
||||
}
|
||||
});
|
||||
editor.value?.commands.focus();
|
||||
});
|
||||
|
||||
const computedTitle = computed(() => {
|
||||
if (editor.value?.isActive("paragraph")) {
|
||||
return "正文";
|
||||
} else if (editor.value?.isActive("heading", { level: 1 })) {
|
||||
return "H1";
|
||||
} else if (editor.value?.isActive("heading", { level: 2 })) {
|
||||
return "H2";
|
||||
} else if (editor.value?.isActive("heading", { level: 3 })) {
|
||||
return "H3";
|
||||
} else if (editor.value?.isActive("heading", { level: 4 })) {
|
||||
return "H4";
|
||||
} else if (editor.value?.isActive("heading", { level: 5 })) {
|
||||
return "H5";
|
||||
} else if (editor.value?.isActive("heading", { level: 6 })) {
|
||||
return "H6";
|
||||
}
|
||||
return "正文";
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
editor.value?.destroy();
|
||||
});
|
||||
|
||||
const colorChange = (value: string) => {
|
||||
editor.value?.chain().focus().setColor(value).run();
|
||||
};
|
||||
|
||||
const setLink = () => {
|
||||
const previousUrl = editor.value?.getAttributes("link").href;
|
||||
const url = window.prompt("URL", previousUrl);
|
||||
|
||||
// cancelled
|
||||
if (url === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// empty
|
||||
if (url === "") {
|
||||
editor.value?.chain().focus().extendMarkRange("link").unsetLink().run();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
editor.value?.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
|
||||
};
|
||||
|
||||
const setImage = async () => {
|
||||
uploadModalVisible.value = true;
|
||||
};
|
||||
|
||||
const setContent = (content: string) => {
|
||||
editor.value?.chain().focus().setContent(content).run();
|
||||
};
|
||||
|
||||
const getContent = () => {
|
||||
return editor.value?.getHTML() || "";
|
||||
};
|
||||
|
||||
const clearContent = () => {
|
||||
editor.value?.commands.clearContent();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
setContent,
|
||||
getContent,
|
||||
clearContent
|
||||
});
|
||||
|
||||
function handleUploadImageSuccess(url: string) {
|
||||
if (url) {
|
||||
console.log(url);
|
||||
|
||||
editor.value?.chain().focus().setImage({ src: url }).run();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background-color: #f8f9fa; /* 背景色 */
|
||||
border: 1px solid #e0e0e0; /* 下划线 */
|
||||
}
|
||||
|
||||
.action {
|
||||
/* 新增防挤压属性 */
|
||||
flex-shrink: 0; /* 关键属性:禁止收缩 */
|
||||
width: auto; /* 保持内容自适应宽度 */
|
||||
min-width: max-content; /* 保证最小宽度足够显示内容 */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.action:hover {
|
||||
background-color: #f2f3f5;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
.active {
|
||||
background-color: #eff1f3;
|
||||
}
|
||||
.highlight-active {
|
||||
background-color: #eff1f3;
|
||||
}
|
||||
.disabled {
|
||||
color: gray;
|
||||
cursor: not-allowed; /* 禁用光标 */
|
||||
opacity: 0.5; /* 透明度降低 */
|
||||
pointer-events: none; /* 阻止点击事件 */
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.editor-border {
|
||||
border-left: 1px solid #e0e0e0; /* 左边框 */
|
||||
border-right: 1px solid #e0e0e0; /* 右边框 */
|
||||
border-bottom: 1px solid #e0e0e0; /* 下边框 */
|
||||
}
|
||||
.editor-maxHeight {
|
||||
max-height: 600px !important;
|
||||
}
|
||||
.editor {
|
||||
background-color: white;
|
||||
width: 100%;
|
||||
padding: 16px 24px;
|
||||
overflow: auto;
|
||||
:deep(.ProseMirror) {
|
||||
/* 修正后的深度选择器 */
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid rgb(242, 243, 245);
|
||||
margin: 2rem 0;
|
||||
}
|
||||
blockquote {
|
||||
border-left: 3px solid #e7e4e2;
|
||||
margin: 1.5rem 0;
|
||||
padding-left: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ProseMirror p.is-editor-empty:first-child::before) {
|
||||
/* 修正后的深度选择器 */
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: #adb5bd;
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
.spin {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div class="upload-card" :class="{ loading }" @click="handleClickUploadImage">
|
||||
<div class="upload-inner" v-if="!modelValue && !loading">
|
||||
<div class="upload-icon i-basil:upload-outline"></div>
|
||||
<div class="upload-text">点击上传</div>
|
||||
</div>
|
||||
<div v-else-if="loading" class="upload-inner">
|
||||
<div class="upload-loading-icon i-svg-spinners:90-ring-with-bg"></div>
|
||||
<div class="upload-text">上传中...</div>
|
||||
</div>
|
||||
<img v-else :src="modelValue" alt="已上传图片" class="upload-image" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { uploadImage, uploadToAliyun } from "@/utils/file";
|
||||
|
||||
// 使用 v-model 的方式绑定
|
||||
defineProps<{
|
||||
modelValue: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", val: string): void;
|
||||
}>();
|
||||
|
||||
const loading = ref(false);
|
||||
|
||||
const handleClickUploadImage = async () => {
|
||||
if (loading.value) return;
|
||||
|
||||
try {
|
||||
const res = await uploadImage();
|
||||
loading.value = true;
|
||||
|
||||
const aliyunRes = await uploadToAliyun(res.blob, res.name, res.size);
|
||||
emit("update:modelValue", aliyunRes.url);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.upload-card {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #fafafa;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.3s ease;
|
||||
padding: 4px;
|
||||
&:hover {
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
&.loading {
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.upload-inner {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.upload-loading-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.upload-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<a-modal v-model:visible="localVisible" title="文件上传" @cancel="handleCancel" @ok="handleOk" :ok-loading="okLoading">
|
||||
<a-radio-group type="button" class="radio" v-model="uploadType">
|
||||
<a-radio value="file">图片上传</a-radio>
|
||||
<a-radio value="link">链接上传</a-radio>
|
||||
</a-radio-group>
|
||||
<div v-if="uploadType === 'file'" class="file">
|
||||
<a-image v-if="uploadImageRes.url" width="200" :src="uploadImageRes.url" />
|
||||
<a-button type="primary" long @click="handleClickUploadImage">点击上传图片</a-button>
|
||||
</div>
|
||||
<div v-else-if="uploadType === 'link'" class="link">
|
||||
<div class="label">图片链接:</div>
|
||||
<a-input v-model="linkUrl" size="small"></a-input>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, defineProps, defineEmits } from "vue";
|
||||
import { uploadImage, UploadImageResponse, uploadToAliyun } from "@/utils/file";
|
||||
const emit = defineEmits(["update:visible", "success"]);
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const localVisible = ref<boolean>(false);
|
||||
const okLoading = ref<boolean>(false);
|
||||
watch(
|
||||
() => props.visible,
|
||||
(newVal) => {
|
||||
localVisible.value = newVal;
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
|
||||
const uploadImageRes = ref<UploadImageResponse>({
|
||||
name: "",
|
||||
size: "",
|
||||
url: "",
|
||||
blob: new Blob()
|
||||
});
|
||||
const linkUrl = ref<string>("");
|
||||
|
||||
const uploadType = ref<string>("file");
|
||||
|
||||
function handleClickUploadImage() {
|
||||
uploadImage().then((data) => {
|
||||
if (data) {
|
||||
uploadImageRes.value = data;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleOk() {
|
||||
if (uploadType.value === "file") {
|
||||
okLoading.value = true;
|
||||
uploadToAliyun(uploadImageRes.value.blob, uploadImageRes.value.name, uploadImageRes.value.size).then((res) => {
|
||||
emit("success", res.url);
|
||||
handleCancel();
|
||||
});
|
||||
} else if (uploadType.value === "link") {
|
||||
emit("success", linkUrl.value);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
localVisible.value = false;
|
||||
linkUrl.value = "";
|
||||
okLoading.value = false;
|
||||
resetUploadImageRes();
|
||||
emit("update:visible", false);
|
||||
}
|
||||
|
||||
function resetUploadImageRes() {
|
||||
uploadImageRes.value = {
|
||||
name: "",
|
||||
size: "",
|
||||
url: "",
|
||||
blob: new Blob()
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.radio {
|
||||
width: 100%;
|
||||
.arco-radio-button {
|
||||
width: 50%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
.file {
|
||||
padding-top: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.link {
|
||||
display: flex;
|
||||
align-items: center; // 垂直居中对齐
|
||||
gap: 8px; // 增加间距
|
||||
padding-top: 24px;
|
||||
.label {
|
||||
width: auto; // 让 label 根据内容自适应
|
||||
min-width: 70px; // 保证最小宽度
|
||||
font-weight: 500; // 加粗,让文本更明显
|
||||
color: #333; // 调整字体颜色
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<img v-bind="$attrs" :src="currentSrc" @error="onError" alt="图片" class="w-full h-auto object-cover " />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const props = defineProps<{
|
||||
url?: string;
|
||||
}>();
|
||||
|
||||
const fallback = "/NoAvatar.png";
|
||||
const currentSrc = ref(props.url?.trim() || fallback);
|
||||
|
||||
// 监听 url 变化,同时处理空值
|
||||
watch(
|
||||
() => props.url,
|
||||
(newUrl) => {
|
||||
currentSrc.value = newUrl?.trim() || fallback;
|
||||
}
|
||||
);
|
||||
|
||||
// 图片加载失败时使用 fallback
|
||||
const onError = () => {
|
||||
currentSrc.value = fallback;
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useAttrs } from "vue";
|
||||
import { Icon } from "@iconify/vue";
|
||||
|
||||
defineOptions({ name: "SvgIcon", inheritAttrs: false });
|
||||
|
||||
/**
|
||||
* Props
|
||||
*
|
||||
* - Support iconify and local svg icon
|
||||
* - If icon and localIcon are passed at the same time, localIcon will be rendered first
|
||||
*/
|
||||
interface Props {
|
||||
/** Iconify icon name */
|
||||
icon?: string;
|
||||
/** Local svg icon name */
|
||||
localIcon?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const attrs = useAttrs();
|
||||
|
||||
const bindAttrs = computed<{ class: string; style: string }>(() => ({
|
||||
class: (attrs.class as string) || "",
|
||||
style: (attrs.style as string) || ""
|
||||
}));
|
||||
|
||||
const symbolId = computed(() => {
|
||||
const { VITE_ICON_LOCAL_PREFIX: prefix } = import.meta.env;
|
||||
|
||||
const defaultLocalIcon = "no-icon";
|
||||
|
||||
const icon = props.localIcon || defaultLocalIcon;
|
||||
|
||||
return `#${prefix}-${icon}`;
|
||||
});
|
||||
|
||||
/** If localIcon is passed, render localIcon first */
|
||||
const renderLocalIcon = computed(() => props.localIcon || !props.icon);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="renderLocalIcon">
|
||||
<svg aria-hidden="true" width="1em" height="1em" v-bind="bindAttrs">
|
||||
<use :xlink:href="symbolId" fill="currentColor" />
|
||||
</svg>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Icon v-if="icon" :icon="icon" v-bind="bindAttrs" />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createApp } from "vue";
|
||||
import UploadProgress from "./index.vue";
|
||||
|
||||
let app: ReturnType<typeof createApp> | null = null;
|
||||
let instance: any = null;
|
||||
|
||||
export function showUploadProgress(status: ProgressStatus = "uploading") {
|
||||
if (!app) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
|
||||
app = createApp(UploadProgress);
|
||||
instance = app.mount(container);
|
||||
}
|
||||
instance.open();
|
||||
instance.setStatus(status); // 外部传入状态
|
||||
}
|
||||
|
||||
export function updateUploadProgress(percent: number, status: ProgressStatus = "uploading") {
|
||||
if (instance) {
|
||||
instance.setPercent(percent);
|
||||
instance.setStatus(status); // 外部传入状态
|
||||
|
||||
if (percent >= 100) {
|
||||
setTimeout(() => {
|
||||
closeUploadProgress();
|
||||
}, 800); // 100%后等一下再关闭,显得自然
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function closeUploadProgress() {
|
||||
if (instance) {
|
||||
instance.close();
|
||||
}
|
||||
}
|
||||
|
||||
// 如果你有 ProgressStatus 类型的需求,确保你导出它
|
||||
export type ProgressStatus = "uploading" | "completed" | "failed";
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<transition name="fade">
|
||||
<div v-if="visible" class="upload-progress-mask">
|
||||
<div class="upload-progress-card">
|
||||
<div :class="['upload-progress-title', status === 'failed' ? 'error' : '']">{{ statusText }}</div>
|
||||
<a-progress :percent="percent" :stroke-width="10" :status="getProgressStatus" />
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from "vue";
|
||||
import { Progress as AProgress } from "@arco-design/web-vue";
|
||||
import { ProgressStatus } from ".";
|
||||
|
||||
// 状态变量
|
||||
const visible = ref(false);
|
||||
const percent = ref(0);
|
||||
const statusText = ref("正在上传,请稍候");
|
||||
const status = ref<ProgressStatus>("uploading"); // 使用 ProgressStatus 类型
|
||||
|
||||
// 监听 percent 变化,更新状态文本
|
||||
watch(percent, (newPercent) => {
|
||||
const clampedPercent = Math.min(newPercent, 1); // 确保 percent 不超过 1
|
||||
percent.value = clampedPercent; // 更新实际的 percent 值
|
||||
|
||||
if (clampedPercent === 1) {
|
||||
status.value = "completed";
|
||||
} else if (clampedPercent < 1 && status.value !== "failed") {
|
||||
status.value = "uploading";
|
||||
}
|
||||
});
|
||||
|
||||
// 监听上传状态变化,更新状态文本
|
||||
watch(status, (newStatus) => {
|
||||
if (newStatus === "completed") {
|
||||
statusText.value = "上传完毕";
|
||||
} else if (newStatus === "uploading") {
|
||||
statusText.value = "正在上传,请稍候 . . .";
|
||||
} else if (newStatus === "failed") {
|
||||
statusText.value = "上传失败,请稍后再试 . . .";
|
||||
}
|
||||
});
|
||||
|
||||
// 获取进度条状态,上传失败时显示为 "exception"
|
||||
const getProgressStatus = computed(() => {
|
||||
if (status.value === "failed") {
|
||||
return "exception";
|
||||
}
|
||||
return percent.value < 1 ? "normal" : "success";
|
||||
});
|
||||
|
||||
// 控制上传进度的函数
|
||||
function open() {
|
||||
percent.value = 0;
|
||||
visible.value = true;
|
||||
status.value = "uploading"; // 开始上传时重置状态
|
||||
statusText.value = "正在上传,请稍候 . . .";
|
||||
}
|
||||
|
||||
function close() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
function setPercent(p: number) {
|
||||
percent.value = parseFloat((p / 100).toFixed(2)); // 保留两位小数
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open,
|
||||
close,
|
||||
setPercent,
|
||||
setStatus: (newStatus: ProgressStatus) => {
|
||||
status.value = newStatus; // 使用 ProgressStatus 类型
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.upload-progress-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 3000;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.upload-progress-card {
|
||||
background: #ffffff;
|
||||
padding: 40px 50px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
|
||||
text-align: center;
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
.upload-progress-title {
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 30px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.upload-progress-title.error {
|
||||
color: red; /* 上传失败时,字体颜色变为红色 */
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Boot, IDomEditor, IButtonMenu } from "@wangeditor/editor";
|
||||
|
||||
// 当前的点击处理器,始终指向最新传入的函数
|
||||
let currentClickHandler: (() => void) | null = null;
|
||||
|
||||
// 是否已经注册过菜单
|
||||
let hasRegistered = false;
|
||||
|
||||
class PreviewButtonMenu implements IButtonMenu {
|
||||
title = "预览";
|
||||
iconSvg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 16 16">
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M12 4.57a.5.5 0 0 0-.024-.235l-.013-.063a1.5 1.5 0 0 0-.18-.434c-.092-.15-.222-.28-.482-.54L8.711.707c-.259-.26-.389-.39-.54-.483a1.5 1.5 0 0 0-.496-.193a.5.5 0 0 0-.235-.024C7.329.004 7.194.004 7.015.004h-2.21c-1.68 0-2.52 0-3.16.327a3.02 3.02 0 0 0-1.31 1.31C.008 2.283.008 3.12.008 4.8v6.4c0 1.68 0 2.52.327 3.16a3.02 3.02 0 0 0 1.31 1.31c.642.327 1.48.327 3.16.327h2.423c.401 0 .602-.523.347-.832a.45.45 0 0 0-.345-.168H4.8c-.857 0-1.44-.001-1.89-.038c-.438-.036-.663-.1-.819-.18a2 2 0 0 1-.874-.874c-.08-.156-.145-.38-.18-.819c-.036-.45-.037-1.03-.037-1.89v-6.4c0-.857 0-1.44.037-1.89c.036-.438.101-.663.18-.819c.192-.376.498-.682.874-.874c.156-.08.381-.145.82-.18C3.36.997 3.94.997 4.8.997H7v3.5a.5.5 0 0 0 .5.5H11v.547c0 .25.207.45.456.473c.285.025.543-.188.543-.474V4.99c0-.178 0-.313-.005-.425zM8 1.41L10.59 4H8z" clip-rule="evenodd"/>
|
||||
<path fill="currentColor" fill-rule="evenodd" d="M11 15c.834 0 1.61-.255 2.25-.691l1.47 1.47a.749.749 0 1 0 1.06-1.06l-1.47-1.47c.436-.641.691-1.41.691-2.25c0-2.21-1.79-4-4-4s-4 1.79-4 4s1.79 4 4 4zm0-1c1.66 0 3-1.34 3-3s-1.34-3-3-3s-3 1.34-3 3s1.34 3 3 3" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
`;
|
||||
tag = "button";
|
||||
|
||||
getValue(): string | boolean {
|
||||
return "";
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
isDisabled(): boolean {
|
||||
return false;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
exec(editor: IDomEditor): void {
|
||||
if (this.isDisabled()) return;
|
||||
currentClickHandler?.(); // 始终执行最新的回调
|
||||
}
|
||||
}
|
||||
|
||||
// 提供注册函数,允许多次调用,但只注册一次菜单
|
||||
export function registerPreviewMenu(handleClick: () => void) {
|
||||
currentClickHandler = handleClick;
|
||||
|
||||
if (hasRegistered) return;
|
||||
|
||||
try {
|
||||
Boot.registerMenu({
|
||||
key: "preview",
|
||||
factory: () => new PreviewButtonMenu()
|
||||
});
|
||||
hasRegistered = true;
|
||||
} catch (e) {
|
||||
if (!/duplicate/i.test(String(e))) {
|
||||
console.warn("注册预览菜单失败:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 菜单插入配置
|
||||
export const toolbarConfig = {
|
||||
excludeKeys: ["codeBlock", "fullScreen", "code"],
|
||||
insertKeys: {
|
||||
index: 32,
|
||||
keys: ["preview"]
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<a-spin :loading="false" class="w-full h-full editor-container">
|
||||
<div class="main">
|
||||
<Toolbar :class="toolbarClass" class="toolbar" :editor="editorRef" :defaultConfig="toolbarConfig" v-if="props.displayMode === 'edit'" />
|
||||
<Editor :class="editorClass" class="editor" @onChange="handleChange" v-model="valueHtml" :defaultConfig="editorConfig" @onCreated="handleCreated" key="main" />
|
||||
</div>
|
||||
<Direcotry class="directory" v-model:content="valueHtml" v-if="showDirecotry" />
|
||||
</a-spin>
|
||||
<Preview v-model:visible="showPreview" :content="previewHtml" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Preview from "./modules/preview.vue";
|
||||
import Direcotry from "./modules/directory.vue";
|
||||
import "@wangeditor/editor/dist/css/style.css"; // 引入 css
|
||||
import { uploadVideo, uploadImage, uploadToAliyun } from "@/utils/file";
|
||||
import { Message } from "@arco-design/web-vue";
|
||||
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
|
||||
|
||||
import { ref, computed, onBeforeUnmount, shallowRef, watch, nextTick, defineExpose, onMounted } from "vue";
|
||||
import { registerPreviewMenu, toolbarConfig } from "./config";
|
||||
|
||||
const props = defineProps({
|
||||
displayMode: {
|
||||
type: String,
|
||||
default: "edit" // 'edit' 表示编辑器模式,'view' 表示只读/阅读模式
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ""
|
||||
},
|
||||
showDirecotry: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const previewHtml = ref(""); // 只读副本
|
||||
|
||||
const showPreview = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
registerPreviewMenu(() => {
|
||||
previewHtml.value = valueHtml.value;
|
||||
showPreview.value = true;
|
||||
});
|
||||
});
|
||||
|
||||
const toolbarClass = computed(() => {
|
||||
return props.displayMode === "view" ? "toolbar-view" : "toolbar-edit";
|
||||
});
|
||||
|
||||
const editorClass = computed(() => {
|
||||
return props.displayMode === "view" ? "editor-view" : "editor-edit";
|
||||
});
|
||||
|
||||
// 编辑器实例,必须用 shallowRef
|
||||
const editorRef = shallowRef();
|
||||
defineExpose({
|
||||
getText: () => {
|
||||
if (editorRef.value) {
|
||||
return editorRef.value.getText();
|
||||
} else {
|
||||
console.error("Editor not initialized");
|
||||
return "";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 内容 HTML
|
||||
const valueHtml = ref("");
|
||||
|
||||
// 目录
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
valueHtml.value = newVal;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const handleChange = () => {
|
||||
emit("update:modelValue", valueHtml);
|
||||
};
|
||||
|
||||
const editorConfig = {
|
||||
placeholder: props.displayMode === "edit" ? "请输入内容..." : "",
|
||||
readOnly: props.displayMode === "view",
|
||||
MENU_CONF: {} // ✅ 这一步不能漏!
|
||||
};
|
||||
|
||||
editorConfig.MENU_CONF["uploadImage"] = {
|
||||
// 自定义选择图片
|
||||
customBrowseAndUpload(insertFn) {
|
||||
uploadImage().then((data) => {
|
||||
if (data) {
|
||||
uploadToAliyun(data.blob, data.name, data.size).then((res) => {
|
||||
insertFn(res.url, res.fileName, "");
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
editorConfig.MENU_CONF["uploadVideo"] = {
|
||||
customBrowseAndUpload(insertFn) {
|
||||
uploadVideo().then((data) => {
|
||||
if (data) {
|
||||
uploadToAliyun(data.blob, data.name, data.size).then((res) => {
|
||||
insertFn(res.url, res.fileName, "");
|
||||
});
|
||||
} else {
|
||||
Message.error("上传失败,请稍后再试!");
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 组件销毁时,也及时销毁编辑器
|
||||
onBeforeUnmount(() => {
|
||||
const editor = editorRef.value;
|
||||
if (editor == null) return;
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
const handleCreated = (editor) => {
|
||||
editorRef.value = editor;
|
||||
|
||||
nextTick(() => {});
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.toolbar {
|
||||
}
|
||||
.toolbar-view {
|
||||
}
|
||||
.toolbar-edit {
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
.editor {
|
||||
overflow-y: hidden;
|
||||
:deep(.w-e-text-container video) {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
.editor-view {
|
||||
height: auto;
|
||||
:deep(.w-e-text-container) {
|
||||
background-color: transparent;
|
||||
}
|
||||
:deep(.w-e-text-container .w-e-scroll [data-slate-editor]) {
|
||||
overflow-y: auto;
|
||||
padding: 0 0px !important;
|
||||
}
|
||||
}
|
||||
.editor-edit {
|
||||
height: 500px !important;
|
||||
border: 1px solid #ccc;
|
||||
border-top: transparent;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
display: flex;
|
||||
gap: 44px;
|
||||
.directory {
|
||||
width: 200px;
|
||||
height: 450px;
|
||||
}
|
||||
.main {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div class="directory-card" :class="{ collapsed }">
|
||||
<div class="directory-header" @click="toggleCollapse">
|
||||
<div class="directory-header-left">
|
||||
<div class="i-material-symbols:folder-copy-outline"></div>
|
||||
<span>目录</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="!collapsed" class="directory-content">
|
||||
<div v-for="item in directory" :key="item.id" class="directory-item" :class="`level-${item.level}`" @click="scrollTo(item.id)">
|
||||
{{ item.text }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, nextTick } from "vue";
|
||||
|
||||
defineOptions({
|
||||
name: "WangEditorDirecotry"
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
content: {
|
||||
type: String
|
||||
},
|
||||
top: {
|
||||
type: Number
|
||||
}
|
||||
});
|
||||
|
||||
const directory = ref([]);
|
||||
const collapsed = ref(false);
|
||||
|
||||
function toggleCollapse() {
|
||||
collapsed.value = !collapsed.value;
|
||||
}
|
||||
|
||||
function scrollTo(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
if (props.top) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
||||
const top = rect.top + scrollTop - props.top;
|
||||
|
||||
window.scrollTo({
|
||||
top,
|
||||
behavior: "smooth"
|
||||
});
|
||||
} else {
|
||||
el.scrollIntoView({
|
||||
behavior: "smooth", // 平滑滚动
|
||||
block: "start", // 元素滚动到视口顶部
|
||||
inline: "nearest" // 元素在水平方向上的滚动对齐方式
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function throttle(fn) {
|
||||
let isRunning = false;
|
||||
return function (...args) {
|
||||
if (!isRunning) {
|
||||
isRunning = true;
|
||||
requestAnimationFrame(() => {
|
||||
fn(...args);
|
||||
isRunning = false;
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
function doGenerateDirectory() {
|
||||
const root = document.querySelector(".editor");
|
||||
|
||||
if (!root) return;
|
||||
const headings = Array.from(root.querySelectorAll("h1, h2, h3"));
|
||||
const toc = headings.map((heading, idx) => {
|
||||
if (!heading.id) heading.id = `section-${idx + 1}`;
|
||||
return {
|
||||
id: heading.id,
|
||||
text: heading.textContent || "",
|
||||
level: +heading.tagName[1], // 1 => h1, 2 => h2, 3 => h3
|
||||
index: idx
|
||||
};
|
||||
});
|
||||
|
||||
directory.value = toc;
|
||||
}
|
||||
|
||||
const updateDirectory = throttle(doGenerateDirectory, 300);
|
||||
|
||||
watch(
|
||||
() => props.content,
|
||||
(newContent) => {
|
||||
if (newContent) {
|
||||
nextTick(() => {
|
||||
updateDirectory();
|
||||
});
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.directory-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #fff;
|
||||
overflow: hidden;
|
||||
|
||||
&.collapsed {
|
||||
height: 44px !important;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.directory-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 1rem;
|
||||
height: 44px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
.directory-header-left {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.directory-content {
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
word-break: break-all;
|
||||
.directory-item {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
|
||||
&.level-1 {
|
||||
font-weight: 600;
|
||||
padding-left: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&.level-2 {
|
||||
padding-left: 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&.level-3 {
|
||||
padding-left: 40px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: #007aff;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<a-modal v-bind="$attrs" :visible="localVisible" :footer="false" :closable="false" @update:visible="handleVisibleChange" :fullscreen="fullscreen" :width="1100" modal-class="custom-modal">
|
||||
<template #title>
|
||||
<div class="header">
|
||||
<div></div>
|
||||
<div class="title">预览</div>
|
||||
<div class="actions">
|
||||
<div class="action" :class="fullscreen ? 'i-material-symbols:fullscreen-exit' : 'i-material-symbols:fullscreen'" @click="fullscreen = !fullscreen"></div>
|
||||
<div class="action i-material-symbols:close-rounded" @click="closeModal"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div :class="fullscreen ? '' : 'editor'">
|
||||
<Editor class="editorView" :defaultConfig="editorConfig" @onCreated="handleCreated" key="preview" />
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import "@wangeditor/editor/dist/css/style.css";
|
||||
import { Editor } from "@wangeditor/editor-for-vue";
|
||||
import { ref, watch, shallowRef } from "vue";
|
||||
|
||||
// 编辑器配置
|
||||
const editorConfig = {
|
||||
placeholder: "暂无内容",
|
||||
readOnly: true,
|
||||
MENU_CONF: {}
|
||||
};
|
||||
|
||||
// Props 定义
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
content?: string;
|
||||
visible?: boolean;
|
||||
}>(),
|
||||
{
|
||||
content: "",
|
||||
visible: false
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits(["update:visible"]);
|
||||
|
||||
const fullscreen = ref(false);
|
||||
const localVisible = ref(props.visible);
|
||||
const editorRef = shallowRef();
|
||||
|
||||
// 同步 props.visible 到 localVisible
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
localVisible.value = val;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
watch(
|
||||
() => props.content,
|
||||
(val) => {
|
||||
if (editorRef.value) {
|
||||
editorRef.value.setHtml(val);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 编辑器创建回调
|
||||
const handleCreated = (editor: any) => {
|
||||
editorRef.value = editor;
|
||||
};
|
||||
|
||||
// 更新 visible 的统一方法
|
||||
const handleVisibleChange = (val: boolean) => {
|
||||
localVisible.value = val;
|
||||
emit("update:visible", val);
|
||||
};
|
||||
|
||||
// 关闭操作
|
||||
const closeModal = () => {
|
||||
fullscreen.value = false;
|
||||
emit("update:visible", false);
|
||||
handleVisibleChange(false);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.title {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 18px;
|
||||
.action {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
.editor {
|
||||
max-height: 500px;
|
||||
}
|
||||
|
||||
.editor-view {
|
||||
overflow-y: hidden;
|
||||
height: auto;
|
||||
:deep(.w-e-text-container .w-e-scroll [data-slate-editor]) {
|
||||
padding: 0 0px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user