Feat: add pluginCard & sandbox

This commit is contained in:
unitwk 2023-12-28 16:44:52 +08:00
parent d42adcebc3
commit 43e1187862
8 changed files with 342 additions and 1 deletions

View File

@ -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']
} }
} }

View 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>

View File

@ -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);
} }

View File

@ -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;

View File

@ -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;
// }

View File

@ -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) => {

View 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;
}

View 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>