最近部门领导有个需求,要在金蝶中展示ai帮我们生成的html看板。

我研究了一下,可以在开发平台新建应用、新建页面,然后有几个方案可以选择:html控件、iframe控件、自定义控件。

html控件就是直接粘贴html源码进去,优点是傻瓜操作,缺点是css、js基本上都会冲突,而且十分不方便。

iframe+src到外部链接的优点是非常方便,缺点是需要外部服务器,并且无法在金蝶中设置权限,容易被爬。

自定义控件就很合适了,相当于用api帮我们渲染出一个页面来,非常丝滑。缺点是配置稍微麻烦一点,而且不能直接上传html,要稍微转化一下。

可以用本文这个Python 脚本,将完整的index.html自动转换成金蝶自定义控件所需要的index.js。

一、文件结构

首先准备一个目录:

项目目录/
├── index.html
└── convert.py

其中index.html就是需要在金蝶云星瀚中展示的完整 HTML 页面。

运行 Python 脚本后,会自动生成:

项目目录/
├── index.html
├── convert.py
└── index.js

最终只需要将index.js用于金蝶自定义控件即可。

二、转换原理

脚本不会把 HTML 手工拆成 HTML、CSS、JavaScript 三部分,而是直接读取整个index.html:

index.html
    ↓
读取完整 HTML
    ↓
转换成 JavaScript 字符串
    ↓
写入 iframe.srcdoc
    ↓
生成 index.js

生成的自定义控件结构如下:

金蝶页面
└── 自定义控件
    └── iframe
        └── index.html
            ├── HTML
            ├── CSS
            └── JavaScript

这样原来的 HTML 页面拥有相对独立的运行环境,可以减少与金蝶页面之间的 CSS、JavaScript 冲突。

三、完整 Python 转换程序

将下面代码保存为convert.py,与index.html放在同一目录。

import json
from pathlib import Path

# ============================================================
# 配置
# ============================================================
INPUT_HTML = "index.html"
OUTPUT_JS = "index.js"
# 金蝶自定义控件 Schema ID
SCHEMA_ID = "bank_account_dashboard"
# index.html 的设计尺寸
DESIGN_WIDTH = 1920
DESIGN_HEIGHT = 1080

# ============================================================
# 读取 index.html
# ============================================================
input_path = Path(INPUT_HTML)
if not input_path.exists():
    raise FileNotFoundError(
        "找不到 index.html,请将 Python 脚本和 index.html 放在同一个目录。"
    )

html = input_path.read_text(
    encoding="utf-8"
)

# ============================================================
# 将完整 HTML 转换成 JavaScript 字符串
#
# 使用 json.dumps,而不是 Python f-string。
# 这样 HTML、CSS、JavaScript 中的 {} 不会造成语法错误。
# ============================================================
html_json = json.dumps(
    html,
    ensure_ascii=False
)

# ============================================================
# 生成金蝶自定义控件
# ============================================================
js_template = r"""
(function () {
    var SCHEMA_ID = "__SCHEMA_ID__";
    var DESIGN_WIDTH = __DESIGN_WIDTH__;
    var DESIGN_HEIGHT = __DESIGN_HEIGHT__;
    /*
     * 完整的 index.html
     */
    var HTML_CONTENT = __HTML_CONTENT__;

    /*
     * ========================================================
     * 自定义控件
     * ========================================================
     */
    function MyComponent(model) {
        this.model = model;
        this.iframe = null;
        this.resizeObserver = null;
        this.resizeHandler = null;
    }

    /*
     * ========================================================
     * 初始化
     * ========================================================
     */
    MyComponent.prototype.init = function (props) {
        this.render(props);
    };

    /*
     * ========================================================
     * 更新
     * ========================================================
     */
    MyComponent.prototype.update = function (props) {
        if (this.iframe) {
            this.resize();
            return;
        }
        this.render(props);
    };

    /*
     * ========================================================
     * 创建 iframe
     * ========================================================
     */
    MyComponent.prototype.render = function (props) {
        var model = this.model;
        if (!model || !model.dom) {
            return;
        }

        /*
         * 限制自定义控件自身边界
         */
        model.dom.style.position = "relative";
        model.dom.style.width = "100%";
        model.dom.style.height = "100%";
        model.dom.style.margin = "0";
        model.dom.style.padding = "0";
        model.dom.style.overflow = "hidden";
        model.dom.style.boxSizing = "border-box";

        /*
         * 清空原控件
         */
        model.dom.innerHTML = "";

        /*
         * 外层容器
         */
        var root = document.createElement("div");
        root.style.position = "absolute";
        root.style.left = "0";
        root.style.top = "0";
        root.style.width = "100%";
        root.style.height = "100%";
        root.style.margin = "0";
        root.style.padding = "0";
        root.style.overflow = "hidden";
        root.style.boxSizing = "border-box";

        /*
         * 创建 iframe
         */
        var iframe = document.createElement("iframe");
        iframe.style.position = "absolute";
        iframe.style.left = "50%";
        iframe.style.top = "50%";
        /*
         * iframe 使用原页面设计尺寸
         */
        iframe.style.width =
            DESIGN_WIDTH + "px";
        iframe.style.height =
            DESIGN_HEIGHT + "px";
        iframe.style.margin = "0";
        iframe.style.padding = "0";
        iframe.style.border = "0";
        iframe.style.display = "block";
        iframe.style.boxSizing = "border-box";
        iframe.style.transformOrigin =
            "center center";
        iframe.style.outline = "none";

        /*
         * 禁止 iframe 滚动条
         */
        iframe.setAttribute(
            "frameborder",
            "0"
        );
        iframe.setAttribute(
            "scrolling",
            "no"
        );

        /*
         * 插入 iframe
         */
        root.appendChild(iframe);
        model.dom.appendChild(root);

        /*
         * 保存引用
         */
        this.iframe = iframe;
        this.root = root;

        /*
         * 使用 srcdoc 加载完整 HTML
         */
        try {
            iframe.srcdoc = HTML_CONTENT;
        } catch (e) {
            console.error(
                "[KDE Custom Control] srcdoc 加载失败:",
                e
            );
        }

        /*
         * 监听尺寸变化
         */
        this.bindResize();

        /*
         * 第一次调整尺寸
         */
        this.resize();

        /*
         * 金蝶页面布局可能是异步的,
         * 延迟重新计算几次。
         */
        var self = this;

        setTimeout(function () {
            self.resize();
        }, 50);

        setTimeout(function () {
            self.resize();
        }, 200);

        setTimeout(function () {
            self.resize();
        }, 500);

        setTimeout(function () {
            self.resize();
        }, 1000);
    };

    /*
     * ========================================================
     * 自适应缩放
     *
     * 保持原页面比例,不裁剪。
     * ========================================================
     */
    MyComponent.prototype.resize = function () {
        var model = this.model;
        var iframe = this.iframe;
        if (!model || !model.dom || !iframe) {
            return;
        }

        var width =
            model.dom.clientWidth;

        var height =
            model.dom.clientHeight;

        if (
            width <= 0 ||
            height <= 0
        ) {
            return;
        }

        /*
         * 横向缩放比例
         */
        var scaleX =
            width / DESIGN_WIDTH;

        /*
         * 纵向缩放比例
         */
        var scaleY =
            height / DESIGN_HEIGHT;

        /*
         * 取较小值。
         *
         * 确保整个页面都能显示,
         * 不裁剪、不变形。
         */
        var scale =
            Math.min(
                scaleX,
                scaleY
            );

        /*
         * 居中显示
         */
        iframe.style.left = "50%";
        iframe.style.top = "50%";

        iframe.style.transform =
            "translate(-50%, -50%) scale(" +
            scale +
            ")";
    };

    /*
     * ========================================================
     * 监听控件尺寸变化
     * ========================================================
     */
    MyComponent.prototype.bindResize = function () {
        var self = this;

        /*
         * 解除旧监听
         */
        this.unbindResize();

        /*
         * 浏览器窗口变化
         */
        this.resizeHandler = function () {
            self.resize();
        };

        window.addEventListener(
            "resize",
            this.resizeHandler
        );

        /*
         * 监听金蝶自定义控件本身的尺寸变化
         */
        if (
            typeof ResizeObserver !== "undefined"
        ) {
            this.resizeObserver =
                new ResizeObserver(
                    function () {
                        self.resize();
                    }
                );

            this.resizeObserver.observe(
                this.model.dom
            );
        }
    };

    /*
     * ========================================================
     * 解除监听
     * ========================================================
     */
    MyComponent.prototype.unbindResize = function () {
        if (this.resizeHandler) {
            window.removeEventListener(
                "resize",
                this.resizeHandler
            );
            this.resizeHandler = null;
        }

        if (this.resizeObserver) {
            try {
                this.resizeObserver.disconnect();
            } catch (e) {}
            this.resizeObserver = null;
        }
    };

    /*
     * ========================================================
     * 销毁
     * ========================================================
     */
    MyComponent.prototype.destroyed = function () {
        this.unbindResize();
        this.iframe = null;
        this.root = null;
    };

    /*
     * ========================================================
     * 注册金蝶自定义控件
     * ========================================================
     */
    KDApi.register(
        SCHEMA_ID,
        MyComponent
    );

})();
"""

# ============================================================
# 替换变量
#
# 不使用 f-string,
# 避免 JavaScript 中的 {} 引发 Python 语法错误。
# ============================================================
js_code = js_template

js_code = js_code.replace(
    "__SCHEMA_ID__",
    SCHEMA_ID
)

js_code = js_code.replace(
    "__DESIGN_WIDTH__",
    str(DESIGN_WIDTH)
)

js_code = js_code.replace(
    "__DESIGN_HEIGHT__",
    str(DESIGN_HEIGHT)
)

js_code = js_code.replace(
    "__HTML_CONTENT__",
    html_json
)

# ============================================================
# 输出 index.js
# ============================================================
output_path = Path(OUTPUT_JS)
output_path.write_text(
    js_code,
    encoding="utf-8"
)

# ============================================================
# 输出结果
# ============================================================
print("=" * 60)
print("转换成功")
print("=" * 60)
print()
print("输入:")
print(input_path.resolve())
print()
print("输出:")
print(output_path.resolve())
print()
print(
    "设计尺寸:{} × {}".format(
        DESIGN_WIDTH,
        DESIGN_HEIGHT
    )
)
print()
print("模式:iframe + srcdoc")
print("HTML:完整内嵌")
print("外部 HTML:无")
print("保持比例:是")
print("裁剪:否")
print("自动居中:是")
print("自动缩放:是")
print()
print("=" * 60)

四、运行脚本

在当前目录执行:

python convert.py

正常情况下会看到:

============================================================
转换成功
============================================================

输入:
D:\xxx\index.html

输出:
D:\xxx\index.js

设计尺寸:1920 × 1080

模式:iframe + srcdoc
HTML:完整内嵌
外部 HTML:无
保持比例:是
裁剪:否
自动居中:是
自动缩放:是

============================================================

然后目录中就会生成:

index.js

五、上传到金蝶

把index.js上传到自定义控件的方案中,注意我这个控件方案的SCHEMA_ID是bank_account_dashboard(银行资金看板),所以我的方案id也得是bank_account_dashboard。

上传成功后点击“返回数据”,保存控件和页面即可。