mirror of
https://github.com/MCSManager/MCSManager.git
synced 2024-12-21 07:49: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']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
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 { t } from "@/lang/i18n";
|
||||
import type { AntColumnsType } from "@/types/ant";
|
||||
import UploadFileDialogVue from "./UploadFileDialog.vue";
|
||||
|
||||
interface DockerConfigItem {
|
||||
host: string;
|
||||
@ -15,6 +16,10 @@ interface PortConfigItem extends DockerConfigItem {
|
||||
protocol: string;
|
||||
}
|
||||
|
||||
export async function useUploadFileDialog() {
|
||||
return (await useMountComponent().mount<string>(UploadFileDialogVue)) || "";
|
||||
}
|
||||
|
||||
export async function useSelectInstances() {
|
||||
return await useMountComponent().mount<UserInstance[]>(SelectInstances);
|
||||
}
|
||||
|
@ -39,6 +39,7 @@ import TitleCard from "@/widgets/TitleCard.vue";
|
||||
import LoginCard from "@/widgets/LoginCard.vue";
|
||||
import DefaultCard from "@/widgets/DefaultCard.vue";
|
||||
import Carousel from "@/widgets/others/Carousel.vue";
|
||||
import PluginCard from "@/widgets/others/PluginCard.vue";
|
||||
|
||||
import { NEW_CARD_TYPE } from "../types/index";
|
||||
import { ROLE } from "./router";
|
||||
@ -81,7 +82,8 @@ export const LAYOUT_CARD_TYPES: { [key: string]: any } = {
|
||||
Schedule,
|
||||
InstanceShortcut,
|
||||
DefaultCard,
|
||||
Carousel
|
||||
Carousel,
|
||||
PluginCard
|
||||
};
|
||||
|
||||
export interface NewCardItem extends LayoutCard {
|
||||
@ -471,6 +473,19 @@ export function getLayoutCardPool() {
|
||||
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;
|
||||
|
@ -1,3 +1,5 @@
|
||||
/* eslint-disable vue/one-component-per-file */
|
||||
|
||||
import { createApp, type Component } from "vue";
|
||||
import { sleep } from "@/tools/common";
|
||||
|
||||
@ -30,3 +32,20 @@ export function useMountComponent(data: Record<string, any> = {}) {
|
||||
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;
|
||||
|
||||
public async subscribe<T>(config: RequestConfig): Promise<T | undefined> {
|
||||
if (!config.url) throw new Error("ApiService: RequestConfig: 'url' is empty!");
|
||||
|
||||
config = _.cloneDeep(config);
|
||||
// filter and clean up expired cache tables
|
||||
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