mirror of
https://github.com/MCSManager/MCSManager.git
synced 2024-12-27 07:59:08 +08:00
Feat: add pluginCard & sandbox
This commit is contained in:
parent
d42adcebc3
commit
43e1187862
1
frontend/components.d.ts
vendored
1
frontend/components.d.ts
vendored
@ -88,5 +88,6 @@ declare module 'vue' {
|
|||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
SelectInstances: typeof import('./src/components/fc/SelectInstances.vue')['default']
|
SelectInstances: typeof import('./src/components/fc/SelectInstances.vue')['default']
|
||||||
|
UploadFileDialog: typeof import('./src/components/fc/UploadFileDialog.vue')['default']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
104
frontend/src/components/fc/UploadFileDialog.vue
Normal file
104
frontend/src/components/fc/UploadFileDialog.vue
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import type { UploadProps } from "ant-design-vue";
|
||||||
|
import { t } from "@/lang/i18n";
|
||||||
|
import { FolderOpenOutlined, UploadOutlined } from "@ant-design/icons-vue";
|
||||||
|
import { message } from "ant-design-vue";
|
||||||
|
import { uploadFile } from "@/services/apis/layout";
|
||||||
|
import type { MountComponent } from "../../types/index";
|
||||||
|
|
||||||
|
const { state, execute } = uploadFile();
|
||||||
|
|
||||||
|
const props = defineProps<MountComponent>();
|
||||||
|
const uploadControl = new AbortController();
|
||||||
|
const open = ref(false);
|
||||||
|
const percentComplete = ref(0);
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
let componentResolve: (filePath: string) => void;
|
||||||
|
|
||||||
|
const openDialog = (): Promise<string> => {
|
||||||
|
open.value = true;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
componentResolve = resolve;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = (path = "") => {
|
||||||
|
open.value = false;
|
||||||
|
componentResolve(path);
|
||||||
|
if (props.destroyComponent) {
|
||||||
|
props.emitResult(path);
|
||||||
|
props.destroyComponent();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const beforeUpload: UploadProps["beforeUpload"] = async (file) => {
|
||||||
|
const uploadFormData = new FormData();
|
||||||
|
uploadFormData.append("file", file);
|
||||||
|
try {
|
||||||
|
const res = await execute({
|
||||||
|
data: uploadFormData,
|
||||||
|
timeout: Number.MAX_SAFE_INTEGER,
|
||||||
|
signal: uploadControl.signal,
|
||||||
|
onUploadProgress: (progressEvent: any) => {
|
||||||
|
percentComplete.value = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.value) {
|
||||||
|
message.success(t("TXT_CODE_773f36a0"));
|
||||||
|
submit(`/upload_files/${res.value}`);
|
||||||
|
} else {
|
||||||
|
submit("");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
reportError(error);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
uploadControl.abort();
|
||||||
|
submit();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
openDialog();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-modal
|
||||||
|
v-model:open="open"
|
||||||
|
:title="t('上传文件')"
|
||||||
|
:closable="false"
|
||||||
|
:destroy-on-close="true"
|
||||||
|
width="400px"
|
||||||
|
@cancel="cancel"
|
||||||
|
>
|
||||||
|
<div class="upload-container">
|
||||||
|
<a-upload
|
||||||
|
:max-count="1"
|
||||||
|
:disabled="percentComplete > 0"
|
||||||
|
:show-upload-list="false"
|
||||||
|
:before-upload="beforeUpload"
|
||||||
|
>
|
||||||
|
<a-button type="primary" :loading="percentComplete > 0">
|
||||||
|
<FolderOpenOutlined v-if="percentComplete === 0" />
|
||||||
|
{{ percentComplete > 0 ? t("TXT_CODE_b625dbf0") + percentComplete + "%" : t("选择文件") }}
|
||||||
|
</a-button>
|
||||||
|
</a-upload>
|
||||||
|
<a-button class="ml-16" @click="cancel">{{ t("TXT_CODE_a0451c97") }}</a-button>
|
||||||
|
</div>
|
||||||
|
<template #footer> </template>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.upload-container {
|
||||||
|
height: 100px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
@ -6,6 +6,7 @@ import CmdAssistantDialog from "@/components/fc/CmdAssistantDialog/index.vue";
|
|||||||
import KvOptionsDialogVue from "@/components/fc/KvOptionsDialog.vue";
|
import KvOptionsDialogVue from "@/components/fc/KvOptionsDialog.vue";
|
||||||
import { t } from "@/lang/i18n";
|
import { t } from "@/lang/i18n";
|
||||||
import type { AntColumnsType } from "@/types/ant";
|
import type { AntColumnsType } from "@/types/ant";
|
||||||
|
import UploadFileDialogVue from "./UploadFileDialog.vue";
|
||||||
|
|
||||||
interface DockerConfigItem {
|
interface DockerConfigItem {
|
||||||
host: string;
|
host: string;
|
||||||
@ -15,6 +16,10 @@ interface PortConfigItem extends DockerConfigItem {
|
|||||||
protocol: string;
|
protocol: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function useUploadFileDialog() {
|
||||||
|
return (await useMountComponent().mount<string>(UploadFileDialogVue)) || "";
|
||||||
|
}
|
||||||
|
|
||||||
export async function useSelectInstances() {
|
export async function useSelectInstances() {
|
||||||
return await useMountComponent().mount<UserInstance[]>(SelectInstances);
|
return await useMountComponent().mount<UserInstance[]>(SelectInstances);
|
||||||
}
|
}
|
||||||
|
@ -39,6 +39,7 @@ import TitleCard from "@/widgets/TitleCard.vue";
|
|||||||
import LoginCard from "@/widgets/LoginCard.vue";
|
import LoginCard from "@/widgets/LoginCard.vue";
|
||||||
import DefaultCard from "@/widgets/DefaultCard.vue";
|
import DefaultCard from "@/widgets/DefaultCard.vue";
|
||||||
import Carousel from "@/widgets/others/Carousel.vue";
|
import Carousel from "@/widgets/others/Carousel.vue";
|
||||||
|
import PluginCard from "@/widgets/others/PluginCard.vue";
|
||||||
|
|
||||||
import { NEW_CARD_TYPE } from "../types/index";
|
import { NEW_CARD_TYPE } from "../types/index";
|
||||||
import { ROLE } from "./router";
|
import { ROLE } from "./router";
|
||||||
@ -81,7 +82,8 @@ export const LAYOUT_CARD_TYPES: { [key: string]: any } = {
|
|||||||
Schedule,
|
Schedule,
|
||||||
InstanceShortcut,
|
InstanceShortcut,
|
||||||
DefaultCard,
|
DefaultCard,
|
||||||
Carousel
|
Carousel,
|
||||||
|
PluginCard
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface NewCardItem extends LayoutCard {
|
export interface NewCardItem extends LayoutCard {
|
||||||
@ -471,6 +473,19 @@ export function getLayoutCardPool() {
|
|||||||
type: "instance"
|
type: "instance"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: getRandomId(),
|
||||||
|
permission: ROLE.GUEST,
|
||||||
|
meta: {},
|
||||||
|
type: "PluginCard",
|
||||||
|
title: t("扩展页面卡片"),
|
||||||
|
width: 6,
|
||||||
|
description: t(
|
||||||
|
"此卡片可以上传自定义 HTML 页面并直接执行 Javascript 脚本,可以直接使用网页上所有元素,适用于 Web 前端开发人员。"
|
||||||
|
),
|
||||||
|
height: LayoutCardHeight.MEDIUM,
|
||||||
|
category: NEW_CARD_TYPE.COMMON
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
return LAYOUT_CARD_POOL;
|
return LAYOUT_CARD_POOL;
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
/* eslint-disable vue/one-component-per-file */
|
||||||
|
|
||||||
import { createApp, type Component } from "vue";
|
import { createApp, type Component } from "vue";
|
||||||
import { sleep } from "@/tools/common";
|
import { sleep } from "@/tools/common";
|
||||||
|
|
||||||
@ -30,3 +32,20 @@ export function useMountComponent(data: Record<string, any> = {}) {
|
|||||||
mount
|
mount
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// export function useFcComponent<T>(component: Component, data: Record<string, any> = {}): T {
|
||||||
|
// const div = document.createElement("div");
|
||||||
|
// document.body.appendChild(div);
|
||||||
|
// const app = createApp(component, {
|
||||||
|
// ...data,
|
||||||
|
// async destroyComponent(delay = 1000) {
|
||||||
|
// await sleep(delay);
|
||||||
|
// app.unmount();
|
||||||
|
// div.remove();
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
// console.debug("XZXZ:", component);
|
||||||
|
// app.mount(div);
|
||||||
|
|
||||||
|
// return app._instance?.exposed as T;
|
||||||
|
// }
|
||||||
|
@ -37,6 +37,8 @@ class ApiService {
|
|||||||
private readonly REQUEST_CACHE_TIME = 100;
|
private readonly REQUEST_CACHE_TIME = 100;
|
||||||
|
|
||||||
public async subscribe<T>(config: RequestConfig): Promise<T | undefined> {
|
public async subscribe<T>(config: RequestConfig): Promise<T | undefined> {
|
||||||
|
if (!config.url) throw new Error("ApiService: RequestConfig: 'url' is empty!");
|
||||||
|
|
||||||
config = _.cloneDeep(config);
|
config = _.cloneDeep(config);
|
||||||
// filter and clean up expired cache tables
|
// filter and clean up expired cache tables
|
||||||
this.responseMap.forEach((value, key) => {
|
this.responseMap.forEach((value, key) => {
|
||||||
|
88
frontend/src/tools/sandbox.ts
Normal file
88
frontend/src/tools/sandbox.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
class SandboxBridge {
|
||||||
|
[key: string | symbol | number]: any;
|
||||||
|
|
||||||
|
private _callbacks = new Map<string, Function[]>();
|
||||||
|
|
||||||
|
// API
|
||||||
|
public $axios = axios;
|
||||||
|
public $realWindow = window;
|
||||||
|
|
||||||
|
public $onMounted(callback: Function) {
|
||||||
|
this._addCallback("onMounted", callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
public $onUnmounted(callback: Function) {
|
||||||
|
this._addCallback("onUnmounted", callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
public $emit(event: string, ...args: any[]) {
|
||||||
|
const callbacks = this._callbacks.get(event);
|
||||||
|
if (callbacks && callbacks.length > 0) {
|
||||||
|
callbacks.forEach((callback) => {
|
||||||
|
try {
|
||||||
|
callback.call(null, ...args);
|
||||||
|
} catch (error) {
|
||||||
|
reportError(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public $destroySandbox() {
|
||||||
|
this.$emit("onUnmounted");
|
||||||
|
// this._callbacks.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public $mountSandbox() {
|
||||||
|
this.$emit("onMounted");
|
||||||
|
}
|
||||||
|
|
||||||
|
private _addCallback(event: string, callback: Function) {
|
||||||
|
const callbacks = this._callbacks.get(event) || [];
|
||||||
|
callbacks.push(callback);
|
||||||
|
this._callbacks.set(event, callbacks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ProxySandBox {
|
||||||
|
public proxyWindow;
|
||||||
|
public fakeWindow: SandboxBridge;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.fakeWindow = new SandboxBridge();
|
||||||
|
this.proxyWindow = new Proxy(this.fakeWindow, {
|
||||||
|
set: (target, prop, value) => {
|
||||||
|
target[prop] = value;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
get: (target, prop: any) => {
|
||||||
|
const attr = prop in target ? target[prop] : window[prop];
|
||||||
|
if (typeof attr === "function") {
|
||||||
|
return attr.bind(prop in target ? target : window);
|
||||||
|
}
|
||||||
|
return attr;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public executeJavascript(code: string) {
|
||||||
|
const proxyFunc = Function("window", `"use strict";\n${code}`);
|
||||||
|
proxyFunc?.call(this.proxyWindow, this.proxyWindow);
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy() {
|
||||||
|
this.fakeWindow.$destroySandbox();
|
||||||
|
}
|
||||||
|
|
||||||
|
public mount() {
|
||||||
|
this.fakeWindow.$mountSandbox();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProxySandbox(code = "") {
|
||||||
|
const sandbox = new ProxySandBox();
|
||||||
|
sandbox.executeJavascript(code);
|
||||||
|
return sandbox;
|
||||||
|
}
|
107
frontend/src/widgets/others/PluginCard.vue
Normal file
107
frontend/src/widgets/others/PluginCard.vue
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from "@/lang/i18n";
|
||||||
|
import type { LayoutCard } from "../../types/index";
|
||||||
|
import { useUploadFileDialog } from "@/components/fc";
|
||||||
|
import { useLayoutCardTools } from "../../hooks/useCardTools";
|
||||||
|
import { ref, onMounted, onUnmounted } from "vue";
|
||||||
|
import { useLayoutContainerStore } from "../../stores/useLayoutContainerStore";
|
||||||
|
import axios from "axios";
|
||||||
|
import { v4 } from "uuid";
|
||||||
|
import { ProxySandBox } from "../../tools/sandbox";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
card: LayoutCard;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const DOM_ID = v4().replace(/[-]/g, "");
|
||||||
|
const { containerState } = useLayoutContainerStore();
|
||||||
|
const { getMetaValue, setMetaValue } = useLayoutCardTools(props.card);
|
||||||
|
|
||||||
|
const originUrl = ref(getMetaValue<string>("url", ""));
|
||||||
|
let sandbox: ProxySandBox;
|
||||||
|
|
||||||
|
const uploadHtmlFile = async () => {
|
||||||
|
originUrl.value = await useUploadFileDialog();
|
||||||
|
setMetaValue("url", originUrl.value);
|
||||||
|
await loadRemoteHtml();
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadRemoteHtml = async () => {
|
||||||
|
if (!originUrl.value || !originUrl.value.includes(".html")) return;
|
||||||
|
|
||||||
|
const { data } = await axios.get<string>(originUrl.value);
|
||||||
|
const dom = document.getElementById(DOM_ID);
|
||||||
|
if (data && dom) {
|
||||||
|
dom.innerHTML = data;
|
||||||
|
const scriptDoms = dom.querySelectorAll("script");
|
||||||
|
sandbox = new ProxySandBox();
|
||||||
|
for (const remoteScript of scriptDoms) {
|
||||||
|
if (remoteScript.src) {
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = remoteScript.src;
|
||||||
|
script.lang = remoteScript.lang;
|
||||||
|
document.head.appendChild(script);
|
||||||
|
}
|
||||||
|
if (remoteScript.textContent) {
|
||||||
|
sandbox.executeJavascript(remoteScript.textContent);
|
||||||
|
sandbox.mount();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadRemoteHtml().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (sandbox) {
|
||||||
|
sandbox.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<card-panel>
|
||||||
|
<template #title>
|
||||||
|
<div class="flex">
|
||||||
|
{{ card.title }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #body>
|
||||||
|
<div class="plugin-card-container">
|
||||||
|
<div v-if="containerState.isDesignMode">
|
||||||
|
<a-typography-paragraph>
|
||||||
|
<p>
|
||||||
|
{{ t("支持上传 HTML 文件,此卡片会加载 HTML 并运行 Javascript 代码。") }}
|
||||||
|
<br />
|
||||||
|
{{ t("使用其他人分享的文件可能会导致面板被入侵。") }}
|
||||||
|
</p>
|
||||||
|
<div v-if="originUrl">{{ t("HTML 文件:") }}</div>
|
||||||
|
<div v-if="originUrl" class="mt-16 mb-16">
|
||||||
|
<code class="p-8"> {{ originUrl }}</code>
|
||||||
|
</div>
|
||||||
|
<a-button class="mt-8" type="primary" @click="uploadHtmlFile">
|
||||||
|
{{ t("上传 HTML 文件") }}
|
||||||
|
</a-button>
|
||||||
|
</a-typography-paragraph>
|
||||||
|
</div>
|
||||||
|
<div v-else :id="DOM_ID" class="html-plugin-container">
|
||||||
|
<!-- Remote HTML -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</card-panel>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.plugin-card-container,
|
||||||
|
.html-plugin-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
</style>
|
Loading…
Reference in New Issue
Block a user