This commit is contained in:
oneao committed 2025-06-10 21:27:20 +08:00
commit c570ec514c
550 files changed
+152597

No files matched your search

@@ -0,0 +1,38 @@
<template>
<div class="layout-main">
<div class="layout-sidebar">
<slot name="sidebar"></slot>
</div>
<div class="layout-content">
<slot name="content"></slot>
</div>
</div>
</template>
<script setup></script>
<style scoped>
.layout-main {
flex: 1;
display: flex;
overflow: hidden;
height: 100%;
}
.layout-sidebar {
border-right: 1px solid #e0e0e0;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none;
-ms-overflow-style: none;
z-index: 2;
background-color: #ffffff;
}
.layout-sidebar::-webkit-scrollbar {
display: none;
}
.layout-content {
flex: 1;
background-color: #ffffff;
overflow: auto;
}
</style>
@@ -0,0 +1,146 @@
<template>
<div>
<HugerteEditor ref="editorRef" :init="init" v-model="modelValue" />
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import HugerteEditor from "@/components/hugerte-editor/index";
import { uploadTempFile } from "@/service/email";
const editorRef = ref<any>(null);
const modelValue = defineModel();
const props = defineProps({
height: {
type: Number,
default: 600
},
placeholder: {
type: String,
default: "请输入内容..."
}
});
const emit = defineEmits(["upload-image"]);
function getPlainText() {
const editor = editorRef.value?.getEditor?.();
return editor?.getContent({ format: "text" }) ?? "";
}
function getHtml() {
const editor = editorRef.value?.getEditor?.();
return editor?.getContent() ?? "";
}
function clear() {
const editor = editorRef.value?.getEditor?.();
if (editor) {
editor.setContent(""); // 清空内容
}
}
defineExpose({
getHtml,
getPlainText,
clear
});
const init = {
language: "zh_CN",
height: props.height,
placeholder: props.placeholder,
menubar: false,
statusbar: false,
plugins: "accordion advlist anchor formatpainter fullscreen image link lists preview table searchreplace ",
font_size_formats: "12px 14px 16px 18px 20px 22px 24px 26px 28px 30px 32px",
font_family_formats:
"PingFang SC;微软雅黑='微软雅黑';宋体='宋体';黑体='黑体';仿宋='仿宋';楷体='楷体';隶书='隶书';幼圆='幼圆';Andale Mono=andale mono,times;Arial=arial,helvetica,sans-serif;Arial Black=arial black,avant garde;Book Antiqua=book antiqua,palatino;Comic Sans MS=comic sans ms,sans-serif;Courier New=courier new,courier;Georgia=georgia,palatino;Helvetica=helvetica;Impact=impact,chicago;Symbol=symbol;Tahoma=tahoma,arial,helvetica,sans-serif;Terminal=terminal,monaco;Times New Roman=times new roman,times;Trebuchet MS=trebuchet ms,geneva;Verdana=verdana,geneva;Webdings=webdings;Wingdings=wingdings,;sans-serif",
toolbar:
"undo redo formatpainter removeformat | " +
"blocks fontfamily fontsize | " +
"bold italic underline strikethrough superscript subscript | forecolor backcolor |hr blockquote | " +
"align lineheight | bullist numlist indent outdent table|" +
"link customImage| searchreplace preview fullscreen print",
formats: {
blockquote: {
block: "blockquote",
styles: {
"border-left": "2px solid #ccc",
margin: "1em 0",
"padding-left": "1em"
}
}
},
body_class: "my-editor-body",
toolbar_mode: "wrap",
setup: function (editor: any) {
function applyTableInlineStyles() {
const body = editor.getBody();
if (!body) return;
body.querySelectorAll("table").forEach((table: HTMLTableElement) => {
table.style.width = "100%";
table.style.borderCollapse = "collapse";
table.style.border = "1px solid #ccc";
});
body.querySelectorAll("th").forEach((th: HTMLTableElement) => {
th.style.border = "1px solid #ccc";
th.style.padding = "8px";
th.style.backgroundColor = "#f2f2f2";
th.style.textAlign = "left";
});
body.querySelectorAll("td").forEach((td: HTMLTableElement) => {
td.style.border = "1px solid #ccc";
td.style.padding = "8px";
td.style.wordBreak = "break-word"; // 更合理
td.style.whiteSpace = "normal";
});
}
// 只在执行“插入表格”命令时添加样式
editor.on("ExecCommand", (e: any) => {
if (e.command === "mceInsertTable") {
// 延迟一下让表格插入完毕
setTimeout(() => {
applyTableInlineStyles();
}, 0);
}
});
editor.ui.registry.addButton("customImage", {
icon: "image",
tooltip: "上传图片",
onAction: () => {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.click();
input.onchange = async () => {
const files = input.files;
if (!files || files.length === 0) return;
const file = files[0];
try {
const res = await uploadTempFile(file);
emit("upload-image", res.data);
editor.insertContent(`<img src="${res.data.fileUrl}" />`);
} catch (err) {
console.error("图片上传失败", err);
}
};
}
});
}
};
</script>
<style lang="scss" scoped>
.preview-iframe {
background-color: white;
}
</style>
@@ -0,0 +1,31 @@
<template>
<SvgIcon :local-icon="localIcon" />
</template>
<script setup lang="ts">
import { computed } from "vue";
const props = defineProps({
email: String
});
const localIcon = computed(() => {
const emailType = props.email?.split("@")[1] || "";
switch (emailType) {
case "163.com":
return "email-163";
case "126.com":
return "email-126";
case "qq.com":
return "email-qq";
case "gmail.com":
return "email-gmail";
case "outlook.com":
return "email-outlook";
case "sina.com":
return "email-sina";
default:
return "email";
}
});
</script>
@@ -0,0 +1,119 @@
<template>
<div v-if="visible" class="init-overlay">
<div class="init-modal">
<div class="init-title">{{ title }}</div>
<div class="init-message">{{ content }}</div>
<div class="progress-container">
<a-progress :percent="percent" size="large" :status="status" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { watch } from "vue";
import type { PropType } from "vue";
const props = defineProps({
title: {
type: String,
required: true
},
content: {
type: String,
required: true
},
status: {
type: String as PropType<"normal" | "success" | "warning" | "danger">,
default: "normal"
},
percent: {
type: Number,
required: true
},
visible: {
type: Boolean,
required: true
}
});
watch(
() => props.visible,
(newVal) => {
if (!newVal) {
// 当模态框关闭时,重置百分比或执行其他操作
console.log("模态框关闭");
}
}
);
</script>
<style lang="scss" scoped>
.init-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 9;
animation: fadeIn 0.5s ease-in-out; /* 添加渐现动画 */
}
.init-modal {
background-color: white;
padding: 30px 40px;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
width: 600px;
max-width: 90%;
text-align: center;
animation: scaleUp 0.5s ease-in-out; /* 添加缩放动画 */
}
/* 渐现动画 */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* 缩放动画 */
@keyframes scaleUp {
from {
transform: scale(0.8);
}
to {
transform: scale(1);
}
}
.init-title {
font-size: 20px;
font-weight: bold;
margin-bottom: 30px;
color: #333;
}
.init-message {
font-size: 16px;
color: #666;
margin-bottom: 14px;
min-height: 24px;
word-break: break-all;
}
.progress-container {
width: 100%;
}
.init-status {
font-size: 14px;
color: #888;
}
</style>
@@ -0,0 +1,93 @@
<template>
<div ref="sandboxRef" class="email-renderer"></div>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from "vue";
import { useRouterStore } from "@/store/modules/router";
const routerStore = useRouterStore();
const props = defineProps<{
html: string;
}>();
const sandboxRef = ref<HTMLDivElement | null>(null);
let shadowRoot: ShadowRoot | null = null;
function sanitizeHtml(html: string): string {
// 简单去除 <script> 标签及其内容
return html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "");
}
watch(
() => props.html,
async (newHtml) => {
if (!sandboxRef.value) return;
await nextTick();
if (!shadowRoot) {
shadowRoot = sandboxRef.value.attachShadow({ mode: "open" });
// 监听shadowRoot里的点击事件,事件委托拦截a标签
shadowRoot.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
const anchor = target.closest("a");
if (anchor && anchor.getAttribute("href")?.startsWith("mailto:")) {
e.preventDefault();
const email = anchor.getAttribute("href")!.replace("mailto:", "");
if (email) {
routerStore.navigateTo({
path: "/write",
query: {
toEmail: email
}
});
}
}
});
}
// 先清空
shadowRoot.innerHTML = "";
// 安全过滤
const safeHtml = sanitizeHtml(newHtml);
// 直接插入,保留里面的 <style> 标签
shadowRoot.innerHTML = `
<style>
html, body {
margin: 0;
padding: 0;
}
img {
max-width: 100%;
height: auto;
display: block;
}
table {
max-width: 100%;
overflow-wrap: break-word;
}
.wrapper {
box-sizing: border-box;
}
</style>
<div class="wrapper">
${safeHtml}
</div>
`;
},
{ immediate: true }
);
</script>
<style scoped>
.email-renderer {
position: relative;
width: 100%;
overflow-x: auto;
}
</style>
@@ -0,0 +1,66 @@
// utils/ErrorModalService.ts
import { createApp, h } from "vue";
import ErrorModal from "./index.vue";
/** 异常详情信息接口 */
export interface ErrorModalOptions {
/** 异常记录的唯一ID */
id?: number;
/** 异常发生时间 */
time?: Date;
/** 请求的URI地址 */
uri?: string;
/** HTTP请求方法,如GET、POST等 */
httpMethod?: string;
/** 抛出异常的类名 */
className?: string;
/** 抛出异常的方法名 */
methodName?: string;
/** 抛出异常的文件名 */
fileName?: string;
/** 抛出异常的代码行号 */
lineNumber?: number;
/** 异常的简短信息 */
summary?: string;
/** 异常的详细堆栈信息 */
detailMessage?: string;
/** 请求参数(可能为JSON字符串等) */
params?: string;
/** 是否展示详细异常信息(用于前端展示控制) */
showDetail?: boolean;
/** 异常状态码,方便分类或识别 */
code?: number;
}
export const ErrorModalService = {
show(responseData: IResData<ErrorModalOptions>) {
const container = document.createElement("div");
document.body.appendChild(container);
const app = createApp({
render() {
return h(ErrorModal, {
responseData: responseData,
onClose: () => {
app.unmount();
document.body.removeChild(container);
}
});
}
});
app.mount(container);
}
};
@@ -0,0 +1,327 @@
<template>
<div class="error-modal-wrapper" @click.self="handleClose">
<div v-if="visible" class="error-modal" @click.stop>
<div class="error-modal-header">
<span class="error-modal-title">⚠️ 系统异常提示 - {{ props.responseData.message }} - {{ props.responseData.code || 500 }}</span>
<div class="error-modal-close-btn" @click="handleClose" role="button" tabindex="0">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</div>
</div>
<div class="error-modal-body">
<div class="log-item" v-for="item in logList" :key="item.label">
<div class="log-label">{{ item.label }}</div>
<div class="log-value">
<pre v-if="item.label === '堆栈跟踪' || item.label === '请求参数'" class="code-block">{{ item.value }}</pre>
<div v-else>{{ item.value }}</div>
</div>
</div>
</div>
<div class="error-modal-footer">
<div class="error-modal-tip">
<svg width="24" height="24" stroke="currentColor" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
<span>请将错误信息 导出 或 复制 发送给管理员!</span>
</div>
<div class="error-modal-actions">
<div class="btn txt-btn" role="button" tabindex="0" @click="exportAsTxt">
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z" />
<path d="M14 2v6h6m-9 4H9m3 3H9m6-9h-1" />
<path d="M12 18v-6m2 3h-4" />
</svg>
<span>导出TXT</span>
</div>
<div class="btn data-btn" role="button" tabindex="0" @click="copyInfo">
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
<span>复制信息</span>
</div>
<div class="btn cancel-btn" role="button" tabindex="0" @click="handleClose">
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M15 9l-6 6m0-6l6 6" />
</svg>
<span>取消</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from "vue";
import { ErrorModalOptions } from ".";
import { copyText } from "@/utils/common";
interface Props {
responseData: IResData<ErrorModalOptions>;
onClose: () => void;
}
const props = defineProps<Props>();
const visible = ref(false);
onMounted(async () => {
await nextTick();
visible.value = true;
});
const logList = computed(() => [
{ label: "ID 唯一标识", value: props.responseData.data.id ?? "" },
{ label: "发生时间", value: props.responseData.data.time?.toString() ?? "" },
{ label: "请求路径", value: props.responseData.data.uri ?? "" },
{ label: "HTTP 方法", value: props.responseData.data.httpMethod ?? "" },
{ label: "类 - 方法", value: `${props.responseData.data.className ?? ""} - ${props.responseData.data.methodName ?? ""}` },
{ label: "代码位置", value: `${props.responseData.data.fileName ?? ""} - ${props.responseData.data.lineNumber ?? ""}` },
{ label: "错误主要信息", value: props.responseData.data.summary ?? "" },
{ label: "堆栈跟踪", value: props.responseData.data.detailMessage ?? "" },
{ label: "请求参数", value: props.responseData.data.params ? JSON.stringify(JSON.parse(props.responseData.data.params), null, 2) : "" }
]);
function handleClose() {
if (visible.value) {
visible.value = false;
props.onClose();
}
}
function exportAsTxt() {
const content = logList.value.map((item) => `${item.label}:\n${item.value}\n`).join("\n");
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "错误信息日志.txt";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function copyInfo() {
copyText(JSON.stringify(props.responseData));
}
</script>
<style lang="scss" scoped>
@keyframes modalFadeIn {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.9);
}
100% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
.error-modal-wrapper {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 99;
.error-modal {
animation: modalFadeIn 0.3s forwards;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border-radius: 4px;
width: 1000px;
overflow: hidden;
background-color: white;
display: flex;
flex-direction: column;
.error-modal-header {
height: 46px;
display: flex;
align-items: center;
padding: 16px;
border-bottom: 1px solid rgb(229, 230, 235);
line-height: 20px;
background-color: #e04848;
.error-modal-title {
font-weight: bold;
font-size: 20px;
color: #fff;
}
.error-modal-close-btn {
padding: 4px;
margin-left: auto;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
color: #fff;
transition:
background-color 0.2s ease,
color 0.2s ease;
svg {
width: 20px;
height: 20px;
}
}
.error-modal-close-btn:hover {
background-color: rgba(255, 255, 255, 0.15);
color: #fff;
border-radius: 2px;
}
}
.error-modal-body {
display: flex;
flex-direction: column;
justify-content: center;
flex: 1;
overflow-x: hidden;
overflow-y: auto;
scrollbar-gutter: stable;
padding: 16px 0px;
max-width: 100%;
box-sizing: border-box; /* 包含内边距 */
.log-item {
display: grid;
grid-template-columns: 120px 1fr;
gap: 16px;
padding: 14px 0;
border-bottom: 1px solid #d9d9d9;
&:last-child {
border-bottom: none;
}
.log-label {
color: #2b2b2b;
font-weight: 600;
text-align: right;
opacity: 0.9;
}
.log-value {
word-break: break-all;
.code-block {
background: #f1f1f1;
padding: 4px 12px;
border-radius: 4px;
max-height: 120px;
overflow-x: auto;
overflow-y: auto;
white-space: pre-wrap; /* 保留换行和缩进空格 */
font-family: Consolas, monospace;
font-size: 13px;
box-sizing: border-box;
}
}
}
}
.error-modal-footer {
border-top: 1px solid rgb(229, 230, 235);
padding: 16px;
height: 56px;
display: flex;
align-items: center;
.error-modal-tip {
display: flex;
align-items: center;
color: #ff7d00;
gap: 8px;
font-size: 16px;
}
.error-modal-actions {
display: flex;
gap: 12px;
margin-left: auto;
.btn {
display: inline-flex;
align-items: center;
padding: 8px 12px;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 250ms cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid transparent;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
&:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
&:active {
transform: translateY(0);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
&:focus-visible {
outline: 2px solid #1976d2;
outline-offset: 2px;
}
.btn-icon {
width: 16px;
height: 16px;
margin-right: 8px;
}
/* 导出按钮样式 */
&.txt-btn {
background: #1976d2;
color: white;
&:hover {
background: #1565c0;
}
&:active {
background: #0d47a1;
}
}
/* 复制按钮样式 */
&.data-btn {
color: #1976d2;
border-color: #90caf9;
&:active {
background: #90caf9;
}
}
/* 取消按钮样式 */
&.cancel-btn {
background: #f5f5f5;
color: #757575;
&:hover {
background: #eeeeee;
}
&:active {
background: #e0e0e0;
}
}
}
}
}
}
}
</style>
@@ -0,0 +1,42 @@
<template>
<SvgIcon :icon="iconName" />
</template>
<script setup lang="ts">
import { computed } from "vue";
const props = defineProps<{
fileName: string | null;
}>();
// 纯函数放组件里也可以,简单方便
function getFileIcon(fileName: string | null): string {
if (!fileName) return "material-symbols:file-copy-outline";
const ext = fileName.toLowerCase().split(".").pop();
switch (ext) {
case "jpg":
case "jpeg":
case "png":
case "gif":
case "bmp":
case "svg":
return "material-symbols:imagesmode-outline";
case "mp4":
case "mkv":
case "avi":
case "mov":
return "material-symbols:camera-video-outline";
case "mp3":
case "wav":
case "flac":
case "ogg":
return "material-symbols:audio-file-outline";
case "pdf":
return "material-symbols:picture-as-pdf-outline";
default:
return "material-symbols:file-copy-outline";
}
}
const iconName = computed(() => getFileIcon(props.fileName));
</script>
@@ -0,0 +1 @@
export const getHugeRTE = () => (globalThis as any).hugerte ?? null;
@@ -0,0 +1,59 @@
/**
* Copyright (c) 2018-present, Ephox, Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import type { Ref, SetupContext } from "vue";
import { watch } from "vue";
import type { IPropTypes } from "./modules/EditorPropTypes";
import type { Editor as HugeRTEEditor, EditorEvent } from "hugerte";
import { validEvents } from "./utils/index";
const isValidKey = (key: string) => validEvents.map((event) => "on" + event.toLowerCase()).indexOf(key.toLowerCase()) !== -1;
const bindHandlers = (initEvent: EditorEvent<any>, listeners: Record<string, any>, editor: HugeRTEEditor): void => {
Object.keys(listeners)
.filter(isValidKey)
.forEach((key: string) => {
const handler = listeners[key];
if (typeof handler === "function") {
if (key === "Init") {
handler(initEvent, editor);
} else {
editor.on(key, (e: EditorEvent<any>) => handler(e, editor));
}
}
});
};
const bindModelHandlers = (props: IPropTypes, ctx: SetupContext, editor: HugeRTEEditor, modelValue: Ref<any>) => {
const modelEvents = props.modelEvents ? props.modelEvents : null;
const normalizedEvents = Array.isArray(modelEvents) ? modelEvents.join(" ") : modelEvents;
watch(modelValue, (val: string, prevVal: string) => {
if (editor && typeof val === "string" && val !== prevVal && val !== editor.getContent({ format: props.outputFormat })) {
editor.setContent(val);
}
});
editor.on(normalizedEvents ? normalizedEvents : "change input undo redo", () => {
ctx.emit("update:modelValue", editor.getContent({ format: props.outputFormat }));
});
};
const initEditor = (initEvent: EditorEvent<any>, props: IPropTypes, ctx: SetupContext, editor: HugeRTEEditor, modelValue: Ref<any>, content: () => string) => {
editor.setContent(content());
if (ctx.attrs["onUpdate:modelValue"]) {
bindModelHandlers(props, ctx, editor, modelValue);
}
bindHandlers(initEvent, ctx.attrs, editor);
};
export {
bindHandlers,
initEditor,
isValidKey // TODO: Seems to only be used in a test, consider removing
};
@@ -0,0 +1,11 @@
/**
* Copyright (c) 2018-present, Ephox, Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import { Editor } from "./modules/Editor";
export default Editor;
@@ -0,0 +1,126 @@
import { ScriptLoader, mergePlugins, isTextarea, uuid } from "../utils/index";
import { getHugeRTE } from "../HugeRTE";
import { initEditor } from "../Utils";
import { editorProps } from "./EditorPropTypes";
import type { IPropTypes } from "./EditorPropTypes";
import type { Ref } from "vue";
import { h, defineComponent, onMounted, ref, toRefs, nextTick, watch, onBeforeUnmount, onActivated, onDeactivated } from "vue";
import type { Editor as HugeRTEEditor, EditorEvent, HugeRTE } from "hugerte";
type EditorOptions = Parameters<HugeRTE["init"]>[0];
const renderInline = (ce: any, id: string, elementRef: Ref<Element | null>, tagName?: string) =>
ce(tagName ? tagName : "div", {
id,
ref: elementRef
});
const renderIframe = (ce: any, id: string, elementRef: Ref<Element | null>) =>
ce("textarea", {
id,
visibility: "hidden",
ref: elementRef
});
const defaultInitValues = { selector: undefined, target: undefined };
export const Editor = defineComponent({
props: editorProps,
setup: (props: IPropTypes, ctx) => {
let conf = props.init ? { ...props.init, ...defaultInitValues } : { ...defaultInitValues };
const { disabled, modelValue, tagName } = toRefs(props);
const element: Ref<Element | null> = ref(null);
let vueEditor: any = null;
const elementId: string = props.id || uuid("hugerte-vue");
const inlineEditor: boolean = (props.init && props.init.inline) || props.inline;
const modelBind = !!ctx.attrs["onUpdate:modelValue"];
let mounting = true;
const initialValue: string = props.initialValue ? props.initialValue : "";
let cache = "";
const getContent = (isMounting: boolean): (() => string) => (modelBind ? () => (modelValue?.value ? modelValue.value : "") : () => (isMounting ? initialValue : cache));
const initWrapper = (): void => {
const content = getContent(mounting);
const finalInit = {
...conf,
readonly: props.disabled,
target: element.value,
plugins: mergePlugins(conf.plugins, props.plugins),
toolbar: props.toolbar || conf.toolbar,
inline: inlineEditor,
setup: (editor: HugeRTEEditor) => {
vueEditor = editor;
editor.on("init", (e: EditorEvent<any>) => initEditor(e, props, ctx, editor, modelValue, content));
if (typeof conf.setup === "function") {
// TODO: add that to other framework integrations, especially svelte
conf.setup(editor);
}
}
};
if (isTextarea(element.value)) {
element.value.style.visibility = "";
}
getHugeRTE().init(finalInit);
mounting = false;
};
watch(disabled, (disable) => {
if (vueEditor !== null) {
if (typeof vueEditor.mode?.set === "function") {
vueEditor.mode.set(disable ? "readonly" : "design");
} else {
vueEditor.setMode(disable ? "readonly" : "design");
}
}
});
watch(tagName, (_) => {
if (!modelBind) {
cache = vueEditor.getContent();
}
getHugeRTE()?.remove(vueEditor);
// TODO
nextTick(() => initWrapper());
});
onMounted(() => {
if (getHugeRTE() !== null) {
initWrapper();
} else if (element.value && element.value.ownerDocument) {
const version = props.cdnVersion ?? "1";
const scriptSrc: string = props.hugerteScriptSrc ?? `https://cdn.jsdelivr.net/npm/hugerte@${version}/hugerte.min.js`;
ScriptLoader.load(element.value.ownerDocument, scriptSrc, initWrapper);
}
});
onBeforeUnmount(() => {
if (getHugeRTE() !== null) {
getHugeRTE().remove(vueEditor);
}
});
if (!inlineEditor) {
onActivated(() => {
if (!mounting) {
initWrapper();
}
});
onDeactivated(() => {
if (!modelBind) {
cache = vueEditor.getContent();
}
getHugeRTE()?.remove(vueEditor);
});
}
const rerender = (init: EditorOptions) => {
cache = vueEditor.getContent();
getHugeRTE()?.remove(vueEditor);
conf = { ...conf, ...init, ...defaultInitValues };
// TODO
nextTick(() => initWrapper());
};
ctx.expose({
rerender,
getEditor: () => vueEditor
});
return () => (inlineEditor ? renderInline(h, elementId, element, props.tagName) : renderIframe(h, elementId, element));
}
});
@@ -0,0 +1,47 @@
/**
* Copyright (c) 2018-present, Ephox, Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import type { HugeRTE } from "hugerte";
type EditorOptions = Parameters<HugeRTE["init"]>[0];
export type CopyProps<T> = { [P in keyof T]: any };
export interface IPropTypes {
cdnVersion: string;
id: string;
init: EditorOptions & { selector?: undefined; target?: undefined };
initialValue: string;
outputFormat: "html" | "text";
inline: boolean;
modelEvents: string[] | string;
plugins: string[] | string;
tagName: string;
toolbar: string[] | string;
modelValue: string;
disabled: boolean;
hugerteScriptSrc: string;
}
export const editorProps: CopyProps<IPropTypes> = {
cdnVersion: String,
id: String,
init: Object,
initialValue: String,
inline: Boolean,
modelEvents: [String, Array],
plugins: [String, Array],
tagName: String,
toolbar: [String, Array],
modelValue: String,
disabled: Boolean,
hugerteScriptSrc: String,
outputFormat: {
type: String,
validator: (prop: string) => prop === "html" || prop === "text"
}
};
@@ -0,0 +1 @@
export declare const pLoadVersion: (version: string) => Promise<void>;
@@ -0,0 +1,56 @@
import { cleanupGlobalHugeRTE } from "./TestHelpers";
/* Based on code from TinyMCE, MODIFIED */
/* See LICENSE.txt for the original license information */
const loadScript = (url, success, failure) => {
const script = document.createElement("script");
script.src = url;
const onLoad = () => {
script.removeEventListener("load", onLoad);
script.removeEventListener("error", onError);
success();
};
const onError = () => {
script.removeEventListener("error", onError);
script.removeEventListener("load", onLoad);
failure(new Error(`Failed to load script: ${url}`));
};
script.addEventListener("load", onLoad);
script.addEventListener("error", onError);
document.body.appendChild(script);
};
const getHugeRTE = () => globalThis.hugerte;
const setHugeRTEBaseUrl = (hugerte, baseUrl) => {
const prefix = document.location.protocol + "//" + document.location.host;
hugerte.baseURL = baseUrl.indexOf("://") === -1 ? prefix + baseUrl : baseUrl;
hugerte.baseURI = new hugerte.util.URI(hugerte.baseURL);
};
const updateHugeRTEUrls = (packageName) => {
const hugerte = getHugeRTE();
if (hugerte) {
setHugeRTEBaseUrl(hugerte, `/project/node_modules/${packageName}`);
}
};
const versionToPackageName = (version) => (version === "latest" ? "hugerte" : `hugerte-${version}`);
const unload = () => {
const hugerte = getHugeRTE();
if (hugerte) {
hugerte.remove();
}
cleanupGlobalHugeRTE();
};
const load = (version, success, failure) => {
const packageName = versionToPackageName(version);
unload();
loadScript(
`/project/node_modules/${packageName}/hugerte.min.js`,
() => {
updateHugeRTEUrls(versionToPackageName(version));
success();
},
failure
);
};
export const pLoadVersion = (version) =>
new Promise((resolve, reject) => {
load(version, resolve, reject);
});
@@ -0,0 +1,12 @@
export type CallbackFn = () => void;
export interface IStateObj {
listeners: CallbackFn[];
scriptId: string;
scriptLoaded: boolean;
}
interface ScriptLoader {
load: (doc: Document, url: string, callback: CallbackFn) => void;
reinitialize: () => void;
}
declare const ScriptLoader: ScriptLoader;
export { ScriptLoader };
@@ -0,0 +1,47 @@
import { uuid } from "./Utils";
const createState = () => ({
listeners: [],
scriptId: uuid("hugerte-script"),
scriptLoaded: false
});
const CreateScriptLoader = () => {
let state = createState();
const injectScriptTag = (scriptId, doc, url, callback) => {
const scriptTag = doc.createElement("script");
scriptTag.referrerPolicy = "origin";
scriptTag.type = "application/javascript";
scriptTag.id = scriptId;
scriptTag.src = url;
const handler = () => {
scriptTag.removeEventListener("load", handler);
callback();
};
scriptTag.addEventListener("load", handler);
if (doc.head) {
doc.head.appendChild(scriptTag);
}
};
const load = (doc, url, callback) => {
if (state.scriptLoaded) {
callback();
} else {
state.listeners.push(callback);
if (!doc.getElementById(state.scriptId)) {
injectScriptTag(state.scriptId, doc, url, () => {
state.listeners.forEach((fn) => fn());
state.scriptLoaded = true;
});
}
}
};
// Only to be used by tests.
const reinitialize = () => {
state = createState();
};
return {
load,
reinitialize
};
};
const ScriptLoader = CreateScriptLoader();
export { ScriptLoader };
@@ -0,0 +1,3 @@
/** Function to clean up and remove HugeRTE-related scripts and links from the document */
declare const cleanupGlobalHugeRTE: () => void;
export { cleanupGlobalHugeRTE };
@@ -0,0 +1,15 @@
import { ScriptLoader } from "./ScriptLoader";
/** Function to clean up and remove HugeRTE-related scripts and links from the document */
const cleanupGlobalHugeRTE = () => {
ScriptLoader.reinitialize();
delete globalThis.hugerte;
delete globalThis.hugeRTE;
/** Helper function to check if an element has a HugeRTE-related URI in a specific attribute */
const hasHugeRTEUri = (attrName) => (elm) => {
const src = elm.getAttribute(attrName);
return src != null && src.includes("hugerte");
};
// Find all script and link elements that have a HugeRTE-related URI
[...Array.from(document.querySelectorAll("script")).filter(hasHugeRTEUri("src")), ...Array.from(document.querySelectorAll("link")).filter(hasHugeRTEUri("href"))].forEach((elm) => elm.remove());
};
export { cleanupGlobalHugeRTE };
@@ -0,0 +1,17 @@
export interface FakeHugeRTE {
majorVersion: string;
minorVersion: string;
}
export interface HugeRTE extends FakeHugeRTE {
baseURL: string;
baseURI: any;
PluginManager: any;
util: {
URI: any;
};
remove(): void;
remove(selector: string | Editor): Editor | void;
}
export interface Editor {
editorManager: any;
}
@@ -0,0 +1 @@
export {};
@@ -0,0 +1,5 @@
export declare const uuid: (prefix: string) => string;
export declare const validEvents: string[];
export declare const isTextarea: (element?: Element | null) => element is HTMLTextAreaElement;
export declare const normalizePluginArray: (plugins?: string | string[]) => string[];
export declare const mergePlugins: (initPlugins?: string | string[], inputPlugins?: string | string[]) => string[];
@@ -0,0 +1,85 @@
let unique = 0;
export const uuid = (prefix) => {
const time = Date.now();
const random = Math.floor(Math.random() * 1000000000);
unique++;
return prefix + "_" + random + unique + String(time);
};
export const validEvents = [
"Activate",
"AddUndo",
"BeforeAddUndo",
"BeforeExecCommand",
"BeforeGetContent",
"BeforeRenderUI",
"BeforeSetContent",
"BeforePaste",
"Blur",
"Change",
"ClearUndos",
"Click",
"CommentChange", // TODO has not been in angular
"CompositionEnd",
"CompositionStart",
"CompositionUpdate",
"ContextMenu",
"Copy",
"Cut",
"Dblclick",
"Deactivate",
"Dirty",
"Drag",
"DragDrop",
"DragEnd",
"DragGesture",
"DragOver",
"Drop",
"ExecCommand",
"Focus",
"FocusIn",
"FocusOut",
"GetContent",
"Hide",
"Init",
"Input",
"KeyDown",
"KeyPress",
"KeyUp",
"LoadContent",
"MouseDown",
"MouseEnter",
"MouseLeave",
"MouseMove",
"MouseOut",
"MouseOver",
"MouseUp",
"NodeChange",
"ObjectResizeStart",
"ObjectResized",
"ObjectSelected",
"Paste",
"PostProcess",
"PostRender",
"PreProcess",
"ProgressState",
"Redo",
"Remove",
"Reset",
"ResizeEditor", // TODO only in svelte and angular, not in Vue – add to changelog at least. also check react.
"SaveContent",
"SelectionChange",
"SetAttrib",
"SetContent",
"Show",
"Submit",
"Undo",
"VisualAid"
];
export const isTextarea = (element) => (element === null || element === void 0 ? void 0 : element.tagName.toLowerCase()) === "textarea";
export const normalizePluginArray = (plugins) => {
if (typeof plugins === "undefined" || plugins === "") {
return [];
}
return Array.isArray(plugins) ? plugins : plugins.split(/[ ,]/);
};
export const mergePlugins = (initPlugins, inputPlugins) => normalizePluginArray(initPlugins).concat(normalizePluginArray(inputPlugins));
@@ -0,0 +1,5 @@
export * from "./Loader";
export * from "./ScriptLoader";
export * from "./TestHelpers";
export * from "./Types";
export * from "./Utils";
@@ -0,0 +1,5 @@
export * from "./Loader";
export * from "./ScriptLoader";
export * from "./TestHelpers";
export * from "./Types";
export * from "./Utils";
@@ -0,0 +1,30 @@
<template>
<a-modal title="新增账户" v-bind="$attrs" :visible="true">
<a-form :model="form" auto-label-width>
<a-form-item field="email" label="Email地址">
<template #extra>
<div>系统默认使用 IMAP 和 SMTP 协议,请确保邮箱已开启对应服务。</div>
</template>
<a-input v-model="form.email" />
</a-form-item>
<a-form-item field="password" label="密码">
<template #extra>
<div>请填写邮箱的授权码,而非登录密码。</div>
</template>
<a-input v-model="form.password" />
</a-form-item>
</a-form>
</a-modal>
</template>
<script setup lang="ts">
import { reactive } from "vue";
const form = reactive({
email: "",
password: ""
});
</script>
<style lang="scss" scoped></style>
@@ -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>