This commit is contained in:
oneao committed 2025-06-18 16:30:01 +08:00
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>